index.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662
  1. <template>
  2. <div class="default-width message" :style="`height: calc(100vh - ${isEnterprise ? '130px' : '50px'});`">
  3. <div class="message-left d-flex flex-column">
  4. <div class="message-left-search d-flex align-center px-3 justify-space-between" >
  5. <div>
  6. <v-icon class="mr-3">mdi-history</v-icon>
  7. 最近联系人
  8. </div>
  9. <div>
  10. <v-btn
  11. density="compact"
  12. :color="showDelete ? '' : 'red'"
  13. :icon="showDelete ? 'mdi-close' : 'mdi-trash-can-outline'"
  14. variant="text"
  15. @click="showDelete = !showDelete"
  16. >
  17. </v-btn>
  18. </div>
  19. <!-- {{ connected ? '连接成功': '连接失败' }} -->
  20. </div>
  21. <div class="message-chat-box mt-5">
  22. <v-overlay
  23. :model-value="!IM.connected"
  24. contained
  25. class="align-center justify-center"
  26. >
  27. <v-progress-circular
  28. color="primary"
  29. size="64"
  30. indeterminate
  31. ></v-progress-circular>
  32. </v-overlay>
  33. <div v-if="conversationList.length">
  34. <v-list density="compact" mandatory @update:selected="handleChange">
  35. <v-list-item
  36. v-for="(val, i) in conversationList"
  37. :key="i"
  38. :value="val"
  39. color="primary"
  40. class="mb-2"
  41. :active="val.channel.channelID === info?.channel?.channelID"
  42. :title="val.userInfoVo ? (val.userInfoVo.userInfoResp?.name ? val.userInfoVo.userInfoResp.name : '游客') : '系统消息'"
  43. :subtitle="timesTampChange(+val.timestamp.padEnd(13, '0'))"
  44. >
  45. <template v-slot:title="{ title }">
  46. <div v-if="!isEnterprise" class="mt-2 d-flex align-center">
  47. {{ title }}
  48. <div class="ml-3 color-666 font-size-14 enterprise-name ellipsis" :style="{'color': val.channel.channelID === info?.channel?.channelID ? '#00897B' : '#666'}">
  49. {{ val.userInfoVo?.userInfoResp?.enterpriseAnotherName }}
  50. <span class="line" v-if="val.userInfoVo?.userInfoResp?.postNameCn && val.userInfoVo?.userInfoResp?.enterpriseAnotherName"></span>
  51. {{ val.userInfoVo?.userInfoResp?.postNameCn }}
  52. </div>
  53. </div>
  54. <div v-else class="mt-2">{{ title }}</div>
  55. </template>
  56. <template v-slot:subtitle="{ subtitle }">
  57. <div class="mt-2">{{ subtitle }}</div>
  58. </template>
  59. <template v-slot:prepend>
  60. <v-avatar :image="getUserAvatar(val?.userInfoVo?.userInfoResp?.avatar, val?.userInfoVo?.userInfoResp?.sex)"></v-avatar>
  61. </template>
  62. <template v-slot:append>
  63. <v-badge
  64. v-if="val.unread > 0"
  65. color="error"
  66. :content="val.unread"
  67. inline
  68. ></v-badge>
  69. <v-btn v-show="showDelete" density="compact" icon="mdi-trash-can-outline" variant="text" color="red" @click.stop="handleDelete(val)"></v-btn>
  70. </template>
  71. </v-list-item>
  72. </v-list>
  73. <div class="message-no-more-text mt-3">没有更多了</div>
  74. </div>
  75. <div v-else class="left-noData">
  76. <Empty :elevation="false" message="暂无30天内联系人" width="300" height="150"></Empty>
  77. </div>
  78. </div>
  79. </div>
  80. <div class="message-right">
  81. <div v-if="showRightNoData" class="right-noData">
  82. <Empty :elevation="false" message="与您进行过沟通的 Boss 都会在左侧列表中显示"></Empty>
  83. </div>
  84. <Chatting
  85. ref="chatRef"
  86. :items="messageItems"
  87. :info="info"
  88. :interview="interview"
  89. :has-more="hasMore"
  90. :updateConversation="updateConversation"
  91. :updateUnreadCount="updateUnreadCount"
  92. :resetUnread="resetUnread"
  93. @handleSend="handleUpdate"
  94. @handleMore="handleGetMore"
  95. @handleAgree="handleAgree"
  96. @handleRefuse="handleRefuse"
  97. @handlePreview="handlePreview"
  98. @handleSendResume="handleSendResume"
  99. >
  100. <template #tools>
  101. <v-btn
  102. v-for="tool in tools"
  103. :key="tool.name"
  104. size="small"
  105. class="mr-3"
  106. :color="tool.color"
  107. @click="tool.handle(tool)"
  108. >
  109. <v-progress-circular
  110. v-if="tool.loading"
  111. :width="2"
  112. :size="16"
  113. color="white"
  114. class="mr-2"
  115. indeterminate
  116. ></v-progress-circular>
  117. <v-icon v-else class="mr-2">{{ tool.icon }}</v-icon>
  118. {{ tool.name }}
  119. </v-btn>
  120. </template>
  121. </Chatting>
  122. </div>
  123. </div>
  124. <!-- 附件上传 -->
  125. <CtDialog
  126. :visible="showUploadDialog"
  127. :widthType="2"
  128. :footer="true"
  129. title="附件简历上传"
  130. titleClass="text-h6"
  131. @close="showUploadDialog = false"
  132. @submit="handleSubmitAttachment"
  133. >
  134. <CtForm ref="CtFormRef" :items="formItems">
  135. <template #uploadFile="{ item }">
  136. <TextInput v-model="item.value" :item="item" @click="openFileInput"></TextInput>
  137. <File ref="uploadFile" @success="handleUploadResume"></File>
  138. </template>
  139. </CtForm>
  140. <div class="color-666" style="font-size: 13px;">* 仅支持.doc, .docx, .pdf文件</div>
  141. </CtDialog>
  142. <CtDialog :visible="showInvite" :widthType="2" titleClass="text-h6" title="邀请面试" @close="showInvite = false" @submit="handleSubmit">
  143. <InvitePage v-if="showInvite" ref="inviteRef" :item-data="itemData" :position="positionList"></InvitePage>
  144. </CtDialog>
  145. <CtDialog :visible="showResume" :widthType="2" titleClass="text-h6" title="发送简历" @close="showResume = false; selectResume = null " @submit="handleSubmitResume">
  146. <div style="position: relative; min-height: 200px">
  147. <v-radio-group v-model="selectResume">
  148. <v-radio v-for="val in resumeList" :key="val.id" :value="val.id" :label="val.title" color="primary"></v-radio>
  149. </v-radio-group>
  150. </div>
  151. </CtDialog>
  152. </template>
  153. <script setup>
  154. defineOptions({ name: 'personal-message-index'})
  155. import InvitePage from '@/views/recruit/enterprise/interviewManagement/components/invite'
  156. import { timesTampChange } from '@/utils/date'
  157. import { ref, inject, watch,onMounted, nextTick } from 'vue'
  158. import Chatting from './components/chatting.vue'
  159. import { initConnect, send, initChart, getMoreMessages, checkConversation } from '@/hooks/web/useIM'
  160. import { useRoute } from 'vue-router'
  161. import { getPositionDetails } from '@/api/position'
  162. import { getInterviewInviteListByInviteUserId } from '@/api/common'
  163. import { getUserInfo } from '@/api/personal/user'
  164. import { useIMStore } from '@/store/im'
  165. import { useUserStore } from '@/store/user'
  166. import Snackbar from '@/plugins/snackbar'
  167. import { getUserAvatar } from '@/utils/avatar'
  168. import { getJobAdvertised } from '@/api/enterprise'
  169. import { dealDictArrayData } from '@/utils/position'
  170. import { saveInterviewInvite } from '@/api/recruit/enterprise/interview'
  171. import { savePersonResumeCv } from '@/api/recruit/personal/resume'
  172. import { useI18n } from '@/hooks/web/useI18n'
  173. import { userInterviewInviteReject, userInterviewInviteConsent } from '@/api/recruit/personal/personalCenter'
  174. import { getPersonResumeCv } from '@/api/recruit/personal/resume'
  175. import { previewFile } from '@/utils'
  176. import Confirm from '@/plugins/confirm'
  177. const { t } = useI18n()
  178. const chatRef = ref()
  179. const IM = useIMStore()
  180. // 自己的信息
  181. const { entBaseInfo } = useUserStore()
  182. const isEnterprise = inject('isEnterprise')
  183. // 实例
  184. const route = useRoute()
  185. const channelItem = ref(null)
  186. const messageItems = ref([])
  187. const pageSize = ref(1)
  188. const hasMore = ref(false)
  189. const showInvite = ref(false)
  190. const positionList = ref([])
  191. const showDelete = ref(false)
  192. const itemData = ref({})
  193. const inviteRef = ref()
  194. // 发送简历
  195. const showResume = ref(false)
  196. const resumeList = ref([])
  197. const selectResume = ref(null)
  198. // 上传附件简历
  199. const CtFormRef = ref()
  200. const formItems = ref({
  201. options: [
  202. {
  203. type: 'text',
  204. key: 'title',
  205. value: '',
  206. label: '附件简历名称 *',
  207. rules: [v => !!v || '请输入附件简历名称']
  208. },
  209. {
  210. slotName: 'uploadFile',
  211. key: 'url',
  212. value: '',
  213. truthValue: '',
  214. label: '点击上传附件简历 *',
  215. outline: true,
  216. rules: [v => !!v || '请上传您的附件简历']
  217. }
  218. ]
  219. })
  220. // 求职者面试列表
  221. const interview = ref([])
  222. const showRightNoData = ref(false)
  223. const info = ref({})
  224. const enterpriseTools = ref([
  225. { name: '求简历', icon: 'mdi-email', color:"primary", loading: false, handle: handleRequest },
  226. { name: '面试邀约', icon: 'mdi-email', color:"primary", loading: false, handle: handleInvite }
  227. ])
  228. const userTools = ref([
  229. { name: '发送简历', icon: 'mdi-email', color:"primary", loading: false, handle: handleSendResume }
  230. ])
  231. const tools = isEnterprise ? enterpriseTools.value : userTools.value
  232. if (!IM) {
  233. console.log('IM is disconnected')
  234. }
  235. if (route.query.id) {
  236. const api = route.query.enterprise ? getPositionDetails : getUserInfo
  237. const res = await api({ id: route.query.id })
  238. const query = route.query.enterprise ? [res.contact?.userId, res.contact?.enterpriseId] : [res?.id]
  239. onMounted(() => {
  240. nextTick(async () => {
  241. const { channel } = await checkConversation(...query)
  242. const items = [
  243. {
  244. channel,
  245. userInfoVo: {
  246. userInfoResp: route.query.enterprise ? res.contact : { ...res, userId: res.id}
  247. }
  248. }
  249. ]
  250. handleChange(items)
  251. })
  252. })
  253. }
  254. const {
  255. conversationList,
  256. updateConversation,
  257. updateUnreadCount,
  258. deleteConversations,
  259. resetUnread
  260. } = initConnect(async (successful) => {
  261. if (!successful) {
  262. Snackbar.error('发送失败')
  263. return
  264. }
  265. chatRef.value.reset()
  266. // 发送成功
  267. const { list } = await getMoreMessages(1, channelItem.value)
  268. // updateConversation()
  269. messageItems.value = list.value
  270. chatRef.value.scrollBottom()
  271. })
  272. const getInterviewInviteList = async () => {
  273. if (!info.value.userId) return
  274. const data = await getInterviewInviteListByInviteUserId(info.value.userId)
  275. interview.value = data.slice(0, 1)
  276. }
  277. // 在当前频道中有新消息时更新未读消息数量
  278. const updateUnreadMessageCount = (val) => {
  279. const obj = val.find(e => e.userInfoVo.userId === info.value.userId)
  280. if (!obj?.unread || obj.unread === 0) return
  281. delete info.value.unread
  282. Object.assign(info.value, { unread: obj.unread, enterpriseId: entBaseInfo?.enterpriseId })
  283. }
  284. watch(
  285. () => conversationList.value,
  286. async (val) => {
  287. // 数据发生变化
  288. if (channelItem.value && IM.fromChannel === channelItem.value.channelID) {
  289. // 更新
  290. const { list } = await getMoreMessages(1, channelItem.value)
  291. messageItems.value = list.value
  292. if (Object.keys(info.value).length) updateUnreadMessageCount(val)
  293. chatRef.value.scrollBottom()
  294. }
  295. },
  296. {
  297. deep: true,
  298. immediate: true
  299. }
  300. )
  301. async function handleChange (items) {
  302. // console.log([...items])
  303. try {
  304. chatRef.value.changeOverlay(true)
  305. const { userInfoVo, channel: myChannel, unread } = items.pop()
  306. info.value = userInfoVo?.userInfoResp ?? { name: '系统消息' }
  307. Object.assign(info.value, {
  308. channel: myChannel,
  309. unread
  310. })
  311. // 个人端获取面试信息
  312. if (!isEnterprise) getInterviewInviteList()
  313. const userId = userInfoVo.userInfoResp.userId
  314. const enterpriseId = userInfoVo.userInfoResp.enterpriseId || undefined
  315. const { channel, list, more } = await initChart(userId, enterpriseId)
  316. // console.log('--------',list)
  317. channelItem.value = channel.value
  318. messageItems.value = list.value
  319. hasMore.value = more
  320. chatRef.value.scrollBottom()
  321. // 点开窗口消除未读数量
  322. await resetUnread(channel.value, entBaseInfo?.enterpriseId)
  323. await updateConversation()
  324. updateUnreadCount()
  325. } catch (error) {
  326. messageItems.value = []
  327. } finally {
  328. chatRef.value.changeOverlay(false)
  329. }
  330. }
  331. // 普通消息
  332. const handleUpdate = (val) => {
  333. send(val.value, channelItem.value)
  334. }
  335. // 选择文件
  336. const uploadFile = ref()
  337. const openFileInput = () => {
  338. uploadFile.value.trigger()
  339. }
  340. // 上传附件
  341. const handleUploadResume = async (url, title, filename) => {
  342. const obj = formItems.value.options.find(e => e.key === 'url')
  343. obj.value = filename
  344. obj.truthValue = url
  345. }
  346. // 获取简历
  347. const showUploadDialog = ref(false)
  348. async function handleSendResume (item) {
  349. try {
  350. item.loading = true
  351. // 获取简历列表
  352. const result = await getPersonResumeCv()
  353. if (result.length === 0) {
  354. Snackbar.warning(t('resume.resumeYetSubmit'))
  355. showUploadDialog.value = true
  356. return
  357. }
  358. resumeList.value = result
  359. showResume.value = true
  360. } finally {
  361. item.loading = false
  362. }
  363. }
  364. /**
  365. * 发送简历
  366. * text param
  367. * {
  368. * remark: 备注
  369. * query: {} 自定义参数 access -1 未确定 0 拒绝 1 同意
  370. * type: 1 => 发送简历
  371. * 2 => 索要简历
  372. * 3 => 信息描述
  373. * }
  374. */
  375. // 没有上传过简历的弹窗上传并发送给对方
  376. const handleSubmitAttachment = async () => {
  377. const { valid } = await CtFormRef.value.formRef.validate()
  378. if (!valid) return
  379. const obj = {}
  380. formItems.value.options.forEach(e => {
  381. obj[e.key] = e.truthValue || e.value
  382. })
  383. if (!obj.title || !obj.url) return
  384. await savePersonResumeCv(obj)
  385. showUploadDialog.value = false
  386. const text = {
  387. remark: '发送简历',
  388. query: {
  389. src: obj.url,
  390. title: obj.title
  391. },
  392. type: 1
  393. }
  394. send (JSON.stringify(text), channelItem.value, 105)
  395. }
  396. function handleSubmitResume () {
  397. if (!selectResume.value) {
  398. Snackbar.error(t('resume.selectResumeToSubmit'))
  399. return
  400. }
  401. const _info = resumeList.value.find((item) => item.id === selectResume.value)
  402. const text = {
  403. remark: '发送简历',
  404. query: {
  405. src: _info.url,
  406. title: _info.title,
  407. id: _info.id,
  408. },
  409. type: 1
  410. }
  411. send (JSON.stringify(text), channelItem.value, 105)
  412. showResume.value = false
  413. }
  414. // 求简历
  415. function handleRequest () {
  416. const text = {
  417. remark: '求简历',
  418. query: {
  419. src: '',
  420. title: '',
  421. id: '',
  422. },
  423. type: 2
  424. }
  425. send (JSON.stringify(text), channelItem.value, 105)
  426. }
  427. // 简历预览
  428. const handlePreview = (val) => {
  429. previewFile(val.content.query.src)
  430. }
  431. const handleGetMore = async () => {
  432. try {
  433. chatRef.value.changeLoading(true)
  434. pageSize.value++
  435. const { list, more } = await getMoreMessages(pageSize.value, channelItem.value)
  436. messageItems.value.unshift(...list.value)
  437. hasMore.value = more
  438. // chatRef.value.scrollBottom()
  439. } finally {
  440. chatRef.value.changeLoading(false)
  441. }
  442. }
  443. const handleDelete = async ({ channel }) => {
  444. await deleteConversations(channel, entBaseInfo?.enterpriseId)
  445. await updateConversation()
  446. updateUnreadCount()
  447. }
  448. // 没有企业ID则enterpriseId为undefined
  449. // 发送消息体 { text, type: 2 }
  450. // 面试邀约
  451. async function handleInvite (item) {
  452. item.loading = true
  453. positionList.value = []
  454. try {
  455. const data = await getJobAdvertised({ hire: false })
  456. if (!data.length) return
  457. const list = dealDictArrayData([], data)
  458. positionList.value = list.map(e => {
  459. const salary = e.payFrom && e.payTo ? `${e.payFrom ? e.payFrom + '-' : ''}${e.payTo}${e.payName ? '/' + e.payName : ''}` : '面议'
  460. return {
  461. label: `${e.name}${e.areaName ? '_' + e.areaName : ''} ${salary}`,
  462. value: e.id,
  463. data: e
  464. }
  465. })
  466. // itemData.value = {
  467. // userId: '',
  468. // jobId: ''
  469. // }
  470. showInvite.value = true
  471. // send(JSON.stringify(msg), channelItem.value, 101)
  472. // console.log(query)
  473. } catch (error) {
  474. console.log(error)
  475. } finally {
  476. item.loading = false
  477. }
  478. }
  479. const handleSubmit = async () => {
  480. const { valid } = await inviteRef.value.CtFormRef.formRef.validate()
  481. if (!valid) {
  482. return
  483. }
  484. const query = inviteRef.value.getQuery()
  485. if (!query.time) {
  486. Snackbar.error('时间不能为空')
  487. return
  488. }
  489. query.userId = info.value.userId
  490. query.positionInfo = positionList.value.find(e => e.value === query.jobId)
  491. // 需要id
  492. const data = await saveInterviewInvite(query)
  493. // 保留邀请id
  494. query.id = data.id
  495. Snackbar.success(t('common.operationSuccessful'))
  496. send(JSON.stringify(query), channelItem.value, 101)
  497. showInvite.value = false
  498. }
  499. const handleAgree = (val) => {
  500. if (!val.id) return
  501. const query = {
  502. id: val.id
  503. }
  504. const type = route?.meta?.loginType === 'enterprise' ? 'entBaseInfo' : 'baseInfo'
  505. const baseInfo = localStorage.getItem(type)
  506. if (baseInfo) {
  507. const { phone } = JSON.parse(baseInfo)
  508. query.phone = phone
  509. }
  510. Confirm(t('common.confirmTitle'), '是否确定接收此面试邀请?').then(async () => {
  511. await userInterviewInviteConsent(query)
  512. Snackbar.success(t('common.operationSuccessful'))
  513. getInterviewInviteList()
  514. send(JSON.stringify({ id: val.id }), channelItem.value, 104)
  515. })
  516. }
  517. // 拒绝面试邀请
  518. const handleRefuse = (val) => {
  519. if (!val.id) return
  520. Confirm(t('common.confirmTitle'), '您是否确定要拒绝此面试邀请?').then(async () => {
  521. await userInterviewInviteReject(val.id)
  522. Snackbar.success(t('common.operationSuccessful'))
  523. getInterviewInviteList()
  524. send(JSON.stringify({ id: val.id }), channelItem.value, 103)
  525. })
  526. }
  527. </script>
  528. <style scoped lang="scss">
  529. .message {
  530. display: flex;
  531. &-left {
  532. position: relative;
  533. flex-shrink: 0;
  534. height: 100%;;
  535. width: 360px;
  536. background-color: #fff;
  537. border-radius: 8px;
  538. margin-right: 12px;
  539. .message-left-search {
  540. width: 100%;
  541. height: 60px;
  542. background: linear-gradient(90deg, #f5fcfc, #fcfbfa);
  543. border-radius: 8px 8px 0 0;
  544. }
  545. .message-chat-box {
  546. height: 0;
  547. flex: 1;
  548. overflow: auto;
  549. padding-bottom: 20px;
  550. .chat-item {
  551. position: relative;
  552. width: 100%;
  553. height: 78px;
  554. padding: 14px 12px;
  555. cursor: pointer;
  556. &:hover {
  557. background-color: #f8f8f8;
  558. }
  559. .chat-item-time {
  560. position: absolute;
  561. right: 12px;
  562. top: 50%;
  563. transform: translateY(-50%);
  564. }
  565. .title-box {
  566. max-width: 114px;
  567. overflow: hidden;
  568. white-space: nowrap;
  569. text-overflow: ellipsis;
  570. display: inline-block;
  571. }
  572. }
  573. }
  574. .message-no-more-text {
  575. color: var(--color-999);
  576. font-size: 14px;
  577. text-align: center
  578. }
  579. .left-noData {
  580. position: absolute;
  581. top: 50%;
  582. left: 50%;
  583. transform: translate(-50%, -50%);
  584. }
  585. }
  586. &-right {
  587. height: 100%;
  588. flex: 1;
  589. width: 0;
  590. position: relative;
  591. background-color: #fff;
  592. border-radius: 8px;
  593. .right-noData {
  594. position: absolute;
  595. top: 50%;
  596. left: 50%;
  597. transform: translate(-50%, -50%);
  598. }
  599. }
  600. }
  601. .enterprise-name {
  602. max-width: 150px;
  603. .line {
  604. display: inline-block;
  605. width: 1px;
  606. height: 12px;
  607. vertical-align: middle;
  608. background-color: #e0e0e0;
  609. margin: 0 3px;
  610. }
  611. }
  612. </style>