fix
Some checks are pending
Check / lint (push) Waiting to run
Check / typecheck (push) Waiting to run
Check / build (build, 18.x, ubuntu-latest) (push) Waiting to run
Check / build (build, 18.x, windows-latest) (push) Waiting to run
Check / build (build:app, 18.x, ubuntu-latest) (push) Waiting to run
Check / build (build:app, 18.x, windows-latest) (push) Waiting to run
Check / build (build:mp-weixin, 18.x, ubuntu-latest) (push) Waiting to run
Check / build (build:mp-weixin, 18.x, windows-latest) (push) Waiting to run

This commit is contained in:
caiyx 2024-11-22 09:06:37 +08:00
parent fd060743bf
commit 2464c15603
76 changed files with 6051 additions and 210 deletions

View File

@ -31,7 +31,9 @@
"@vueuse/core": "^9.13.0",
"axios": "^1.7.2",
"dayjs": "^1.11.12",
"less": "^4.2.0",
"nzh": "^1.0.13",
"pinia-plugin-persistedstate": "^4.1.3",
"vconsole": "^3.15.1",
"vue": "^3.3.8",
"vue-i18n": "^9.6.5"

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,17 @@
<script setup>
import {useStatus} from "@/store/status";
import { useUserStore } from '@/store'
import {
useProvideUserModal,
} from '@/hooks'
import ws from '@/connect'
const {statusBarHeight}= useStatus()
const { uid, isShow } = useProvideUserModal()
const userStore = useUserStore()
const root = document.documentElement
root.style.setProperty('--statusBarHeight',`${statusBarHeight.value}px`)
const init = () => {
userStore.loadSetting()
ws.connect()
}
init()

21
src/api/upload/index.js Normal file
View File

@ -0,0 +1,21 @@
import { post, upload } from '@/utils/upload/request'
// 上传头像裁剪图片服务接口
export const ServeUploadAvatar = (data) => {
return post('/api/v1/upload/avatar', data)
}
// 上传头像裁剪图片服务接口
export const ServeUploadImage = (data) => {
return post('/api/v1/upload/image', data)
}
// 查询大文件拆分信息服务接口
export const ServeFindFileSplitInfo = (data = {}) => {
return post('/api/v1/upload/multipart/initiate', data)
}
// 文件拆分上传服务接口
export const ServeFileSubareaUpload = (data = {}, options = {}) => {
return upload('/api/v1/upload/multipart', data, options)
}

9
src/api/user/index.js Normal file
View File

@ -0,0 +1,9 @@
import request from '@/service/index.js'
export const ServeGetUserSetting = (data) => {
return request({
url: '/api/v1/users/setting',
method: 'GET',
data,
})
}

View File

@ -0,0 +1,41 @@
<script lang="ts" setup>
import { hashStrToHexColor } from '@/utils/common'
import { defAvatar } from '@/constant/default'
defineProps({
src: {
type: String,
default: ''
},
username: {
type: String,
default: ''
},
size: {
type: Number,
default: 30
},
fontSize: {
type: Number,
default: 14
}
})
</script>
<template>
<n-avatar v-if="src.length" round :src="src" :size="size" :fallback-src="defAvatar" />
<n-avatar
v-else
round
:style="{
color: '#ffffff',
backgroundColor: hashStrToHexColor(username || ''),
fontSize: fontSize + 'px'
}"
:size="size"
>
{{ username && username.substring(0, 1) }}
</n-avatar>
</template>
<style lang="less" scoped></style>

View File

@ -0,0 +1,225 @@
<script setup>
import { reactive, ref } from 'vue'
import { NModal, NCard } from 'naive-ui'
import { Close, UploadOne, RefreshOne, Redo, Undo } from '@icon-park/vue-next'
import 'vue-cropper/dist/index.css'
import { VueCropper } from 'vue-cropper'
import { ServeUploadAvatar } from '@/api/upload'
const emit = defineEmits(['close', 'success'])
const state = reactive({
show: true,
src: ''
})
const cropper = ref('cropper')
const option = reactive({
img: '',
size: 1,
full: false,
outputType: 'png',
canMove: true,
fixedBox: true,
original: false,
canMoveBox: true,
autoCrop: true,
autoCropWidth: 250,
autoCropHeight: 250,
centerBox: false,
high: true,
preview: ''
})
const onMaskClick = () => {
emit('close')
}
function onTriggerUpload() {
document.getElementById('upload-avatar').click()
}
const onUpload = (e) => {
let file = e.target.files[0]
let reader = new FileReader()
reader.onload = (e) => {
let data
if (typeof e.target.result === 'object') {
// Array Bufferblob base64
data = window.URL.createObjectURL(new Blob([e.target.result]))
console.log(data, e.target.result)
} else {
data = e.target.result
}
option.img = data
}
reader.readAsArrayBuffer(file)
}
const realTime = (data) => {
cropper.value.getCropData((img) => {
option.preview = img
})
}
const rotateLeft = () => {
cropper.value.rotateLeft()
}
const rotateRight = () => {
cropper.value.rotateRight()
}
const refreshCrop = () => {
cropper.value.refresh()
}
const onSubmit = () => {
cropper.value.getCropBlob((blob) => {
let file = new File([blob], 'avatar.png', {
type: blob.type,
lastModified: Date.now()
})
const form = new FormData()
form.append('file', file)
ServeUploadAvatar(form).then((res) => {
if (res.code == 200) {
emit('success', res.data.avatar)
} else {
window['$message'].info(res.message)
}
})
})
}
</script>
<template>
<input
id="upload-avatar"
type="file"
accept="image/png, image/jpeg, image/jpg, image/webp"
@change="onUpload"
/>
<n-modal v-model:show="state.show" :on-after-leave="onMaskClick">
<n-card style="width: 800px" title="选择头像" :bordered="false" class="modal-radius">
<template #header-extra>
<n-icon size="22" :component="Close" @click="state.show = false" class="pointer" />
</template>
<div class="content">
<div class="canvas">
<vue-cropper
ref="cropper"
:img="option.img"
:output-size="option.size"
:output-type="option.outputType"
:info="true"
:full="option.full"
:can-move="option.canMove"
:can-move-box="option.canMoveBox"
:fixed-box="option.fixedBox"
:original="option.original"
:auto-crop="option.autoCrop"
:auto-crop-width="option.autoCropWidth"
:auto-crop-height="option.autoCropHeight"
:center-box="option.centerBox"
@real-time="realTime"
/>
</div>
<div class="view">
<div class="preview">
<img :src="option.preview" v-show="option.preview" />
</div>
</div>
</div>
<template #footer>
<section class="el-container" style="height: 38px">
<aside
class="el-aside"
style="
width: 400px;
justify-content: space-between;
align-items: center;
display: flex;
padding: 0 5px;
"
>
<n-button @click="onTriggerUpload" type="primary" ghost>
上传图片
<template #icon>
<n-icon :component="UploadOne" />
</template>
</n-button>
<n-button @click="refreshCrop">
重置
<template #icon>
<n-icon :component="RefreshOne" />
</template>
</n-button>
<n-button @click="rotateLeft">
<template #icon>
<n-icon :component="Undo" />
</template>
左转
</n-button>
<n-button @click="rotateRight">
<template #icon>
<n-icon :component="Redo" />
</template>
右转
</n-button>
</aside>
<main class="el-main" style="text-align: center">
<n-button type="primary" @click="onSubmit">保存头像</n-button>
</main>
</section>
</template>
</n-card>
</n-modal>
</template>
<style lang="less" scoped>
#upload-avatar {
display: none;
}
.content {
width: 100%;
height: 400px;
display: flex;
.canvas {
width: 400px;
height: 400px;
padding: 5px;
}
.view {
flex: auto;
display: flex;
align-items: center;
justify-content: center;
.preview {
width: 180px;
height: 180px;
border-radius: 20px;
overflow: hidden;
border: 1px solid var(--border-color);
img {
width: 100%;
height: 100%;
}
}
}
}
</style>

View File

@ -0,0 +1,305 @@
<script setup></script>
<template>
<div class="loading-content">
<div class="ant-spin ant-spin-lg ant-spin-spinning">
<span class="ant-spin-dot ant-spin-dot-spin">
<i class="ant-spin-dot-item" />
<i class="ant-spin-dot-item" />
<i class="ant-spin-dot-item" />
<i class="ant-spin-dot-item" />
</span>
</div>
<p>数据加载中...</p>
</div>
</template>
<style lang="less" scoped>
.loading-content {
width: 100%;
height: 60%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 13px;
p {
margin-top: 10px;
color: rgb(194 194 194);
}
}
/* ant-spin 加载动画 start */
.ant-spin {
-webkit-box-sizing: border-box;
box-sizing: border-box;
margin: 0;
padding: 0;
color: rgba(0, 0, 0, 0.65);
font-size: 14px;
font-variant: tabular-nums;
line-height: 1.5715;
list-style: none;
-webkit-font-feature-settings: 'tnum';
font-feature-settings: 'tnum';
position: absolute;
display: none;
color: #1890ff;
text-align: center;
vertical-align: middle;
opacity: 0;
-webkit-transition: -webkit-transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
transition: -webkit-transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
transition: transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
transition:
transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86),
-webkit-transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
}
.ant-spin-spinning {
position: static;
display: inline-block;
opacity: 1;
}
.ant-spin-nested-loading {
position: relative;
}
.ant-spin-nested-loading > div > .ant-spin {
position: absolute;
top: 0;
left: 0;
z-index: 4;
display: block;
width: 100%;
height: 100%;
max-height: 400px;
}
.ant-spin-nested-loading > div > .ant-spin .ant-spin-dot {
position: absolute;
top: 50%;
left: 50%;
margin: -10px;
}
.ant-spin-nested-loading > div > .ant-spin .ant-spin-text {
position: absolute;
top: 50%;
width: 100%;
padding-top: 5px;
text-shadow: 0 1px 2px #fff;
}
.ant-spin-nested-loading > div > .ant-spin.ant-spin-show-text .ant-spin-dot {
margin-top: -20px;
}
.ant-spin-nested-loading > div > .ant-spin-sm .ant-spin-dot {
margin: -7px;
}
.ant-spin-nested-loading > div > .ant-spin-sm .ant-spin-text {
padding-top: 2px;
}
.ant-spin-nested-loading > div > .ant-spin-sm.ant-spin-show-text .ant-spin-dot {
margin-top: -17px;
}
.ant-spin-nested-loading > div > .ant-spin-lg .ant-spin-dot {
margin: -16px;
}
.ant-spin-nested-loading > div > .ant-spin-lg .ant-spin-text {
padding-top: 11px;
}
.ant-spin-nested-loading > div > .ant-spin-lg.ant-spin-show-text .ant-spin-dot {
margin-top: -26px;
}
.ant-spin-container {
position: relative;
-webkit-transition: opacity 0.3s;
transition: opacity 0.3s;
}
.ant-spin-container::after {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 10;
display: none \9;
width: 100%;
height: 100%;
background: #fff;
opacity: 0;
-webkit-transition: all 0.3s;
transition: all 0.3s;
content: '';
pointer-events: none;
}
.ant-spin-blur {
clear: both;
overflow: hidden;
opacity: 0.5;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
}
.ant-spin-blur::after {
opacity: 0.4;
pointer-events: auto;
}
.ant-spin-tip {
color: rgba(0, 0, 0, 0.45);
}
.ant-spin-dot {
position: relative;
display: inline-block;
font-size: 20px;
width: 1em;
height: 1em;
}
.ant-spin-dot-item {
position: absolute;
display: block;
width: 9px;
height: 9px;
background-color: #1890ff;
border-radius: 100%;
-webkit-transform: scale(0.75);
transform: scale(0.75);
-webkit-transform-origin: 50% 50%;
transform-origin: 50% 50%;
opacity: 0.3;
-webkit-animation: antSpinMove 1s infinite linear alternate;
animation: antSpinMove 1s infinite linear alternate;
}
.ant-spin-dot-item:nth-child(1) {
top: 0;
left: 0;
}
.ant-spin-dot-item:nth-child(2) {
top: 0;
right: 0;
-webkit-animation-delay: 0.4s;
animation-delay: 0.4s;
}
.ant-spin-dot-item:nth-child(3) {
right: 0;
bottom: 0;
-webkit-animation-delay: 0.8s;
animation-delay: 0.8s;
}
.ant-spin-dot-item:nth-child(4) {
bottom: 0;
left: 0;
-webkit-animation-delay: 1.2s;
animation-delay: 1.2s;
}
.ant-spin-dot-spin {
-webkit-transform: rotate(45deg);
transform: rotate(45deg);
-webkit-animation: antRotate 1.2s infinite linear;
animation: antRotate 1.2s infinite linear;
}
.ant-spin-sm .ant-spin-dot {
font-size: 14px;
}
.ant-spin-sm .ant-spin-dot i {
width: 6px;
height: 6px;
}
.ant-spin-lg .ant-spin-dot {
font-size: 32px;
}
.ant-spin-lg .ant-spin-dot i {
width: 14px;
height: 14px;
}
.ant-spin.ant-spin-show-text .ant-spin-text {
display: block;
}
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
.ant-spin-blur {
background: #fff;
opacity: 0.5;
}
}
@-webkit-keyframes antSpinMove {
to {
opacity: 1;
}
}
@keyframes antSpinMove {
to {
opacity: 1;
}
}
@-webkit-keyframes antRotate {
to {
-webkit-transform: rotate(405deg);
transform: rotate(405deg);
}
}
@keyframes antRotate {
to {
-webkit-transform: rotate(405deg);
transform: rotate(405deg);
}
}
.ant-spin-rtl {
direction: rtl;
}
.ant-spin-rtl .ant-spin-dot-spin {
-webkit-transform: rotate(-45deg);
transform: rotate(-45deg);
-webkit-animation-name: antRotateRtl;
animation-name: antRotateRtl;
}
@-webkit-keyframes antRotateRtl {
to {
-webkit-transform: rotate(-405deg);
transform: rotate(-405deg);
}
}
@keyframes antRotateRtl {
to {
-webkit-transform: rotate(-405deg);
transform: rotate(-405deg);
}
}
/* ant-spin 加载动画 end */
</style>

View File

@ -0,0 +1,164 @@
<script setup>
import { NProgress } from 'naive-ui'
import { useUploadsStore } from '@/store'
import { fileFormatSize } from '@/utils/strings'
const uploadsStore = useUploadsStore()
const statusItem = {
0: '等待上传',
1: '上传中',
2: '上传完成',
3: '网络异常'
}
</script>
<template>
<div class="section me-scrollbar me-scrollbar-thumb">
<div class="title bdr-b">
<span>上传管理 ({{ uploadsStore.successCount }}/{{ uploadsStore.items.length }})</span>
<span class="pointer" @click="uploadsStore.close()">关闭</span>
</div>
<div class="file-item" v-for="item in uploadsStore.items" :key="item.upload_id">
<div class="file-header">
<div class="type-icon flex-center">
{{ item.username.substr(0, 1) }}
</div>
<div class="filename">{{ item.username }}</div>
<div class="status">
<span :class="{ success: item.status == 2 }">
{{ statusItem[item.status] }}
</span>
</div>
</div>
<div class="file-mian">
<div class="progress flex-center">
<n-progress
style="width: 60px; height: 60px"
type="circle"
:percentage="item.percentage"
/>
</div>
<div class="detail">
<p>
名称<span>{{ item.file.name }}</span>
</p>
<p>
类型<span>{{ item.file.type || 'text' }}</span>
</p>
<p>
大小<span>{{ fileFormatSize(item.file.size) }}</span>
</p>
</div>
</div>
</div>
</div>
</template>
<style lang="less" scoped>
.section {
height: 100%;
width: 100%;
.title {
height: 60px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 10px;
}
.file-item {
width: 95%;
min-height: 100px;
display: flex;
flex-direction: column;
margin: 15px auto;
overflow: hidden;
border: 1px solid var(--im-message-border-color);
border-radius: 5px;
.file-header {
height: 45px;
display: flex;
flex-direction: row;
align-items: center;
position: relative;
border-bottom: 1px solid var(--im-message-border-color);
.type-icon {
height: 30px;
width: 30px;
background-color: rgb(80, 138, 254);
border-radius: 50%;
margin-left: 5px;
font-size: 10px;
font-weight: 200;
overflow: hidden;
color: white;
}
.filename {
margin-left: 10px;
font-size: 14px;
width: 65%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status {
position: absolute;
right: 14px;
top: 12px;
font-size: 13px;
color: #6b6868;
font-weight: 200;
.success {
color: rgb(103, 194, 58);
}
}
}
.file-mian {
padding: 8px;
display: flex;
flex-direction: row;
.progress {
width: 80px;
height: 80px;
flex-shrink: 0;
}
.detail {
flex: auto;
flex-shrink: 0;
display: flex;
flex-direction: column;
margin-left: 10px;
justify-content: center;
align-items: flex-start;
font-size: 13px;
p {
margin: 3px;
color: #ada8a8;
span {
color: #595a5a;
font-weight: 500;
}
}
}
}
}
}
:deep(.n-progress-text) {
font-size: 13px !important;
}
</style>

View File

@ -0,0 +1,41 @@
<script>
import { defineComponent, h, ref, onUnmounted, watch } from 'vue'
import { beautifyTime } from '@/utils/datetime'
//
export default defineComponent({
name: 'Xtime',
props: {
time: {
type: String,
default: '2022-03-06 21:20:00'
}
},
setup(props) {
let timeout = null
const inTime = new Date(props.time.replace(/-/g, '/')).getTime()
const text = ref('')
const format = () => {
clearTimeout(timeout)
text.value = beautifyTime(props.time)
if (new Date().getTime() - inTime < 30 * 60 * 1000) {
timeout = setTimeout(format, 60 * 1000)
}
}
watch(props, format)
onUnmounted(() => {
clearTimeout(timeout)
})
format()
return () => h('span', [text.value])
}
})
</script>

View File

@ -0,0 +1,124 @@
<script lang="ts" setup>
import { ref, onMounted } from 'vue'
import Loading from '@/components/base/Loading.vue'
import { ServeGetForwardRecords } from '@/api/chat'
import { MessageComponents } from '@/constant/message'
import { ITalkRecord } from '@/types/chat'
import { useInject } from '@/hooks'
const emit = defineEmits(['close'])
const props = defineProps({
msgId: {
type: String,
required: true
}
})
const { showUserInfoModal } = useInject()
const isShow = ref(true)
const items = ref<ITalkRecord[]>([])
const title = ref('会话记录')
const onMaskClick = () => {
emit('close')
}
const onLoadData = () => {
ServeGetForwardRecords({
msg_id: props.msgId
}).then((res) => {
if (res.code == 200) {
items.value = res.data.items || []
title.value = `会话记录(${items.value.length})`
}
})
}
onMounted(() => {
onLoadData()
})
</script>
<template>
<n-modal
v-model:show="isShow"
preset="card"
:title="title"
style="max-width: 500px"
class="modal-radius"
:on-after-leave="onMaskClick"
:segmented="{
content: true
}"
:header-style="{
padding: '20px 15px'
}"
:content-style="{
padding: 0
}"
>
<div class="main-box me-scrollbar me-scrollbar-thumb">
<Loading v-if="items.length === 0" />
<div v-for="item in items" :key="item.msg_id" class="message-item">
<div class="left-box pointer" @click="showUserInfoModal(item.user_id)">
<im-avatar :src="item.avatar" :size="30" :username="item.nickname" />
</div>
<div class="right-box">
<div class="msg-header">
<span class="name">{{ item.nickname }}</span>
<span class="time"> {{ item.created_at }}</span>
</div>
<component
:is="MessageComponents[item.msg_type] || 'unknown-message'"
:extra="item.extra"
:data="item"
/>
</div>
</div>
</div>
</n-modal>
</template>
<style lang="less" scoped>
.main-box {
height: 600px;
width: 100%;
overflow-y: auto;
}
.message-item {
min-height: 38px;
display: flex;
margin-bottom: 10px;
padding: 5px 15px;
.left-box {
width: 30px;
display: flex;
user-select: none;
padding-top: 8px;
}
.right-box {
width: 100%;
overflow-x: auto;
padding: 0px 5px 15px 5px;
box-sizing: border-box;
height: fit-content;
.msg-header {
height: 30px;
line-height: 30px;
font-size: 12px;
position: relative;
user-select: none;
display: flex;
justify-content: space-between;
}
}
}
</style>

View File

@ -0,0 +1,281 @@
<script lang="ts" setup>
import { ref, reactive, onMounted } from 'vue'
import Loading from '@/components/base/Loading.vue'
import { ServeFindTalkRecords } from '@/api/chat'
import { Down, Calendar } from '@icon-park/vue-next'
import * as message from '@/constant/message'
import { ITalkRecord } from '@/types/chat'
import { useInject } from '@/hooks'
const emit = defineEmits(['close'])
const props = defineProps({
talkType: {
type: Number,
default: 0
},
receiverId: {
type: Number,
default: 0
}
})
const { showUserInfoModal } = useInject()
const model = reactive({
cursor: 0,
limit: 30,
msgType: 0,
loading: false,
loadMore: false,
isLoadMore: true
})
const isShow = ref(true)
const items = ref<ITalkRecord[]>([])
const tabs = [
{ name: '全部', type: 0, show: true },
{ name: '图片', type: message.ChatMsgTypeImage, show: true },
{ name: '音频', type: message.ChatMsgTypeAudio, show: true },
{ name: '视频', type: message.ChatMsgTypeVideo, show: true },
{ name: '文件', type: message.ChatMsgTypeFile, show: true },
{ name: '会话', type: message.ChatMsgTypeForward, show: true },
{ name: '代码', type: message.ChatMsgTypeCode, show: true },
{ name: '位置', type: message.ChatMsgTypeLocation, show: true },
{ name: '群投票', type: message.ChatMsgTypeVote, show: props.talkType == 2 }
]
const onMaskClick = () => {
emit('close')
}
const loadChatRecord = () => {
let data = {
talk_type: props.talkType,
receiver_id: props.receiverId,
msg_type: model.msgType,
cursor: model.cursor,
limit: model.limit
}
if (model.cursor === 0) {
model.loading = true
} else {
model.loadMore = true
}
ServeFindTalkRecords(data).then((res) => {
if (res.code != 200) return
if (data.cursor === 0) {
items.value = []
}
let list = res.data.items || []
if (list.length) {
model.cursor = res.data.cursor
}
model.loading = false
model.loadMore = false
model.isLoadMore = list.length >= model.limit
items.value.push(...list)
})
}
const triggerType = (type: number) => {
model.msgType = type
model.cursor = 0
loadChatRecord()
}
onMounted(() => {
loadChatRecord()
})
</script>
<template>
<n-modal
v-model:show="isShow"
preset="card"
title="消息管理"
style="max-width: 750px"
class="modal-radius"
:on-after-leave="onMaskClick"
:segmented="{
content: true
}"
:header-style="{
padding: '20px 15px'
}"
:content-style="{
padding: 0
}"
>
<section class="main-box el-container is-vertical o-hidden">
<header class="el-header bdr-b search" style="height: 50px">
<div class="type-items">
<span
v-for="tab in tabs"
:key="tab.name"
class="pointer"
:class="{ active: model.msgType == tab.type }"
@click="triggerType(tab.type)"
v-show="tab.show"
>
{{ tab.name }}
</span>
</div>
<div style="display: flex; align-items: center">
<!-- <n-popover placement="bottom-end" trigger="click" :show-arrow="false">
<template #trigger>
<n-icon
:size="20"
class="pointer"
:component="Calendar"
/>
</template>
<n-date-picker
panel
type="date"
:is-date-disabled="disablePreviousDate"
:on-update:value="datefunc"
/>
</n-popover> -->
<n-icon :size="20" class="pointer" :component="Calendar" />
</div>
</header>
<main v-if="model.loading" class="el-main flex-center">
<Loading />
</main>
<main v-else-if="items.length === 0" class="el-main flex-center">
<n-empty size="200" description="暂无相关数据">
<template #icon>
<img src="@/assets/image/no-data.svg" alt="" />
</template>
</n-empty>
</main>
<main v-else class="el-main me-scrollbar me-scrollbar-thumb">
<div v-for="item in items" :key="item.id" class="message-item">
<div class="left-box">
<im-avatar
:src="item.avatar"
:size="30"
:username="item.nickname"
@click="showUserInfoModal(item.user_id)"
/>
</div>
<div class="right-box me-scrollbar">
<div class="msg-header">
<span class="name">{{ item.nickname }}</span>
<span class="time"> {{ item.created_at }}</span>
</div>
<template v-if="item.is_revoke == 1">
<div class="msg-content">此消息已被撤回</div>
</template>
<component
v-if="item.is_revoke == 0"
:is="message.MessageComponents[item.msg_type] || 'unknown-message'"
:extra="item.extra"
:data="item"
/>
</div>
</div>
<div class="more pointer flex-center" @click="loadChatRecord" v-show="model.isLoadMore">
<n-icon v-show="!model.loadMore" :size="20" class="icon" :component="Down" />
<span> &nbsp;{{ model.loadMore ? '数据加载中...' : '加载更多' }} </span>
</div>
</main>
</section>
</n-modal>
</template>
<style lang="less" scoped>
.main-box {
height: 550px;
.search {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 15px 0 5px;
.type-items {
line-height: 40px;
user-select: none;
.active {
color: #03a9f4;
font-weight: 500;
}
span {
height: 40px;
width: 45px;
margin: 0 10px;
font-size: 13px;
font-weight: 400;
}
}
}
}
.message-item {
min-height: 30px;
display: flex;
margin-bottom: 5px;
flex-direction: row;
padding: 5px 15px;
&:first-child {
margin-top: 10px;
}
.left-box {
width: 30px;
flex-shrink: 0;
display: flex;
justify-content: center;
user-select: none;
padding-top: 8px;
margin-right: 10px;
img {
height: 30px;
width: 30px;
border-radius: 3px;
}
}
.right-box {
width: 100%;
overflow-x: auto;
padding: 0px 5px 15px 5px;
box-sizing: border-box;
height: fit-content;
.msg-header {
height: 30px;
line-height: 30px;
font-size: 12px;
position: relative;
user-select: none;
display: flex;
align-items: center;
justify-content: space-between;
}
}
}
.more {
margin: 10px auto 20px;
width: 150px;
height: 30px;
}
</style>

View File

@ -0,0 +1,260 @@
<script lang="ts" setup>
import { ref, reactive } from 'vue'
import { PlayOne, PauseOne } from '@icon-park/vue-next'
import { ITalkRecordExtraAudio, ITalkRecord } from '@/types/chat'
defineProps<{
extra: ITalkRecordExtraAudio
data: ITalkRecord
maxWidth?: Boolean
}>()
const audioRef = ref()
const durationDesc = ref('-')
const state = reactive({
isAudioPlay: false,
progress: 0,
duration: 0,
currentTime: 0,
loading: true
})
const onPlay = () => {
if (state.isAudioPlay) {
audioRef.value.pause()
} else {
audioRef.value.play()
}
state.isAudioPlay = !state.isAudioPlay
}
const onPlayEnd = () => {
state.isAudioPlay = false
state.progress = 0
}
const onCanplay = () => {
state.duration = audioRef.value.duration
durationDesc.value = formatTime(parseInt(audioRef.value.duration))
state.loading = false
}
const onError = (e: any) => {
console.log('音频播放异常===>', e)
}
const onTimeUpdate = () => {
let audio = audioRef.value
if (audio.duration == 0) {
state.progress = 0
} else {
state.currentTime = audio.currentTime
state.progress = (audio.currentTime / audio.duration) * 100
}
}
const formatTime = (value: number = 0) => {
if (value == 0) {
return '-'
}
const minutes = Math.floor(value / 60)
let seconds = value
if (minutes > 0) {
seconds = Math.floor(value - minutes * 60)
}
return `${minutes}'${seconds}"`
}
</script>
<template>
<div class="im-message-audio">
<audio
ref="audioRef"
preload="auto"
type="audio/mp3,audio/wav"
:src="extra.url"
@timeupdate="onTimeUpdate"
@ended="onPlayEnd"
@canplay="onCanplay"
@error="onError"
/>
<div class="play">
<div class="btn pointer" @click.stop="onPlay">
<n-icon :size="18" :component="state.isAudioPlay ? PauseOne : PlayOne" />
</div>
</div>
<div class="desc">
<span class="line" v-for="i in 23" :key="i"></span>
<span
class="indicator"
:style="{ left: state.progress + '%' }"
v-show="state.progress > 0"
></span>
</div>
<div class="time">{{ durationDesc }}</div>
</div>
</template>
<style lang="less" scoped>
.im-message-audio {
--audio-bg-color: #f5f5f5;
--audio-btn-bg-color: #ffffff;
width: 200px;
height: 45px;
border-radius: 10px;
display: flex;
align-items: center;
overflow: hidden;
background-color: var(--audio-bg-color);
> div {
display: flex;
align-items: center;
justify-content: center;
}
.play {
width: 45px;
height: inherit;
flex-shrink: 0;
.btn {
width: 26px;
height: 26px;
background-color: var(--audio-btn-bg-color);
border-radius: 50%;
color: rgb(24, 24, 24);
display: flex;
align-items: center;
justify-content: center;
}
}
.desc {
flex: 1 1;
height: inherit;
position: relative;
overflow: hidden;
flex-shrink: 0;
.line {
justify-content: space-between;
height: 30px;
width: 2px;
background-color: rgb(40, 39, 39);
margin-left: 3px;
&:first-child {
margin-left: 0;
}
&:nth-child(1) {
height: 16px;
}
&:nth-child(2) {
height: 10px;
}
&:nth-child(3) {
height: 8px;
}
&:nth-child(4) {
height: 6px;
}
&:nth-child(5) {
height: 2px;
}
&:nth-child(6) {
height: 10px;
}
&:nth-child(7) {
height: 20px;
}
&:nth-child(8) {
height: 16px;
}
&:nth-child(9) {
height: 10px;
}
&:nth-child(10) {
height: 13px;
}
&:nth-child(11) {
height: 10px;
}
&:nth-child(12) {
height: 8px;
}
&:nth-child(13) {
height: 15px;
}
&:nth-child(14) {
height: 16px;
}
&:nth-child(15) {
height: 16px;
}
&:nth-child(16) {
height: 15px;
}
&:nth-child(17) {
height: 14px;
}
&:nth-child(18) {
height: 12px;
}
&:nth-child(19) {
height: 8px;
}
&:nth-child(20) {
height: 3px;
}
&:nth-child(21) {
height: 6px;
}
&:nth-child(22) {
height: 10px;
}
&:nth-child(23) {
height: 16px;
}
}
.indicator {
position: absolute;
height: 70%;
width: 1px;
background-color: #9b9595;
}
}
.time {
width: 50px;
height: inherit;
font-size: 12px;
flex-shrink: 0;
}
}
html[theme-mode='dark'] {
.im-message-audio {
--audio-bg-color: #2c2c32;
--audio-btn-bg-color: rgb(78, 75, 75);
.btn {
color: #ffffff;
}
.desc {
.line {
background-color: rgb(169, 167, 167);
}
}
}
}
</style>

View File

@ -0,0 +1,127 @@
<script lang="ts" setup>
import { ref } from 'vue'
import { NCode } from 'naive-ui'
import { Copy, Stretching } from '@icon-park/vue-next'
import { clipboard } from '@/utils/common'
import { useUtil } from '@/hooks'
import { ITalkRecordExtraCode, ITalkRecord } from '@/types/chat'
const props = defineProps<{
extra: ITalkRecordExtraCode
data: ITalkRecord
maxWidth?: Boolean
}>()
const { useMessage } = useUtil()
const lineMumber = props.extra.code.trim().split('\n').length
const full = ref(false)
const onClipboard = () => {
clipboard(props.extra.code, () => {
useMessage.success('复制成功')
})
}
</script>
<template>
<section
class="im-message-code el-container is-vertical"
:class="{
maxwidth: maxWidth,
full: full
}"
>
<header class="el-header tools">
<p># {{ extra.lang }}</p>
<p>
<n-icon class="icon" :component="Stretching" @click="full = !full" />
<n-icon class="icon" :component="Copy" @click="onClipboard" />
</p>
</header>
<main class="el-main me-scrollbar me-scrollbar-thumb" :lineMumber="lineMumber">
<n-code :language="extra.lang" :code="extra.code" show-line-numbers />
<div class="el-footer mask pointer" v-show="lineMumber > 20" @click="full = !full">
查看更多
</div>
</main>
</section>
</template>
<style lang="less" scoped>
.im-message-code {
min-width: 300px;
min-height: 100px;
border-radius: 10px;
overflow-x: auto;
border: 1px solid var(--border-color);
padding: 5px 8px;
max-height: 500px;
overflow-y: hidden;
flex: unset;
.el-main {
overflow-y: hidden;
}
&.maxwidth {
max-width: 60%;
}
&.full {
position: fixed;
top: 0;
left: 0;
z-index: 1;
background-color: var(--im-bg-color);
width: 100%;
height: 100%;
border: 0;
box-sizing: border-box;
max-width: unset;
max-height: unset;
overflow-y: unset;
border-radius: unset;
.el-main {
overflow-y: unset;
}
.mask {
display: none;
}
}
.tools {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
height: 30px;
cursor: pointer;
padding: 0 8px;
.icon {
margin-left: 5px;
}
}
.mask {
height: 80px;
text-align: center;
line-height: 10;
position: sticky;
bottom: 0;
left: 0;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 100%);
color: var(--im-text-color);
}
}
html[theme-mode='dark'] {
.im-message-code {
background: var(--im-message-bg-color);
.mask {
background: linear-gradient(to bottom, transparent 0%, var(--im-bg-color) 100%);
}
}
}
</style>

View File

@ -0,0 +1,118 @@
<script lang="ts" setup>
import { fileFormatSize } from '@/utils/strings'
import { download, getFileNameSuffix } from '@/utils/functions'
import { ITalkRecordExtraFile, ITalkRecord } from '@/types/chat'
defineProps<{
extra: ITalkRecordExtraFile
data: ITalkRecord
maxWidth?: Boolean
}>()
</script>
<template>
<section class="file-message">
<div class="main">
<div class="ext">{{ getFileNameSuffix(extra.name) }}</div>
<div class="file-box">
<p class="info">
<span class="name">{{ extra.name }}</span>
<span class="size">({{ fileFormatSize(extra.size) }})</span>
</p>
<p class="notice">文件已成功发送, 文件助手永久保存</p>
</div>
</div>
<div class="footer">
<a @click="download(data.msg_id)">下载</a>
<a>在线预览</a>
</div>
</section>
</template>
<style lang="less" scoped>
.file-message {
width: 250px;
min-height: 85px;
padding: 10px;
border-radius: 10px;
border: 1px solid var(--im-message-border-color);
.main {
height: 45px;
display: flex;
flex-direction: row;
margin-top: 5px;
.ext {
display: flex;
justify-content: center;
align-items: center;
width: 45px;
height: 45px;
color: #ffffff;
background: #49a4ff;
border-radius: 5px;
font-size: 12px;
}
.file-box {
flex: 1 1;
height: 45px;
margin-left: 10px;
overflow: hidden;
.info {
display: flex;
justify-content: space-between;
align-items: center;
overflow: hidden;
height: 24px;
font-size: 14px;
.name {
flex: 1 auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.size {
font-size: 12px;
color: #cac6c6;
flex-shrink: 0;
}
}
.notice {
height: 25px;
line-height: 25px;
font-size: 12px;
color: #929191;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
.footer {
height: 30px;
line-height: 37px;
text-align: right;
font-size: 12px;
border-top: 1px solid var(--border-color);
margin-top: 10px;
a {
margin: 0 3px;
user-select: none;
cursor: pointer;
color: var(--im-text-color);
&:hover {
color: royalblue;
}
}
}
}
</style>

View File

@ -0,0 +1,84 @@
<script lang="ts" setup>
import { computed, ref } from 'vue'
import ForwardRecord from '../ForwardRecord.vue'
import { ITalkRecordExtraForward, ITalkRecord } from '@/types/chat'
const props = defineProps<{
extra: ITalkRecordExtraForward
data: ITalkRecord
maxWidth?: Boolean
}>()
const isShowRecord = ref(false)
const title = computed(() => {
return [...new Set(props.extra.records.map((v) => v.nickname))].join('、')
})
const onClick = () => {
isShowRecord.value = true
}
</script>
<template>
<section class="im-message-forward pointer" @click="onClick">
<div class="title">{{ title }} 的会话记录</div>
<div class="list" v-for="(record, index) in extra.records" :key="index">
<p>
<span>{{ record.nickname }}: </span>
<span>{{ record.text }}</span>
</p>
</div>
<div class="tips">
<span>转发聊天会话记录 ({{ extra.msg_ids.length }})</span>
</div>
<ForwardRecord v-if="isShowRecord" :msg-id="data.msg_id" @close="isShowRecord = false" />
</section>
</template>
<style lang="less" scoped>
.im-message-forward {
width: 250px;
min-height: 95px;
max-height: 150px;
border-radius: 10px;
padding: 8px 10px;
border: 1px solid var(--im-message-border-color);
user-select: none;
.title {
height: 30px;
line-height: 30px;
font-size: 15px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 400;
margin-bottom: 5px;
}
.list p {
height: 18px;
line-height: 18px;
font-size: 12px;
color: #a8a8a8;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 5px;
}
.tips {
height: 32px;
line-height: 35px;
color: #8a8888;
border-top: 1px solid var(--border-color);
font-size: 12px;
margin-top: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
</style>

View File

@ -0,0 +1,60 @@
<script lang="ts" setup>
import { ref } from 'vue'
import { ITalkRecordExtraGroupNotice, ITalkRecord } from '@/types/chat'
defineProps<{
extra: ITalkRecordExtraGroupNotice
data: ITalkRecord
maxWidth?: Boolean
}>()
let show = ref(false)
</script>
<template>
<section class="im-message-group-notice pointer" @click="show = !show">
<div class="title">
<n-tag :bordered="false" size="small" type="primary"> 群公告 </n-tag>
{{ extra.title }}
</div>
<div class="content" :class="{ ellipsis: !show }">
{{ extra.content }}
</div>
</section>
</template>
<style lang="less" scoped>
.im-message-group-notice {
max-width: 500px;
min-height: 10px;
border-radius: 10px;
padding: 8px 10px;
border: 1px solid var(--im-message-border-color);
user-select: none;
.title {
height: 30px;
line-height: 30px;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 400;
margin-bottom: 5px;
position: relative;
}
.content {
font-size: 13px;
color: #a8a8a8;
line-height: 24px;
white-space: pre-wrap;
&.ellipsis {
height: 24px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
</style>

View File

@ -0,0 +1,60 @@
<script lang="ts" setup>
import { NImage } from 'naive-ui'
import { getImageInfo } from '@/utils/functions'
import { ITalkRecordExtraImage, ITalkRecord } from '@/types/chat'
defineProps<{
extra: ITalkRecordExtraImage
data: ITalkRecord
maxWidth?: Boolean
}>()
const img = (src: string, width = 200) => {
const info = getImageInfo(src)
if (info.width == 0 || info.height == 0) {
return {}
}
if (info.width < width) {
return {
width: `${info.width}px`,
height: `${info.height}px`
}
}
return {
width: width + 'px',
height: `${info.height / (info.width / width)}px`
}
}
</script>
<template>
<section
class="im-message-image"
:class="{ left: data.float === 'left' }"
:style="img(extra.url, 350)"
>
<n-image :src="extra.url" />
</section>
</template>
<style lang="less" scoped>
.im-message-image {
overflow: hidden;
padding: 5px;
border-radius: 5px;
background: var(--im-message-left-bg-color);
min-width: 30px;
min-height: 30px;
&.left {
background: var(--im-message-right-bg-color);
}
:deep(.n-image img) {
width: 100%;
height: 100%;
border-radius: 5px;
}
}
</style>

View File

@ -0,0 +1,67 @@
<script lang="ts" setup>
import { ITalkRecordExtraLogin, ITalkRecord } from '@/types/chat'
defineProps<{
extra: ITalkRecordExtraLogin
data: ITalkRecord
maxWidth?: Boolean
}>()
function getExploreName(userAgent = '') {
if (userAgent.indexOf('Opera') > -1 || userAgent.indexOf('OPR') > -1) {
return 'Opera'
} else if (userAgent.indexOf('compatible') > -1 && userAgent.indexOf('MSIE') > -1) {
return 'IE'
} else if (userAgent.indexOf('Edge') > -1) {
return 'Edge'
} else if (userAgent.indexOf('Firefox') > -1) {
return 'Firefox'
} else if (userAgent.indexOf('Safari') > -1 && userAgent.indexOf('Chrome') == -1) {
return 'Safari'
} else if (userAgent.indexOf('Chrome') > -1 && userAgent.indexOf('Safari') > -1) {
return 'Chrome'
} else {
return 'Unkonwn'
}
}
function getExploreOs(userAgent = '') {
if (userAgent.indexOf('Mac OS') > -1) {
return 'Mac OS'
} else {
return 'Windows'
}
}
</script>
<template>
<section class="im-message-login">
<h4>登录操作通知</h4>
<p>登录时间{{ extra.datetime }} (CST)</p>
<p>IP 地址{{ extra.ip }}</p>
<p>登录地点{{ extra.address }}</p>
<p>
登录设备{{ getExploreName(extra.agent) }} /
{{ getExploreOs(extra.agent) }}
</p>
<p>异常原因{{ extra.reason }}</p>
</section>
</template>
<style lang="less" scoped>
.im-message-login {
width: 300px;
min-height: 50px;
background: var(--im-message-bg-color);
border-radius: 5px;
padding: 15px;
color: var(--im-text-color);
p {
font-size: 13px;
margin: 10px 0;
&:last-child {
margin-bottom: 0;
}
}
}
</style>

View File

@ -0,0 +1,105 @@
<script lang="ts" setup>
import { NImage } from 'naive-ui'
import { textReplaceEmoji } from '@/utils/emojis'
import { textReplaceLink } from '@/utils/strings'
import { getImageInfo } from '@/utils/functions'
import { ITalkRecordExtraMixed, ITalkRecord } from '@/types/chat'
const props = defineProps<{
extra: ITalkRecordExtraMixed
data: ITalkRecord
maxWidth?: Boolean
}>()
const float = props.data.float
const img = (src, width = 200) => {
const info = getImageInfo(src)
if (info.width == 0 || info.height == 0) {
return {}
}
if (info.width < width) {
return {
width: `${info.width}px`,
height: `${info.height}px`
}
}
let h = info.height / (info.width / width)
return {
width: width + 'px',
height: h + 'px'
}
}
</script>
<template>
<div
class="im-message-mixed"
:class="{
left: float == 'left',
right: float == 'right',
maxwidth: maxWidth
}"
>
<pre>
<template v-for="(item) in extra.items" :key="item.id">
<template v-if="item.type === 1">
<span v-html="textReplaceEmoji(textReplaceLink(item.content))" />
</template>
<template v-else-if="item.type === 3">
<div
:style="img(item.content, 300)"
style="display: flex; margin: 5px 0;border-radius: 8px;overflow: hidden;;"
>
<n-image :src="item.content"></n-image>
</div>
</template>
</template>
</pre>
</div>
</template>
<style lang="less" scoped>
.im-message-mixed {
min-width: 30px;
min-height: 30px;
padding: 3px;
color: var(--im-message-left-text-color);
background: var(--im-message-left-bg-color);
border-radius: 0px 10px 10px 10px;
&.right {
background-color: var(--im-message-right-bg-color);
color: var(--im-message-right-text-color);
border-radius: 10px 0px 10px 10px;
}
&.maxwidth {
max-width: 70%;
}
pre {
display: flex;
flex-direction: column;
white-space: pre-wrap;
overflow: hidden;
word-break: break-word;
word-wrap: break-word;
font-size: 14px;
padding: 3px 5px;
font-family: 'PingFang SC', 'Microsoft YaHei', 'Alibaba PuHuiTi 2.0 45';
line-height: 25px;
:deep(a) {
color: #2196f3;
text-decoration: revert;
}
}
}
</style>

View File

@ -0,0 +1,71 @@
<script setup>
import { formatTime } from '@/utils/datetime'
defineProps({
login_uid: {
type: Number,
default: 0
},
user_id: {
type: Number,
default: 0
},
talk_type: {
type: Number,
default: 0
},
nickname: {
type: String,
default: ''
},
datetime: {
type: String,
default: ''
}
})
</script>
<template>
<div class="im-message-revoke">
<div class="content">
<span v-if="login_uid == user_id"> 你撤回了一条消息 | {{ formatTime(datetime) }} </span>
<span v-else-if="talk_type == 1"> 对方撤回了一条消息 | {{ formatTime(datetime) }} </span>
<span v-else>
"{{ nickname }}" 撤回了一条消息 |
{{ formatTime(datetime) }}
</span>
</div>
</div>
</template>
<style lang="less" scoped>
.im-message-revoke {
display: flex;
justify-content: center;
.content {
margin: 10px auto;
background-color: #f5f5f5;
font-size: 11px;
line-height: 30px;
padding: 0 8px;
word-break: break-all;
word-wrap: break-word;
color: #979191;
user-select: none;
font-weight: 300;
display: inline-block;
border-radius: 3px;
span {
margin: 0 5px;
}
}
}
html[theme-mode='dark'] {
.im-message-revoke {
.content {
background: unset;
}
}
}
</style>

View File

@ -0,0 +1,84 @@
<script lang="ts" setup>
import { textReplaceEmoji } from '@/utils/emojis'
import { textReplaceLink, textReplaceMention } from '@/utils/strings'
import { ITalkRecordExtraText, ITalkRecord } from '@/types/chat'
const props = defineProps<{
extra: ITalkRecordExtraText
data: ITalkRecord
maxWidth?: boolean
source?: 'panel' | 'forward' | 'history'
}>()
const float = props.data.float
let textContent = props.extra?.content || ''
textContent = textReplaceLink(textContent)
if (props.data.talk_type == 2) {
textContent = textReplaceMention(textContent, '#1890ff')
}
textContent = textReplaceEmoji(textContent)
</script>
<template>
<div
class="im-message-text"
:class="{
left: float == 'left',
right: float == 'right',
maxwidth: maxWidth,
'radius-reset': source != 'panel'
}"
>
<pre v-html="textContent" />
</div>
</template>
<style lang="less" scoped>
.im-message-text {
min-width: 30px;
min-height: 30px;
padding: 3px;
color: var(--im-message-left-text-color);
background: var(--im-message-left-bg-color);
border-radius: 0px 10px 10px 10px;
&.right {
background-color: var(--im-message-right-bg-color);
color: var(--im-message-right-text-color);
border-radius: 10px 0px 10px 10px;
}
&.maxwidth {
max-width: 70%;
}
&.radius-reset {
border-radius: 0;
}
pre {
white-space: pre-wrap;
overflow: hidden;
word-break: break-word;
word-wrap: break-word;
font-size: 14px;
padding: 3px 5px;
font-family: 'PingFang SC', 'Microsoft YaHei', 'Alibaba PuHuiTi 2.0 45';
line-height: 25px;
:deep(.emoji) {
vertical-align: text-bottom;
margin: 0 5px;
}
:deep(a) {
color: #2196f3;
text-decoration: revert;
}
}
}
</style>

View File

@ -0,0 +1,23 @@
<script setup>
const props = defineProps({
extra: Object,
data: Object
})
</script>
<template>
<div class="im-message-unknown">[{{ data.msg_type }}] 未知消息类型</div>
</template>
<style lang="less" scoped>
.im-message-unknown {
height: 35px;
line-height: 35px;
border-radius: 20px;
color: #979191;
background: #eff0f1;
width: 150px;
text-align: center;
font-weight: 300;
}
</style>

View File

@ -0,0 +1,137 @@
<script lang="ts" setup>
import 'xgplayer/dist/index.min.css'
import { ref, nextTick } from 'vue'
import { NImage, NModal, NCard } from 'naive-ui'
import { Play, Close } from '@icon-park/vue-next'
import { getImageInfo } from '@/utils/functions'
import Player from 'xgplayer'
import { ITalkRecordExtraVideo, ITalkRecord } from '@/types/chat'
const props = defineProps<{
extra: ITalkRecordExtraVideo
data: ITalkRecord
maxWidth?: Boolean
}>()
const img = (src: string, width = 200) => {
const info: any = getImageInfo(src)
if (info.width == 0 || info.height == 0) {
return {}
}
if (info.height > 300) {
return {
height: '300px'
}
}
if (info.width < width) {
return {
width: `${info.width}px`,
height: `${info.height}px`
}
}
return {
width: width + 'px',
height: info.height / (info.width / width) + 'px'
}
}
const open = ref(false)
async function onPlay() {
open.value = true
await nextTick()
new Player({
id: 'im-xgplayer',
url: props.extra.url,
fluid: true,
autoplay: true,
lang: 'zh-cn'
})
}
</script>
<template>
<section
class="im-message-video"
:class="{ left: data.float === 'left' }"
:style="img(extra.cover, 350)"
@click="onPlay"
>
<n-image :src="extra.cover" preview-disabled />
<div class="btn-video">
<n-icon :component="Play" size="36" />
</div>
<n-modal v-model:show="open">
<n-card
style="width: 800px; min-height: 300px; background-color: #ffffff; position: relative"
role="dialog"
aria-modal="true"
>
<div id="im-xgplayer"></div>
<div class="im-xgplayer-close" @click="open = false">
<n-icon :component="Close" size="18" />
</div>
</n-card>
</n-modal>
</section>
</template>
<style lang="less" scoped>
.im-message-video {
overflow: hidden;
padding: 5px;
border-radius: 5px;
background: var(--im-message-left-bg-color);
min-width: 30px;
min-height: 30px;
display: inline-flex;
position: relative;
&.left {
background: var(--im-message-right-bg-color);
}
:deep(.n-image img) {
width: 100%;
height: 100%;
border-radius: 5px;
}
.btn-video {
width: 30px;
height: 20px;
position: absolute;
left: calc(50% - 15px);
top: calc(50% - 10px);
cursor: pointer;
color: #ffffff;
}
&:hover {
.btn-video {
color: #03a9f4;
}
}
}
.im-xgplayer-close {
position: absolute;
height: 35px;
width: 35px;
background-color: #f5f5f5;
right: -45px;
top: -45px;
cursor: pointer;
border-radius: 50%;
color: #000;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@ -0,0 +1,281 @@
<script setup>
import { reactive, computed, onMounted, ref } from 'vue'
import { NCheckbox, NProgress } from 'naive-ui'
import { ServeConfirmVoteHandle } from '@/api/chat'
import { useUserStore } from '@/store'
const props = defineProps({
extra: Object,
data: Object
})
const extra = ref(props.extra)
const userStore = useUserStore()
const mode = extra.value.detail.answer_mode
const state = reactive({ options: [] })
//
const isCanSubmit = computed(() => {
return state.options.some((item) => item.is_checked)
})
//
const isVoted = computed(() => {
return extra.value.vote_users.some((item) => item == userStore.uid)
})
/**
* 设置投票选项
*/
function setOptions(options) {
for (const option of options) {
state.options.push({
key: option.key,
value: option.value,
is_checked: false,
num: 0,
progress: 0
})
}
}
/**
* 更新统计信息
*
* @param {*} data
*/
function updateStatistics(data) {
let count = data.count
state.options.forEach((option) => {
option.num = data.options[option.key]
if (count > 0) {
option.progress = (data.options[option.key] / count) * 100
}
})
}
/**
* 选择投票
*
* @param {*} data
* @param {*} option
*/
function change(data, option) {
if (mode == 0) {
state.options.forEach((option) => (option.is_checked = false))
}
option.is_checked = data
}
/**
* 表单提交
*/
const onSubmit = () => {
if (!isCanSubmit.value) return
let items = []
state.options.forEach((item) => {
item.is_checked && items.push(item.key)
})
ServeConfirmVoteHandle({
msg_id: props.data.msg_id,
options: items.join(',')
}).then((res) => {
if (res.code == 200) {
updateStatistics(res.data)
extra.value.vote_users.push(userStore.uid)
extra.value.detail.answered_num++
}
})
}
onMounted(() => {
setOptions(extra.value.detail.answer_option)
updateStatistics(extra.value.statistics)
})
</script>
<template>
<section class="im-message-vote">
<div class="vote-from">
<div class="vheader">
<p style="font-weight: bold">
{{ mode == 1 ? '[多选投票]' : '[单选投票]' }}
</p>
<p>{{ extra.detail.title }}</p>
</div>
<template v-if="isVoted">
<div class="vbody">
<div class="vote-view" v-for="option in state.options" :key="option.key">
<p class="vote-option">{{ option.key }} {{ option.value }}</p>
<p class="vote-census">{{ option.num }} {{ option.progress }}%</p>
<p class="vote-progress">
<n-progress
type="line"
:height="5"
:show-indicator="false"
:percentage="parseInt(option.progress)"
color="#1890ff"
/>
</p>
</div>
</div>
<div class="vfooter vote-view">
<p>应参与人数{{ extra.detail.answer_num }} </p>
<p>实际参与人数{{ extra.detail.answered_num }} </p>
</div>
</template>
<template v-else>
<div class="vbody">
<div
class="option"
:class="{ radio: mode == 0 }"
v-for="option in state.options"
:key="option.key"
>
<p class="checkbox">
<n-checkbox
v-model:checked="option.is_checked"
@update:checked="change(option.is_checked, option)"
/>
</p>
<p class="text" @click="change(!option.is_checked, option)">
{{ option.key }}{{ option.value }}
</p>
</div>
</div>
<div class="vfooter">
<n-button plain round @click="onSubmit">
{{ isCanSubmit ? '立即投票' : '请选择进行投票' }}
</n-button>
</div>
</template>
</div>
</section>
</template>
<style lang="less" scoped>
.im-message-vote {
width: 300px;
min-height: 150px;
border: 1px solid var(--border-color);
box-sizing: border-box;
border-radius: 10px;
overflow: hidden;
.vote-from {
width: 100%;
.vheader {
min-height: 50px;
background: #4e83fd;
padding: 15px;
position: relative;
p {
margin: 3px 0;
&:first-child {
color: rgb(245, 237, 237);
font-size: 13px;
margin-bottom: 8px;
}
&:last-child {
color: white;
}
}
&::before {
content: '投票';
position: absolute;
font-size: 60px;
color: white;
opacity: 0.1;
top: -5px;
right: 10px;
}
}
.vbody {
min-height: 80px;
width: 100%;
padding: 5px 15px;
box-sizing: border-box;
.option {
margin: 14px 0px;
font-size: 13px;
display: flex;
flex-direction: row;
.text {
margin-left: 10px;
cursor: pointer;
line-height: 26px;
}
&.radio {
:deep(.n-checkbox-box) {
border-radius: 50%;
margin-top: 2px;
}
}
}
margin-bottom: 10px;
}
.vfooter {
height: 55px;
text-align: center;
box-sizing: border-box;
.n-button {
width: 90%;
font-weight: 400;
}
&.vote-view {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
padding-left: 15px;
p {
border-left: 2px solid #2196f3;
padding-left: 5px;
}
}
}
}
.vote-view {
width: 100%;
min-height: 30px;
margin: 15px 0;
box-sizing: border-box;
> p {
margin: 6px 0px;
font-size: 13px;
}
.vote-option {
min-height: 20px;
line-height: 20px;
}
.vote-census {
height: 20px;
line-height: 20px;
}
}
}
</style>

View File

@ -0,0 +1,11 @@
import { defineAsyncComponent } from 'vue'
export function setComponents(app) {
// 动态导出当前目录下的组件
const modules = import.meta.glob(['./*.vue', './system/*.vue'])
for (const [key, value] of Object.entries(modules)) {
const name = key.slice(key.lastIndexOf('/') + 1, key.lastIndexOf('.'))
app.component(name, defineAsyncComponent(value))
}
}

View File

@ -0,0 +1,23 @@
<script setup>
import './sys-message.less'
import { useInject } from '@/hooks'
const { showUserInfoModal } = useInject()
defineProps({
extra: Object,
data: Object
})
</script>
<template>
<div class="im-message-sys-text">
<div class="sys-text">
<a @click="showUserInfoModal(extra.owner_id)">
{{ extra.owner_name }}
</a>
<span>取消了全员禁言</span>
</div>
</div>
</template>

View File

@ -0,0 +1,28 @@
<script setup>
import './sys-message.less'
import { useInject } from '@/hooks'
defineProps({
extra: Object,
data: Object
})
const { showUserInfoModal } = useInject()
</script>
<template>
<div class="im-message-sys-text">
<div class="sys-text">
<a @click="showUserInfoModal(extra.owner_id)">
{{ extra.owner_name }}
</a>
<span>创建了群聊并邀请了</span>
<template v-for="(user, index) in extra.members" :key="index">
<a @click="showUserInfoModal(user.user_id)">{{ user.nickname }}</a>
<em v-show="index < extra.members.length - 1"></em>
</template>
</div>
</div>
</template>

View File

@ -0,0 +1,30 @@
<script setup>
import './sys-message.less'
import { useInject } from '@/hooks'
defineProps({
extra: Object,
data: Object
})
const { showUserInfoModal } = useInject()
</script>
<template>
<div class="im-message-sys-text">
<div class="sys-text">
<a @click="showUserInfoModal(extra.owner_id)">
{{ extra.owner_name }}
</a>
<span>邀请了</span>
<template v-for="(user, index) in extra.members" :key="index">
<a @click="showUserInfoModal(user.user_id)">{{ user.nickname }}</a>
<em v-show="index < extra.members.length - 1"></em>
</template>
<span>加入群聊</span>
</div>
</div>
</template>

View File

@ -0,0 +1,30 @@
<script setup>
import './sys-message.less'
import { useInject } from '@/hooks'
defineProps({
extra: Object,
data: Object
})
const { showUserInfoModal } = useInject()
</script>
<template>
<div class="im-message-sys-text">
<div class="sys-text">
<a @click="showUserInfoModal(extra.owner_id)">
{{ extra.owner_name }}
</a>
<span>解除了</span>
<template v-for="(user, index) in extra.members" :key="index">
<a @click="showUserInfoModal(user.user_id)">{{ user.nickname }}</a>
<em v-show="index < extra.members.length - 1"></em>
</template>
<span>禁言</span>
</div>
</div>
</template>

View File

@ -0,0 +1,30 @@
<script setup>
import './sys-message.less'
import { useInject } from '@/hooks'
defineProps({
extra: Object,
data: Object
})
const { showUserInfoModal } = useInject()
</script>
<template>
<div class="im-message-sys-text">
<div class="sys-text">
<a @click="showUserInfoModal(extra.owner_id)">
{{ extra.owner_name }}
</a>
<span></span>
<template v-for="(user, index) in extra.members" :key="index">
<a @click="showUserInfoModal(user.user_id)">{{ user.nickname }}</a>
<em v-show="index < extra.members.length - 1"></em>
</template>
<span>踢出群聊</span>
</div>
</div>
</template>

View File

@ -0,0 +1,30 @@
<script setup>
import './sys-message.less'
import { useInject } from '@/hooks'
defineProps({
extra: Object,
data: Object
})
const { showUserInfoModal } = useInject()
</script>
<template>
<div class="im-message-sys-text">
<div class="sys-text">
<a @click="showUserInfoModal(extra.owner_id)">
{{ extra.owner_name }}
</a>
<span>设置了</span>
<template v-for="(user, index) in extra.members" :key="index">
<a @click="showUserInfoModal(user.user_id)">{{ user.nickname }}</a>
<em v-show="index < extra.members.length - 1"></em>
</template>
<span>禁言</span>
</div>
</div>
</template>

View File

@ -0,0 +1,23 @@
<script setup>
import './sys-message.less'
import { useInject } from '@/hooks'
defineProps({
extra: Object,
data: Object
})
const { showUserInfoModal } = useInject()
</script>
<template>
<div class="im-message-sys-text">
<div class="sys-text">
<a @click="showUserInfoModal(extra.owner_id)">
{{ extra.owner_name }}
</a>
<span>退出了群聊</span>
</div>
</div>
</template>

View File

@ -0,0 +1,23 @@
<script setup>
import './sys-message.less'
import { useInject } from '@/hooks'
defineProps({
extra: Object,
data: Object
})
const { showUserInfoModal } = useInject()
</script>
<template>
<div class="im-message-sys-text">
<div class="sys-text">
<a @click="showUserInfoModal(extra.owner_id)">
{{ extra.owner_name }}
</a>
<span>设置了全员禁言</span>
</div>
</div>
</template>

View File

@ -0,0 +1,21 @@
<script setup>
import './sys-message.less'
import { useInject } from '@/hooks'
defineProps({
extra: Object,
data: Object
})
const { showUserInfoModal } = useInject()
</script>
<template>
<div class="im-message-sys-text">
<div class="sys-text">
<a @click="showUserInfoModal(extra.old_owner_id)">{{ extra.old_owner_name }}</a>
<span>将群主转让给</span>
<a @click="showUserInfoModal(extra.new_owner_id)">{{ extra.new_owner_name }}</a>
</div>
</div>
</template>

View File

@ -0,0 +1,45 @@
<script setup>
defineProps({
extra: Object,
data: Object
})
</script>
<template>
<div class="im-message-system-text">
<div class="content">
{{ extra.content }}
</div>
</div>
</template>
<style lang="less" scoped>
.im-message-system-text {
display: flex;
justify-content: center;
.content {
margin: 10px auto;
background-color: #f5f5f5;
font-size: 11px;
line-height: 30px;
padding: 0 8px;
word-break: break-all;
word-wrap: break-word;
color: #979191;
user-select: none;
font-weight: 300;
display: inline-block;
border-radius: 3px;
}
}
html[theme-mode='dark'] {
.im-message-system-text {
.content {
background: unset;
color: unset;
}
}
}
</style>

View File

@ -0,0 +1,43 @@
.im-message-sys-text {
display: flex;
justify-content: center;
.sys-text {
margin: 10px auto;
background-color: #f5f5f5;
font-size: 11px;
line-height: 30px;
padding: 0 8px;
word-wrap: break-word;
color: #979191;
user-select: none;
font-weight: 300;
display: inline-block;
border-radius: 3px;
max-width: 80%;
text-align: center;
span {
margin: 0 5px;
}
a {
color: #939596;
cursor: pointer;
font-size: 12px;
font-weight: 400;
&:hover {
color: #1890ff;
}
}
}
}
html[theme-mode='dark'] {
.im-message-sys-text {
.sys-text {
background: unset;
}
}
}

View File

@ -1,6 +1,6 @@
// import { h } from 'vue'
// import { NAvatar } from 'naive-ui'
import { useTalkStore,useUserStore } from '@/store'
import { useTalkStore,useUserStore,useDialogueStore } from '@/store'
// import { notifyIcon } from '@/constant/default'
import WsSocket from './plugins/ws-socket'
import EventTalk from './event/talk'
@ -86,7 +86,7 @@ class Connect {
this.onPing()
this.onPong()
this.onImMessage()
// this.onImMessageRead()
this.onImMessageRead()
// this.onImContactStatus()
this.onImMessageRevoke()
// this.onImMessageKeyboard()
@ -104,21 +104,21 @@ class Connect {
this.conn.on('im.message', (data) => new EventTalk(data))
}
// onImMessageRead() {
// this.conn.on('im.message.read', (data) => {
// const dialogueStore = useDialogueStore()
onImMessageRead() {
this.conn.on('im.message.read', (data) => {
const dialogueStore = useDialogueStore()
// if (dialogueStore.index_name !== `1_${data.sender_id}`) {
// return
// }
if (dialogueStore.index_name !== `1_${data.sender_id}`) {
return
}
// const { msg_ids = [] } = data
const { msg_ids = [] } = data
// for (const msgid of msg_ids) {
// dialogueStore.updateDialogueRecord({ msg_id: msgid, is_read: 1 })
// }
// })
// }
for (const msgid of msg_ids) {
dialogueStore.updateDialogueRecord({ msg_id: msgid, is_read: 1 })
}
})
}
onImContactStatus() {
// 好友在线状态事件

21
src/constant/default.ts Normal file
View File

@ -0,0 +1,21 @@
import avatar from '@/static/image/chatList/notify.png'
import notify from '@/static/image/chatList/notify.png'
export const GenderOptions = [
{
label: '未知',
value: '0'
},
{
label: '男',
value: '1'
},
{
label: '女',
value: '2'
}
]
export const defAvatar = avatar
export const notifyIcon = notify

104
src/directive/dropsize.js Normal file
View File

@ -0,0 +1,104 @@
import { storage } from '@/utils/storage'
function getCacheKey(key, direction) {
if (!key.length) return ''
return `dropsize_${direction}_${key}`
}
export default {
mounted: function (el, binding) {
let { min, max, direction = 'right', key = '' } = binding.value
el.style.position = 'relative'
el.touch = { status: false, pageX: 0, pageY: 0, width: 0, height: 0 }
const cacheKey = getCacheKey(key, direction)
const cursor = ['left', 'right'].includes(direction) ? 'col-resize' : 'row-resize'
const linedom = document.createElement('div')
linedom.className = `dropsize-line dropsize-line-${direction}`
el.linedomMouseup = function () {
if (!el.touch.status) return
el.touch.status = false
linedom.classList.remove('dropsize-resizing')
document.querySelector('body').classList.remove(`dropsize-${cursor}`)
}
el.linedomMousemove = function (e) {
if (!el.touch.status) return
let width,
height = 0
switch (direction) {
case 'left':
case 'right':
if (direction == 'left') {
width = el.touch.width + el.touch.pageX - e.pageX
} else {
width = el.touch.width + e.pageX - el.touch.pageX
}
if (width < min) width = min
if (width > max) width = max
el.style.width = `${width}px`
cacheKey && storage.set(cacheKey, width)
break
case 'top':
case 'bottom':
if (direction == 'top') {
height = el.touch.height + el.touch.pageY - e.pageY
} else {
height = el.touch.height + e.pageY - el.touch.pageY
}
if (height < min) height = min
if (height > max) height = max
el.style.height = `${height}px`
cacheKey && storage.set(cacheKey, height)
break
}
}
linedom.addEventListener('mousedown', function (e) {
el.touch = {
status: true,
pageX: e.pageX,
pageY: e.pageY,
width: el.offsetWidth,
height: el.offsetHeight
}
this.classList.add('dropsize-resizing')
document.querySelector('body').classList.add(`dropsize-${cursor}`)
document.addEventListener('mouseup', el.linedomMouseup)
document.addEventListener('mousemove', el.linedomMousemove)
})
if (cacheKey) {
const value = storage.get(cacheKey)
if (direction == 'left' || direction == 'right') {
el.style.width = `${value}px`
} else {
el.style.height = `${value}px`
}
}
el.appendChild(linedom)
},
unmounted: function (el) {
document.removeEventListener('mousemove', el.linedomMouseup)
document.removeEventListener('mouseup', el.linedomMousemove)
}
}

7
src/directive/focus.js Normal file
View File

@ -0,0 +1,7 @@
export default {
mounted(el) {
setTimeout(() => {
el.focus()
}, 0)
}
}

15
src/directive/index.ts Normal file
View File

@ -0,0 +1,15 @@
import dropsize from './dropsize'
import focus from './focus'
import loading from './loading'
const directives = {
dropsize,
focus,
loading
}
export function setupDirective(app: any) {
for (const key in directives) {
app.directive(key, directives[key])
}
}

View File

@ -0,0 +1,177 @@
<template>
<div class="loading-content">
<div class="ant-spin ant-spin-spinning">
<span class="ant-spin-dot ant-spin-dot-spin">
<i class="ant-spin-dot-item"></i>
<i class="ant-spin-dot-item"></i>
<i class="ant-spin-dot-item"></i>
<i class="ant-spin-dot-item"></i>
</span>
<div class="ant-spin-text" style="color: #9b9b9b; margin-top: 8px">加载中...</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.loading-content {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 13px;
background: var(--im-bg-color);
z-index: 1000;
position: absolute;
left: 0;
top: 0;
p {
margin-top: 10px;
color: rgb(194 194 194);
}
}
.ant-spin {
box-sizing: border-box;
margin: 0;
padding: 0;
color: #000000d9;
font-size: 14px;
font-variant: tabular-nums;
line-height: 1.5715;
list-style: none;
font-feature-settings: 'tnum';
position: absolute;
display: none;
color: #1890ff;
text-align: center;
vertical-align: middle;
opacity: 0;
transition: transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
}
.ant-spin-spinning {
position: static;
display: inline-block;
opacity: 1;
}
.ant-spin-container {
position: relative;
transition: opacity 0.3s;
}
.ant-spin-container:after {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 10;
display: none;
width: 100%;
height: 100%;
background: #fff;
opacity: 0;
transition: all 0.3s;
content: '';
pointer-events: none;
}
.ant-spin-blur {
clear: both;
opacity: 0.5;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
pointer-events: none;
}
.ant-spin-blur:after {
opacity: 0.4;
pointer-events: auto;
}
.ant-spin-tip {
color: #00000073;
}
.ant-spin-dot {
position: relative;
display: inline-block;
font-size: 20px;
width: 1em;
height: 1em;
}
.ant-spin-dot-item {
position: absolute;
display: block;
width: 9px;
height: 9px;
background-color: #1890ff;
border-radius: 100%;
transform: scale(0.75);
transform-origin: 50% 50%;
opacity: 0.3;
animation: antSpinMove 1s infinite linear alternate;
}
.ant-spin-dot-item:nth-child(1) {
top: 0;
left: 0;
}
.ant-spin-dot-item:nth-child(2) {
top: 0;
right: 0;
animation-delay: 0.4s;
}
.ant-spin-dot-item:nth-child(3) {
right: 0;
bottom: 0;
animation-delay: 0.8s;
}
.ant-spin-dot-item:nth-child(4) {
bottom: 0;
left: 0;
animation-delay: 1.2s;
}
.ant-spin-dot-spin {
transform: rotate(45deg);
animation: antRotate 1.2s infinite linear;
}
.ant-spin-sm .ant-spin-dot {
font-size: 14px;
}
.ant-spin-sm .ant-spin-dot i {
width: 6px;
height: 6px;
}
.ant-spin-lg .ant-spin-dot {
font-size: 32px;
}
.ant-spin-lg .ant-spin-dot i {
width: 14px;
height: 14px;
}
.ant-spin.ant-spin-show-text .ant-spin-text {
display: block;
}
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
.ant-spin-blur {
background: #fff;
opacity: 0.5;
}
}
.ant-spin-rtl {
direction: rtl;
}
.ant-spin-rtl .ant-spin-dot-spin {
transform: rotate(-45deg);
animation-name: antRotateRtl;
}
@keyframes antSpinMove {
to {
opacity: 1;
}
}
@keyframes antRotate {
to {
transform: rotate(405deg);
}
}
</style>

55
src/directive/loading.js Normal file
View File

@ -0,0 +1,55 @@
import { createApp } from 'vue'
import Loading from './inner/loading.vue'
export default {
mounted(el, binding) {
const app = createApp(Loading)
const instance = app.mount(document.createElement('div'))
el.instance = instance
if (binding.value) {
if (el.style.position == '') {
el.dataset['position'] = true
}
if (el.style.overflow == '') {
el.dataset['overflow'] = true
}
appendEl(el)
}
},
updated(el, binding) {
if (binding.value !== binding.oldValue) {
binding.value ? appendEl(el) : removeEl(el)
}
}
}
// 插入元素
const appendEl = (el) => {
// 给父元素加个定位让loading元素定位
el.style.position = 'relative'
el.style.overflow = 'hidden'
el?.appendChild(el.instance.$el)
}
// 移除元素
const removeEl = (el) => {
if (el.dataset['position']) {
el.style.position = ''
}
if (el.dataset['overflow']) {
el.style.overflow = ''
}
delete el.dataset['position']
delete el.dataset['overflow']
let $el = el.instance.$el
if (el?.contains($el)) {
el?.removeChild($el)
}
}

View File

@ -1,4 +1,4 @@
import { useDialogueStore } from '@/store'
import { useUserStore,useDialogueStore } from '@/store'
// import router from '@/router'
import { useAuth } from "@/store/auth/index.js";
@ -14,7 +14,7 @@ class Base {
* 获取当前登录用户的ID
*/
getAccountId() {
return userInfo.value.ID
return useUserStore().uid
}
getTalkParams() {

14
src/hooks/index.js Normal file
View File

@ -0,0 +1,14 @@
// export * from './useAccessPrompt'
// export * from './useClickEvent'
// export * from './useConnectStatus'
// export * from './useEventBus'
// export * from './useFriendsMenu'
// export * from './useGroupListMenu'
export * from './useInject'
export * from './useSessionMenu'
// export * from './useSmsLock'
export * from './useTalkRecord'
// export * from './useThemeMode'
// export * from './useUnreadMessage'
export * from './useProvideUserModal'
// export * from './useVisibilityChange'

11
src/hooks/useInject.js Normal file
View File

@ -0,0 +1,11 @@
import { inject } from 'vue'
export function useInject() {
const user = inject('$user')
const showUserInfoModal = (uid) => {
user(uid)
}
return { showUserInfoModal }
}

View File

@ -0,0 +1,20 @@
import { ref, provide } from 'vue'
export function useProvideUserModal() {
const isShow = ref(false)
const uid = ref(0)
const show = (id) => {
uid.value = id
isShow.value = true
}
const close = () => {
uid.value = 0
isShow.value = false
}
provide('$user', show)
return { isShow, uid, show, close }
}

268
src/hooks/useSessionMenu.js Normal file
View File

@ -0,0 +1,268 @@
import { reactive, nextTick, computed, h, inject } from 'vue'
// import { renderIcon } from '@/utils/util'
// import {
// ArrowUp,
// ArrowDown,
// Logout,
// Delete,
// Clear,
// Remind,
// CloseRemind,
// EditTwo,
// IdCard
// } from '@icon-park/vue-next'
import { ServeTopTalkList, ServeDeleteTalkList, ServeSetNotDisturb } from '@/api/chat'
import { useDialogueStore, useTalkStore } from '@/store'
import { ServeSecedeGroup } from '@/api/group'
// import { ServeDeleteContact, ServeEditContactRemark } from '@/api/contact'
// import { NInput } from 'naive-ui'
export function useSessionMenu() {
const dropdown = reactive({
options: [],
show: false,
x: 0,
y: 0,
item: {}
})
const dialogueStore = useDialogueStore()
const talkStore = useTalkStore()
const user = inject('$user')
// 当前会话索引
const indexName = computed(() => dialogueStore.index_name)
// const onContextMenu = (e: any, item: ISession) => {
// dropdown.show = false
// dropdown.item = Object.assign({}, item)
// dropdown.options = []
// const options: any[] = []
// if (item.talk_type == 1) {
// options.push({
// icon: renderIcon(IdCard),
// label: '好友信息',
// key: 'info'
// })
// options.push({
// icon: renderIcon(EditTwo),
// label: '修改备注',
// key: 'remark'
// })
// }
// options.push({
// icon: renderIcon(item.is_top ? ArrowDown : ArrowUp),
// label: item.is_top ? '取消置顶' : '会话置顶',
// key: 'top'
// })
// options.push({
// icon: renderIcon(item.is_disturb ? Remind : CloseRemind),
// label: item.is_disturb ? '关闭免打扰' : '开启免打扰',
// key: 'disturb'
// })
// options.push({
// icon: renderIcon(Clear),
// label: '移除会话',
// key: 'remove'
// })
// if (item.talk_type == 1) {
// options.push({
// icon: renderIcon(Delete),
// label: '删除好友',
// key: 'delete_contact'
// })
// } else {
// options.push({
// icon: renderIcon(Logout),
// label: '退出群聊',
// key: 'signout_group'
// })
// }
// dropdown.options = [...options]
// nextTick(() => {
// dropdown.show = true
// dropdown.x = e.clientX
// dropdown.y = e.clientY
// })
// e.preventDefault()
// }
const onCloseContextMenu = () => {
dropdown.show = false
dropdown.item = {}
}
const onDeleteTalk = (index_name = '') => {
talkStore.delItem(index_name)
index_name === indexName.value && dialogueStore.$reset()
}
const onUserInfo = (item) => {
user(item.receiver_id)
}
// 移除会话
const onRemoveTalk = (item) => {
ServeDeleteTalkList({
list_id: item.id
}).then(({ code }) => {
if (code == 200) {
onDeleteTalk(item.index_name)
}
})
}
// 设置消息免打扰
const onSetDisturb = (item) => {
ServeSetNotDisturb({
talk_type: item.talk_type,
receiver_id: item.receiver_id,
is_disturb: item.is_disturb == 0 ? 1 : 0
}).then(({ code, message }) => {
if (code == 200) {
window['$message'].success('设置成功!')
talkStore.updateItem({
index_name: item.index_name,
is_disturb: item.is_disturb == 0 ? 1 : 0
})
} else {
window['$message'].error(message)
}
})
}
// 置顶会话
const onToTopTalk = (item) => {
if (item.is_top == 0 && talkStore.topItems.length >= 18) {
return window['$message'].info('置顶最多不能超过18个会话')
}
ServeTopTalkList({
list_id: item.id,
type: item.is_top == 0 ? 1 : 2
}).then(({ code, message }) => {
if (code == 200) {
talkStore.updateItem({
index_name: item.index_name,
is_top: item.is_top == 0 ? 1 : 0
})
} else {
window['$message'].error(message)
}
})
}
// // 移除联系人
// const onDeleteContact = (item) => {
// const name = item.remark || item.name
// window['$dialog'].create({
// showIcon: false,
// title: `删除 [${name}] 联系人?`,
// content: '删除后不再接收对方任何消息。',
// positiveText: '确定',
// negativeText: '取消',
// onPositiveClick: () => {
// ServeDeleteContact({
// friend_id: item.receiver_id
// }).then(({ code, message }) => {
// if (code == 200) {
// window['$message'].success('删除联系人成功')
// onDeleteTalk(item.index_name)
// } else {
// window['$message'].error(message)
// }
// })
// }
// })
// }
// 退出群聊
const onSignOutGroup = (item) => {
window['$dialog'].create({
showIcon: false,
title: `退出 [${item.name}] 群聊?`,
content: '退出后不再接收此群的任何消息。',
positiveText: '确定',
negativeText: '取消',
onPositiveClick: () => {
ServeSecedeGroup({
group_id: item.receiver_id
}).then(({ code, message }) => {
if (code == 200) {
window['$message'].success('已退出群聊')
onDeleteTalk(item.index_name)
} else {
window['$message'].error(message)
}
})
}
})
}
// const onChangeRemark = (item) => {
// let remark = ''
// window['$dialog'].create({
// showIcon: false,
// title: '修改备注',
// content: () => {
// return h(NInput, {
// defaultValue: item.remark,
// placeholder: '请输入备注信息',
// style: { marginTop: '20px' },
// onInput: (value) => (remark = value),
// autofocus: true
// })
// },
// negativeText: '取消',
// positiveText: '修改备注',
// onPositiveClick: () => {
// ServeEditContactRemark({
// friend_id: item.receiver_id,
// remark: remark
// }).then(({ code, message }) => {
// if (code == 200) {
// window['$message'].success('备注成功')
// talkStore.updateItem({
// index_name: item.index_name,
// remark: remark
// })
// } else {
// window['$message'].error(message)
// }
// })
// }
// })
// }
// 会话列表右键菜单回调事件
const onContextMenuTalkHandle = (key) => {
// 注册回调事件
const evnets = {
info: onUserInfo,
top: onToTopTalk,
remove: onRemoveTalk,
disturb: onSetDisturb,
signout_group: onSignOutGroup,
delete_contact: onDeleteContact,
remark: onChangeRemark
}
dropdown.show = false
evnets[key] && evnets[key](dropdown.item)
}
return { dropdown, onCloseContextMenu, onContextMenuTalkHandle, onToTopTalk }
}

146
src/hooks/useTalkRecord.js Normal file
View File

@ -0,0 +1,146 @@
import { reactive, computed, nextTick } from 'vue'
import { ServeTalkRecords } from '@/api/chat'
import { useDialogueStore } from '@/store'
import { formatTalkRecord } from '@/utils/talk'
import { addClass, removeClass } from '@/utils/dom'
export const useTalkRecord = (uid) => {
const dialogueStore = useDialogueStore()
const records = computed(() => dialogueStore.records)
const location = reactive({
msgid: '',
num: 0
})
const loadConfig = reactive({
receiver_id: 0,
talk_type: 0,
status: 0,
cursor: 0
})
const onJumpMessage = (msgid) => {
const element = document.getElementById(msgid)
if (!element) {
if (location.msgid == '') {
location.msgid = msgid
location.num = 3
} else {
location.num--
if (location.num === 0) {
location.msgid = ''
location.num = 0
window['$message'].info('仅支持查看最近300条的记录')
return
}
}
const el = document.getElementById('imChatPanel')
return el?.scrollTo({
top: 0,
behavior: 'smooth'
})
}
location.msgid = ''
location.num = 0
element?.scrollIntoView({
behavior: 'smooth'
})
addClass(element, 'border')
setTimeout(() => {
element && removeClass(element, 'border')
}, 3000)
}
// 加载数据列表
const load = async (params) => {
const request = {
talk_type: params.talk_type,
receiver_id: params.receiver_id,
cursor: loadConfig.cursor,
limit: 30
}
loadConfig.status = 0
let scrollHeight = 0
const el = document.getElementById('imChatPanel')
if (el) {
scrollHeight = el.scrollHeight
}
const { data, code } = await ServeTalkRecords(request)
if (code != 200) {
return (loadConfig.status = 1)
}
// 防止对话切换过快,数据渲染错误
if (
request.talk_type != loadConfig.talk_type ||
request.receiver_id != loadConfig.receiver_id
) {
return (location.msgid = '')
}
const items = (data.items || []).map((item) => formatTalkRecord(uid, item))
if (request.cursor == 0) {
// 判断是否是初次加载
dialogueStore.clearDialogueRecord()
}
dialogueStore.unshiftDialogueRecord(items.reverse())
loadConfig.status = items.length >= request.limit ? 1 : 2
loadConfig.cursor = data.cursor
nextTick(() => {
const el = document.getElementById('imChatPanel')
if (el) {
if (request.cursor == 0) {
el.scrollTop = el.scrollHeight
setTimeout(() => {
el.scrollTop = el.scrollHeight + 1000
}, 50)
} else {
el.scrollTop = el.scrollHeight - scrollHeight
}
}
if (location.msgid) {
onJumpMessage(location.msgid)
}
})
}
const onRefreshLoad = () => {
if (loadConfig.status == 1) {
load({
receiver_id: loadConfig.receiver_id,
talk_type: loadConfig.talk_type,
limit: 30
})
}
}
const onLoad = (params) => {
loadConfig.cursor = 0
loadConfig.receiver_id = params.receiver_id
loadConfig.talk_type = params.talk_type
load(params)
}
return { loadConfig, records, onLoad, onRefreshLoad, onJumpMessage }
}

View File

@ -11,6 +11,7 @@ import xLoaderror from '@/components/x-loaderror/index.vue'
import { vLoading } from "@/components/x-loading/index.js"
import messagePopup from '@/components/x-message/useMessagePopup'
import pageAnimation from '@/components/page-animation/index.vue'
import * as plugins from './plugins'
const {showMessage}=messagePopup()
dayjs.locale('zh-cn')
if (import.meta.env.VITE_SHOW_CONSOLE){
@ -19,6 +20,8 @@ if (import.meta.env.VITE_SHOW_CONSOLE){
export function createApp() {
const app = createSSRApp(App)
app.use(tmui,{...config})
plugins.setPinia(app)
plugins.setComponents(app)
app.directive("loading", vLoading)
app.mixin(pageAnimation)
app.component('x-loaderror',xLoaderror)

View File

@ -14,6 +14,14 @@
"enablePullDownRefresh":false
}
},
{
"path": "pages/dialog/index",
"type": "page",
"style": {
"navigationStyle": "custom",
"enablePullDownRefresh":false
}
},
{

View File

@ -0,0 +1,140 @@
<template>
<div>
<wd-swipe-action >
<div @click="cellClick" :class="['chatItem',props.data.is_top ===1?'isTop':'' ]">
<div class="avatarImg">
<tm-badge :count="props.data.unread_num" :maxCount="99" color="#D03050">
<tm-image preview :width="96" :height="96" :round="12" :src="props.data.avatar"></tm-image>
</tm-badge>
</div>
<div class="chatInfo">
<div class="chatInfo_1">
<div class="flex items-center">
<div class="text-[#000000] text-[32rpx] font-bold opacity-90">{{ props.data.name }}</div>
<div>
<div class="companyTag">公司</div>
</div>
</div>
<div class="text-[#000000] text-[28rpx] font-medium opacity-26">{{ beautifyTime(props.data.updated_at) }}
</div>
</div>
<div class="chatInfo_2 w-full mr-[6rpx]">
<div class="w-full chatInfo_2_1 textEllipsis">{{ props.data.msg_text }}</div>
</div>
</div>
</div>
<template #right>
<div class="flex flex-row flex-row-center-end">
<div @click="handleTop" class="w-[156rpx] h-[154rpx] text-[#ffffff] bg-[#F09F1F] flex items-center justify-center">{{props.data.is_top===1?'取消置顶':'置顶'}}</div>
<div class="w-[156rpx] h-[154rpx] text-[#ffffff] bg-[#CF3050] flex items-center justify-center">删除</div>
</div>
</template>
</wd-swipe-action>
<div v-if="props.index !== talkStore.talkItems.length - 1" class="divider"></div>
</div>
</template>
<script setup>
import { ref, reactive, defineProps } from "vue"
import dayjs from "dayjs";
import { beautifyTime } from '@/utils/datetime'
import { useTalkStore } from '@/store'
import { useSessionMenu } from '@/hooks'
const talkStore = useTalkStore()
const {
onToTopTalk
} = useSessionMenu()
const props = defineProps({
data: {
type: Object,
default: {},
required: true,
},
index: {
type: Number,
default: -1,
required: true,
}
});
const cellClick = () => {
console.log(props.data);
}
const handleTop = () => {
console.log(props.data,1);
onToTopTalk(props.data)
}
</script>
<style lang="scss" scoped>
.chatItem {
width: 100%;
height: 154rpx;
padding: 30rpx 16rpx;
display: flex;
align-items: center;
&.isTop{
background-color: #F3F3F3;
}
}
.avatarImg {
height: 96rpx;
width: 96rpx;
}
.chatInfo {
flex: 1;
margin-left: 20rpx;
}
.chatInfo_1 {
display: flex;
align-items: center;
justify-content: space-between;
}
.chatInfo_2 {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 6rpx;
}
.chatInfo_2_1 {
font-size: 28rpx;
color: #000000;
opacity: 40%;
}
.companyTag {
width: 76rpx;
height: 38rpx;
border: 1px solid #7A58DE;
font-size: 24rpx;
text-align: center;
border-radius: 6rpx;
color: #7A58DE;
font-weight: bold;
margin-left: 12rpx;
}
.textEllipsis {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
}
.divider {
background-color: rgba(243, 243, 243, 1);
height: 1rpx;
margin: 0 18rpx;
}
</style>

143
src/pages/dialog/index.vue Normal file
View File

@ -0,0 +1,143 @@
<template>
<div class="outer-layer">
<div>
<tm-navbar :hideBack="false" hideHome title="" :leftWidth="220" >
<div class="flex flex-col items-center justify-center" >
<div class="text-[34rpx] font-bold" >{{ talkParams.username }}</div>
<div v-if="true" class="text-[24rpx] text-[#999999]" >公司群</div>
</div>
<template v-slot:right>
<div class="mr-[36rpx]">
<tm-icon color="rgb(51, 51, 51)" :font-size="36" name="tmicon-gengduo"></tm-icon>
</div>
</template>
</tm-navbar>
</div>
<div class="root">
<div class="dialogBox" >
<!-- <div v-if="loadConfig.status == 0" class="h-[240rpx] flex items-center justify-center flex-col" >
<wd-loading />
<div class="text-[#959598] mt-[20rpx] text-[28rpx]" > 正在加载中... </div>
</div>
<div v-else-if="loadConfig.status == 1" @click="onRefreshLoad" >查看更多消息 ...</div>
<span v-else class="no-more"> 没有更多消息了 </span> -->
<div
class="message-item"
v-for="(item, index) in records"
:key="item.msg_id"
:id="item.msg_id"
>
<!-- 系统消息 -->
<div v-if="item.msg_type >= 1000" class="message-box">
<component
:is="MessageComponents[item.msg_type] || 'unknown-message'"
:extra="item.extra"
:data="item"
/>
</div>
<!-- 撤回消息 -->
<div v-else-if="item.is_revoke == 1" class="message-box">
<revoke-message
:login_uid="uid"
:user_id="item.user_id"
:nickname="item.nickname"
:talk_type="item.talk_type"
:datetime="item.created_at"
/>
</div>
</div>
</div>
</div>
<div class="footBox" >
<div class="mt-[16rpx] ml-[32rpx] mr-[32rpx] flex items-center justify-between" >
<div class="w-[534rpx]" >
<tm-input :height="72" placeholder="" ></tm-input>
</div>
<tm-image :width="52" :height="52" :round="12" :src="smile"></tm-image>
<tm-image :width="52" :height="52" :round="12" :src="addCircleGray"></tm-image>
</div>
</div>
</div>
</template>
<script setup>
import { ref,reactive,watch,computed,onMounted } from 'vue';
import { useChatList } from "@/store/chatList/index.js";
import {useAuth} from "@/store/auth";
import { useUserStore, useDialogueStore, useUploadsStore } from '@/store'
import addCircleGray from "@/static/image/chatList/addCircleGray.png";
import { MessageComponents, ForwardableMessageType } from '@/constant/message'
import smile from "@/static/image/chatList/smile@2x.png";
import { useInject, useTalkRecord } from '@/hooks'
const userStore = useUserStore()
const dialogueStore = useDialogueStore()
const talkParams = reactive({
uid: computed(() => userStore.uid),
index_name: computed(() => dialogueStore.index_name),
type: computed(() => dialogueStore.talk.talk_type),
receiver_id: computed(() => dialogueStore.talk.receiver_id),
username: computed(() => dialogueStore.talk.username),
online: computed(() => dialogueStore.online),
keyboard: computed(() => dialogueStore.keyboard),
num: computed(() => dialogueStore.members.length)
})
const { loadConfig, records, onLoad, onRefreshLoad, onJumpMessage } = useTalkRecord(talkParams.uid)
watch(() => records, (newValue, oldValue) => {
console.log(newValue);
},{deep:true,immediate:true})
watch(() => talkParams.uid, (newValue, oldValue) => {
let objT = {
uid: talkParams.uid,
talk_type: talkParams.type,
receiver_id: talkParams.receiver_id,
index_name: talkParams.index_name
}
onLoad({ ...objT, limit: 30 })
},{immediate:true})
onMounted(() => {
let objT = {
uid: talkParams.uid,
talk_type: talkParams.type,
receiver_id: talkParams.receiver_id,
index_name: talkParams.index_name
}
onLoad({ ...objT, limit: 30 })
})
</script>
<style scoped lang="scss">
.outer-layer {
overflow-y: auto;
flex: 1;
background-image: url("@/static/image/clockIn/z3280@3x.png");
background-size: cover;
display: flex;
flex-direction: column;
}
.root {
flex: 1;
padding: 20rpx 32rpx;
}
.searchRoot {
background-color: #fff;
padding: 22rpx 18rpx;
}
.contentRoot {
margin-top: 20rpx;
background-color: #fff;
}
.footBox {
height: 162rpx;
background-color: #fff;
}
.dialogBox{
height: 100%;
}
</style>

View File

@ -0,0 +1,162 @@
<template>
<div>
<wd-swipe-action>
<div @click="cellClick" :class="['chatItem', props.data.is_top === 1 ? 'isTop' : '']">
<div class="avatarImg">
<tm-badge :count="props.data.unread_num" :maxCount="99" color="#D03050">
<tm-image preview :width="96" :height="96" :round="12" :src="props.data.avatar"></tm-image>
</tm-badge>
</div>
<div class="chatInfo">
<div class="chatInfo_1">
<div class="flex items-center">
<div class="text-[#000000] text-[32rpx] font-bold opacity-90">{{ props.data.name }}</div>
<div>
<div class="companyTag">公司</div>
</div>
</div>
<div class="text-[#000000] text-[28rpx] font-medium opacity-26">{{ beautifyTime(props.data.updated_at) }}
</div>
</div>
<div class="chatInfo_2 w-full mr-[6rpx]">
<div class="w-full chatInfo_2_1 textEllipsis">{{ props.data.msg_text }}</div>
</div>
</div>
</div>
<template #right>
<div class="flex flex-row flex-row-center-end">
<div @click="handleTop"
class="w-[156rpx] h-[154rpx] text-[#ffffff] bg-[#F09F1F] flex items-center justify-center">
{{ props.data.is_top === 1 ? '取消置顶' : '置顶' }}</div>
<div class="w-[156rpx] h-[154rpx] text-[#ffffff] bg-[#CF3050] flex items-center justify-center">删除</div>
</div>
</template>
</wd-swipe-action>
<div v-if="props.index !== talkStore.talkItems.length - 1" class="divider"></div>
</div>
</template>
<script setup>
import { ref, reactive, defineProps } from "vue"
import dayjs from "dayjs";
import { beautifyTime } from '@/utils/datetime'
import { ServeClearTalkUnreadNum } from '@/api/chat'
import { useTalkStore, useDialogueStore } from '@/store'
import { useSessionMenu } from '@/hooks'
const talkStore = useTalkStore()
const {
onToTopTalk
} = useSessionMenu()
const dialogueStore = useDialogueStore()
const props = defineProps({
data: {
type: Object,
default: {},
required: true,
},
index: {
type: Number,
default: -1,
required: true,
}
});
const cellClick = () => {
console.log(props.data);
//
dialogueStore.setDialogue(props.data)
//
if (props.data.unread_num > 0) {
ServeClearTalkUnreadNum({
talk_type: props.data.talk_type,
receiver_id: props.data.receiver_id
}).then(() => {
talkStore.updateItem({
index_name: props.data.index_name,
unread_num: 0
})
})
}
uni.navigateTo({
url: '/pages/dialog/index',
})
}
const handleTop = () => {
console.log(props.data, 1);
onToTopTalk(props.data)
}
</script>
<style lang="scss" scoped>
.chatItem {
width: 100%;
height: 154rpx;
padding: 30rpx 16rpx;
display: flex;
align-items: center;
&.isTop {
background-color: #F3F3F3;
}
}
.avatarImg {
height: 96rpx;
width: 96rpx;
}
.chatInfo {
flex: 1;
margin-left: 20rpx;
}
.chatInfo_1 {
display: flex;
align-items: center;
justify-content: space-between;
}
.chatInfo_2 {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 6rpx;
}
.chatInfo_2_1 {
font-size: 28rpx;
color: #000000;
opacity: 40%;
}
.companyTag {
width: 76rpx;
height: 38rpx;
border: 1px solid #7A58DE;
font-size: 24rpx;
text-align: center;
border-radius: 6rpx;
color: #7A58DE;
font-weight: bold;
margin-left: 12rpx;
}
.textEllipsis {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
}
.divider {
background-color: rgba(243, 243, 243, 1);
height: 1rpx;
margin: 0 18rpx;
}
</style>

View File

@ -1,42 +1,66 @@
<template>
<div class="outer-layer">
<div>
<tm-navbar :hideBack="false" hideHome :title="123"> </tm-navbar>
<tm-navbar :hideBack="false" hideHome title="" :leftWidth="320" >
<template v-slot:left>
<div class="flex items-center ml-[48rpx]" >
<tm-image :width="72" :height="72" :round="12" :src="userStore.avatar"></tm-image>
<div class="ml-[24rpx] text-[36rpx] font-bold">{{ userStore.nickname }}</div>
</div>
</template>
<template v-slot:right>
<div class="mr-[48rpx]">
<tm-image :width="41" :height="41" :round="12" :src="addCircle"></tm-image>
</div>
</template>
</tm-navbar>
</div>
<div class="root">
<div class="searchRoot">
<tm-input placeholder="请输入…" color="#F9F9FD" :round="1" prefix="tmicon-search" prefixColor="#46299D" ></tm-input>
</div>
<div class="contentRoot">
<div class="chatItem" >
<div class="avatarImg">
<tm-image preview :width="96" :height="96" :src="userInfo.Avatar"></tm-image>
</div>
<div class="chatInfo" >
<div class="chatInfo_1" >
<div class="flex items-center">
<div class="text-[#000000] text-[32rpx] font-bold opacity-90" >泰丰国际600</div>
<div>
<div class="companyTag" >公司</div>
</div>
</div>
<div class="text-[#000000] text-[28rpx] font-medium opacity-26" >12:00</div>
</div>
<div class="chatInfo_2 w-full mr-[6rpx]">
<div class="w-full chatInfo_2_1 textEllipsis" >欢迎加入欢迎加入欢迎加入欢迎加入欢迎加入欢迎加入欢迎加入欢迎加入</div>
</div>
</div>
</div>
<chatItem
v-for="(item,index) in items"
:key="item.index_name"
:index="index"
:data="item"
/>
</div>
</div>
</div>
</template>
<script setup>
import { ref,watch,computed } from 'vue';
import { useChatList } from "@/store/chatList/index.js";
import {useAuth} from "@/store/auth";
import { useTalkStore,useUserStore } from '@/store'
import chatItem from './components/chatItem.vue';
import addCircle from "@/static/image/chatList/addCircle.png";
const talkStore = useTalkStore()
const userStore = useUserStore()
const {userInfo}=useAuth()
const topItems = computed(() => talkStore.topItems)
const items = computed(() => {
// if (searchKeyword.value.length === 0) {
return talkStore.talkItems
// }
// return talkStore.talkItems.filter((item) => {
// let keyword = item.remark || item.name
// return keyword.toLowerCase().indexOf(searchKeyword.value.toLowerCase()) != -1
// })
})
watch(() => talkStore, (newValue, oldValue) => {
console.log(talkStore);
},{deep:true,immediate:true})
</script>
<style scoped lang="scss">
.outer-layer {
@ -60,52 +84,5 @@ const {userInfo}=useAuth()
margin-top: 20rpx;
background-color: #fff;
}
.chatItem{
width: 100%;
padding: 30rpx 16rpx;
display: flex;
align-items: center;
}
.avatarImg{
height: 96rpx;
width: 96rpx;
}
.chatInfo{
flex:1;
margin-left: 20rpx;
}
.chatInfo_1{
display: flex;
align-items: center;
justify-content: space-between;
}
.chatInfo_2{
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 6rpx;
}
.chatInfo_2_1{
font-size: 28rpx;
color: #000000;
opacity: 40%;
}
.companyTag{
width: 76rpx;
height: 38rpx;
border: 1px solid #7A58DE;
font-size: 24rpx;
text-align: center;
border-radius: 6rpx;
color: #7A58DE;
font-weight: bold;
}
.textEllipsis {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
}
</style>

11
src/plugins/component.js Normal file
View File

@ -0,0 +1,11 @@
import { setComponents as Components } from '@/components/talk/message'
import Avatar from '@/components/base/Avatar.vue'
export { setupDirective } from '@/directive'
// 注册全局消息组件
export function setComponents(app) {
Components(app)
app.component('im-avatar', Avatar)
}

3
src/plugins/index.js Normal file
View File

@ -0,0 +1,3 @@
export * from './component'
export * from './ws-socket'
export * from './pinia'

9
src/plugins/pinia.js Normal file
View File

@ -0,0 +1,9 @@
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
export function setPinia(app) {
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(pinia)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -3,7 +3,8 @@ import {uniStorage} from "@/utils/uniStorage.js"
import {ref} from 'vue'
export const useAuth = createGlobalState(() => {
const token = useStorage('token', '', uniStorage)
// const token = useStorage('token', '', uniStorage)
const token = ref('30119d9978a6f3321fb4779c0040e997df4dd0dd0cf6b71119657617d2249ed783f940b0050d5be7e758740ea467afdf3eeb4d28fb5ea234af60ebe51fb218ffea38d3362de44912166520e87a6f38da2162e348845fabbb0db3604d4a2e7e543f680408fdbb166cbc70418235831e0b95aed8016b4a3b75feeec68212e4824da6e28c747409384ce136db6b9cd6e688cc40db73794e69f51be0920811cc437e51ca29fc82fccc1e98cb39b2577ef5b574460c853336d6afbe13149711d5e3fe')
const refreshToken = useStorage('refreshToken', '', uniStorage)
const userInfo = useStorage('userInfo', {}, uniStorage)
const leaderList = useStorage('leaderList', [], uniStorage)

View File

@ -4,5 +4,5 @@ export * from '@/store/modules/talk'
// export * from '@/store/modules/editor'
export * from '@/store/modules/dialogue'
// export * from '@/store/modules/editor-draft'
// export * from '@/store/modules/uploads'
export * from '@/store/modules/uploads'
// export * from '@/store/modules/note'

View File

@ -22,9 +22,15 @@ export const useTalkStore = defineStore('talk', {
// 对话列表
talkItems: (state) => {
return state.items.sort((a, b) => {
let topList = state.items.filter((item) => item.is_top == 1)
let listT = state.items.filter(v=>v.is_top !== 1)
let listP = topList.sort((a, b) => {
return ttime(b.updated_at) - ttime(a.updated_at)
})
listT = listT.sort((a, b) => {
return ttime(b.updated_at) - ttime(a.updated_at)
})
return [...listP,...listT]
},
// 消息未读数总计
@ -65,10 +71,9 @@ export const useTalkStore = defineStore('talk', {
// 更新对话消息
updateMessage(params) {
const item = this.items.find((item) => item.index_name === params.index_name)
if (item) {
item.unread_num++
// item.msg_text = params.msg_text
item.msg_text = params.msg_text
item.updated_at = params.updated_at
}
},

View File

@ -0,0 +1,125 @@
import { defineStore } from 'pinia'
import { ServeFindFileSplitInfo, ServeFileSubareaUpload } from '@/api/upload'
import { ServeSendTalkFile } from '@/api/chat'
// @ts-ignore
const message = window.$message
// 处理拆分上传文件
function fileSlice(file: File, uploadId: string, eachSize: number) {
const splitNum = Math.ceil(file.size / eachSize) // 分片总数
const items: FormData[] = []
// 处理每个分片的上传操作
for (let i = 0; i < splitNum; i++) {
const start = i * eachSize
const end = Math.min(file.size, start + eachSize)
const form = new FormData()
form.append('file', file.slice(start, end))
form.append('upload_id', uploadId)
form.append('split_index', `${i + 1}`)
form.append('split_num', `${splitNum}`)
items.push(form)
}
return items
}
export const useUploadsStore = defineStore('uploads', {
state: () => {
return {
isShow: false,
items: []
}
},
getters: {
successCount: (state) => {
return state.items.filter((item: any) => {
return item.status === 2
}).length
}
},
actions: {
close() {
this.isShow = false
},
// 初始化上传
initUploadFile(file: File, talkType: number, receiverId: number, username: string) {
ServeFindFileSplitInfo({
file_name: file.name,
file_size: file.size
}).then((res) => {
if (res.code == 200) {
const { upload_id, split_size } = res.data
// @ts-ignore
this.items.unshift({
file: file,
talk_type: talkType,
receiver_id: receiverId,
upload_id: upload_id,
uploadIndex: 0,
percentage: 0,
status: 0, // 文件上传状态 0:等待上传 1:上传中 2:上传完成 3:网络异常
files: fileSlice(file, upload_id, split_size),
avatar: '',
username: username
})
this.triggerUpload(upload_id)
this.isShow = true
} else {
message.error(res.message)
}
})
},
// 获取分片文件数组索引
findItem(uploadId: string): any {
return this.items.find((item: any) => item.upload_id === uploadId)
},
// 触发上传
triggerUpload(uploadId: string) {
const item = this.findItem(uploadId)
const form = item.files[item.uploadIndex]
item.status = 1
ServeFileSubareaUpload(form)
.then((res) => {
if (res.code == 200) {
item.uploadIndex++
if (item.uploadIndex === item.files.length) {
item.status = 2
item.percentage = 100
this.sendUploadMessage(item)
} else {
const percentage = (item.uploadIndex / item.files.length) * 100
item.percentage = percentage.toFixed(1)
this.triggerUpload(uploadId)
}
} else {
item.status = 3
}
})
.catch(() => {
item.status = 3
})
},
// 发送上传消息
sendUploadMessage(item: any) {
ServeSendTalkFile({
upload_id: item.upload_id,
receiver_id: item.receiver_id,
talk_type: item.talk_type
})
}
}
})

View File

@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
// import { ServeGetUserSetting } from '@/api/user'
import { ServeGetUserSetting } from '@/api/user/index.js'
// import { ServeFindFriendApplyNum } from '@/api/contact'
import { ServeGroupApplyUnread } from '@/api/group'
// import { delAccessToken } from '@/utils/auth'
@ -38,21 +38,21 @@ export const useUserStore = defineStore('user', {
// },
loadSetting() {
// ServeGetUserSetting().then(({ code, data }) => {
// if (code == 200) {
// this.nickname = data.user_info.nickname
// this.uid = data.user_info.uid
// this.avatar = data.user_info.avatar
ServeGetUserSetting().then(({ code, data }) => {
if (code == 200) {
this.nickname = data.user_info.nickname
this.uid = data.user_info.uid
this.avatar = data.user_info.avatar
// this.gender = data.user_info.gender
// this.mobile = data.user_info.mobile || ''
// this.email = data.user_info.email || ''
// this.motto = data.user_info.motto
// this.isQiye = data.user_info.is_qiye || false
this.gender = data.user_info.gender
this.mobile = data.user_info.mobile || ''
this.email = data.user_info.email || ''
this.motto = data.user_info.motto
this.isQiye = data.user_info.is_qiye || false
// storage.set('user_info', data)
// }
// })
}
})
// ServeFindFriendApplyNum().then(({ code, data }) => {
// if (code == 200) {

198
src/utils/common.js Normal file
View File

@ -0,0 +1,198 @@
import { createApp } from 'vue'
/**
* 防抖函数
*
* @param {*} fn 回调方法
* @param {*} delay 延迟时间
* @returns
*/
export function debounce(fn, delay) {
let timer = null
return function () {
timer && clearTimeout(timer)
let content = this
let args = arguments
timer = setTimeout(() => {
fn.apply(content, args)
}, delay)
}
}
/**
* 节流函数
*
* @param {*} fn 回调方法
* @param {*} delay 延迟时间
* @returns
*/
export function throttle(fn, delay, call = function () {}) {
let lastTime = 0
return function () {
// 获取当前时间戳
let now = new Date().getTime()
// 如果当前时间减去上次时间大于限制时间时才执行
if (now - lastTime > delay) {
lastTime = now
fn.apply(this, arguments)
} else {
call()
}
}
}
/**
* 剪贴板复制功能
*
* @param {String} text 复制内容
* @param {Function} callback 复制成功回调方法
*/
export function clipboard(text, callback) {
navigator.clipboard
.writeText(text)
.then(() => {
callback && callback()
})
.catch(() => {
alert('Oops, unable to copy')
})
}
export async function clipboardImage(src, callback) {
const { state } = await navigator.permissions.query({
name: 'clipboard-write'
})
if (state != 'granted') return
try {
const data = await fetch(src)
const blob = await data.blob()
// navigator.clipboard.write 仅支持 png 图片
if (blob.type == 'image/png') {
await navigator.clipboard.write([
new ClipboardItem({
[blob.type]: blob
})
])
return callback()
}
const objectURL = URL.createObjectURL(blob)
const img = new Image()
img.src = URL.createObjectURL(blob)
img.onload = () => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.width
canvas.height = img.height
ctx.drawImage(img, 0, 0)
canvas.toBlob(
(blob) => {
const data = [new ClipboardItem({ [blob.type]: blob })]
navigator.clipboard
.write(data)
.then(() => {
callback()
})
.catch((err) => {
console.error(err)
})
URL.revokeObjectURL(objectURL)
},
'image/png',
1
)
}
} catch (err) {
console.error(err.name, err.message)
}
}
export function hashStrToHexColor(str) {
let hash = 0
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash)
}
let color = '#'
for (let i = 0; i < 3; i++) {
const value = (hash >> (i * 8)) & 0xff
color += value.toString(16).padStart(2, '0')
}
return color
}
export function emitCall(event, data, fn) {
return { event: event, data: data, callBack: fn || function () {} }
}
// 判断是否是客户端模式
export function isElectronMode() {
return electron() != undefined
}
export function electron() {
return window.electron
}
export function htmlDecode(input) {
return input
.replace(/&amp;/g, '&')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&#34;/g, '"')
}
// 文件转 图片 关键函数 异步
export function getVideoImage(file, time = 1) {
return new Promise((resolve) => {
let video = document.createElement('video')
const objectURL = URL.createObjectURL(file)
video.src = objectURL
video.currentTime = time
video.autoplay = true
video.muted = true
video.oncanplay = () => {
let canvas = document.createElement('canvas')
canvas.width = video.videoWidth
canvas.height = video.videoHeight
let ctx = canvas.getContext('2d')
ctx?.drawImage(video, 0, 0, canvas.width, canvas.height)
let data = {
url: canvas.toDataURL('image/jpeg', 1),
width: video.videoWidth,
height: video.videoHeight,
duration: video.duration,
file: null
}
canvas.toBlob((blob) => {
data.file = new File([blob], 'image.jpeg', {
type: blob.type,
lastModified: Date.now()
})
URL.revokeObjectURL(objectURL)
resolve(data)
}, 'image/jpeg')
}
})
}

56
src/utils/dom.ts Normal file
View File

@ -0,0 +1,56 @@
function trim(string: string) {
return (string || '').replace(/^[\s\uFEFF]+|[\s\uFEFF]+$/g, '')
}
/* istanbul ignore next */
export function hasClass(el: Element, cls: string) {
if (!el || !cls) return false
if (cls.indexOf(' ') !== -1) throw new Error('className should not contain space.')
if (el.classList) {
return el.classList.contains(cls)
} else {
return (' ' + el.className + ' ').indexOf(' ' + cls + ' ') > -1
}
}
/* istanbul ignore next */
export function addClass(el: Element, cls: string) {
if (!el) return
let curClass = el.className
const classes = (cls || '').split(' ')
for (let i = 0, j = classes.length; i < j; i++) {
const clsName = classes[i]
if (!clsName) continue
if (el.classList) {
el.classList.add(clsName)
} else if (!hasClass(el, clsName)) {
curClass += ' ' + clsName
}
}
if (!el.classList) {
el.className = curClass
}
}
/* istanbul ignore next */
export function removeClass(el: Element, cls: string) {
if (!el || !cls) return
const classes = cls.split(' ')
let curClass = ' ' + el.className + ' '
for (let i = 0, j = classes.length; i < j; i++) {
const clsName = classes[i]
if (!clsName) continue
if (el.classList) {
el.classList.remove(clsName)
} else if (hasClass(el, clsName)) {
curClass = curClass.replace(' ' + clsName + ' ', ' ')
}
}
if (!el.classList) {
el.className = trim(curClass)
}
}

73
src/utils/storage.ts Normal file
View File

@ -0,0 +1,73 @@
interface IStorage {
setItem(key: string, value: any): void
getItem(key: string): any
removeItem(key: string): void
clear(): void
}
class Storage {
// 缓存前缀
prefix = ''
// 缓存驱动
storage: IStorage = localStorage
constructor(prefix = '', storage: IStorage) {
this.prefix = prefix
this.storage = storage
}
cacheKey(key: string) {
return `${this.prefix}_${key}`.toUpperCase()
}
get(key: string, def: any = '') {
const item = this.storage.getItem(this.cacheKey(key))
if (!item) return def
try {
const { value, expire } = JSON.parse(item)
// 在有效期内直接返回
if (expire === null || expire >= Date.now()) {
return value
}
this.remove(key)
} catch (e) {
console.warn(e)
}
return def
}
/**
*
*
* @param {String} key // 缓存KEY
* @param {Any} value // 缓存值
* @param {Number|null} expire // 缓存时间单位秒
*/
set(key: string, value: any, expire: number | null = 60 * 60 * 24) {
this.storage.setItem(
this.cacheKey(key),
JSON.stringify({
value,
expire: expire !== null ? new Date().getTime() + expire * 1000 : null
})
)
}
remove(key: string) {
this.storage.removeItem(this.cacheKey(key))
}
clear() {
this.storage.clear()
}
}
export default Storage
export const storage = new Storage('im', localStorage)

37
src/utils/upload/auth.js Normal file
View File

@ -0,0 +1,37 @@
import { storage } from './storage'
const AccessToken = 'AUTH_TOKEN'
/**
* 验证是否登录
*
* @returns token
*/
export function isLoggedIn() {
return getAccessToken() != ''
}
/**
* 获取登录授权 Token
*
* @returns token
*/
export function getAccessToken() {
return storage.get(AccessToken) || ''
}
/**
* 设置登录授权 Token
*
* @returns token
*/
export function setAccessToken(token = '', expire = 60 * 60 * 2) {
return storage.set(AccessToken, token, expire) || ''
}
/**
* 删除登录授权 Token
*/
export function delAccessToken() {
storage.remove(AccessToken)
}

109
src/utils/upload/request.js Normal file
View File

@ -0,0 +1,109 @@
import axios from 'axios'
import { delAccessToken, getAccessToken } from '@/utils/upload/auth'
// 创建 axios 实例
const request = axios.create({
// API 请求的默认前缀
baseURL: import.meta.env.VITE_BASE_API,
// 请求超时时间
timeout: 10000
})
let once = false
/**
* 异常拦截处理器
*
* @param {*} error
*/
const errorHandler = (error) => {
// 判断是否是响应错误信息
if (error.response) {
if (error.response.status == 401) {
delAccessToken()
if (!once) {
once = true
console.log('当前登录已失效,请重新登录');
// window['$dialog'].info({
// title: '友情提示',
// content: '当前登录已失效,请重新登录?',
// positiveText: '立即登录?',
// maskClosable: false,
// onPositiveClick: () => {
// location.reload()
// }
// })
}
}
}
return Promise.reject(error)
}
// 请求拦截器
request.interceptors.request.use((config) => {
const token = getAccessToken()
if (token) {
config.headers['Authorization'] = `Bearer ${token}`
}
return config
}, errorHandler)
// 响应拦截器
request.interceptors.response.use((response) => response.data, errorHandler)
/**
* GET 请求
*
* @param {String} url
* @param {Object} data
* @param {Object} options
* @returns {Promise<any>}
*/
export const get = (url, data = {}, options = {}) => {
return request({
url,
params: data,
method: 'get',
...options
})
}
/**
* POST 请求
*
* @param {String} url
* @param {Object} data
* @param {Object} options
* @returns {Promise<any>}
*/
export const post = (url, data = {}, options = {}) => {
return request({
url,
method: 'post',
data: data,
...options
})
}
/**
* 上传文件 POST 请求
*
* @param {String} url
* @param {Object} data
* @param {Object} options
* @returns {Promise<any>}
*/
export const upload = (url, data = {}, options = {}) => {
return request({
url,
method: 'post',
data: data,
...options
})
}

View File

@ -0,0 +1,73 @@
interface IStorage {
setItem(key: string, value: any): void
getItem(key: string): any
removeItem(key: string): void
clear(): void
}
class Storage {
// 缓存前缀
prefix = ''
// 缓存驱动
storage: IStorage = localStorage
constructor(prefix = '', storage: IStorage) {
this.prefix = prefix
this.storage = storage
}
cacheKey(key: string) {
return `${this.prefix}_${key}`.toUpperCase()
}
get(key: string, def: any = '') {
const item = this.storage.getItem(this.cacheKey(key))
if (!item) return def
try {
const { value, expire } = JSON.parse(item)
// 在有效期内直接返回
if (expire === null || expire >= Date.now()) {
return value
}
this.remove(key)
} catch (e) {
console.warn(e)
}
return def
}
/**
*
*
* @param {String} key // 缓存KEY
* @param {Any} value // 缓存值
* @param {Number|null} expire // 缓存时间单位秒
*/
set(key: string, value: any, expire: number | null = 60 * 60 * 24) {
this.storage.setItem(
this.cacheKey(key),
JSON.stringify({
value,
expire: expire !== null ? new Date().getTime() + expire * 1000 : null
})
)
}
remove(key: string) {
this.storage.removeItem(this.cacheKey(key))
}
clear() {
this.storage.clear()
}
}
export default Storage
export const storage = new Storage('im', localStorage)

View File

@ -23,7 +23,7 @@
"./src/*.ts",
"./src/*.d.ts",
"./src/**/*.ts"
, "src/connect.js", "src/store/index.js", "src/store/modules/settings.js", "src/store/modules/user.js", "src/store/modules/uploads.js", "src/store/modules/talk.js", "src/event/talk.js", "src/plugins/ws-socket.js" ],
, "src/connect.js", "src/store/index.js", "src/store/modules/settings.js", "src/store/modules/user.js", "src/store/modules/uploads.ts", "src/store/modules/talk.js", "src/event/talk.js", "src/plugins/ws-socket.js", "src/hooks/index.js", "src/hooks/useSessionMenu.js", "src/hooks/useProvideUserModal.js", "src/hooks/useInject.js", "src/hooks/useTalkRecord.js", "src/plugins/index.js", "src/plugins/pinia.js", "src/plugins/component.js" ],
"vueCompilerOptions": {
"experimentalRuntimeMode": "runtime-uni-app",
"nativeTags": ["block", "component", "template", "slot"]