feat(ContactModal): 新增通讯录功能并重构转发联系人界面

- 添加@vicons/tabler图标库依赖
- 启用vueDevTools插件
- 重构ContactModal组件,增加通讯录功能
- 实现部门树形结构展示和员工搜索
- 添加群聊列表展示功能
- 优化界面样式和交互体验
This commit is contained in:
xingyy 2025-08-27 15:48:23 +08:00
parent b484bc0823
commit 1946ef8e9c
5 changed files with 522 additions and 77 deletions

View File

@ -68,6 +68,7 @@
"@types/node": "^18.18.5",
"@types/vue": "^2.0.0",
"@unocss/reset": "^66.1.1",
"@vicons/tabler": "^0.13.0",
"@vitejs/plugin-vue": "^4.4.0",
"@vitejs/plugin-vue-jsx": "^3.0.2",
"@vue/tsconfig": "^0.4.0",

View File

@ -159,6 +159,9 @@ importers:
'@unocss/reset':
specifier: ^66.1.1
version: 66.3.0
'@vicons/tabler':
specifier: ^0.13.0
version: 0.13.0
'@vitejs/plugin-vue':
specifier: ^4.4.0
version: 4.6.2(vite@6.3.5(@types/node@18.19.112)(jiti@1.21.7)(less@4.3.0)(sass@1.89.2)(terser@5.43.1))(vue@3.5.17(typescript@5.2.2))
@ -1250,6 +1253,9 @@ packages:
'@vicons/ionicons5@0.13.0':
resolution: {integrity: sha512-zvZKBPjEXKN7AXNo2Na2uy+nvuv6SP4KAMQxpKL2vfHMj0fSvuw7JZcOPCjQC3e7ayssKnaoFVAhbYcW6v41qQ==}
'@vicons/tabler@0.13.0':
resolution: {integrity: sha512-AykuhiqjszkIoAL/7knIFm6RDOBS1ZmQdJfQ+RNLEah0fVsxykUFCfMBSNZh8lOzC85EtdD1k5g/sv5GYk0Ohg==}
'@vitejs/plugin-vue-jsx@3.1.0':
resolution: {integrity: sha512-w9M6F3LSEU5kszVb9An2/MmXNxocAnUb3WhRr8bHlimhDrXNt6n6D2nJQR3UXpGlZHh/EsgouOHCsM8V3Ln+WA==}
engines: {node: ^14.18.0 || >=16.0.0}
@ -5009,6 +5015,8 @@ snapshots:
'@vicons/ionicons5@0.13.0': {}
'@vicons/tabler@0.13.0': {}
'@vitejs/plugin-vue-jsx@3.1.0(vite@6.3.5(@types/node@18.19.112)(jiti@1.21.7)(less@4.3.0)(sass@1.89.2)(terser@5.43.1))(vue@3.5.17(typescript@5.2.2))':
dependencies:
'@babel/core': 7.27.4

View File

@ -1,46 +1,164 @@
<script lang="ts" setup>
import { ref, computed, onMounted, watch } from 'vue'
<script lang="js" setup>
import { ref, computed, onMounted, watch, reactive, nextTick, getCurrentInstance, h } from 'vue'
import { ServeGetTalkList } from '@/api/chat.js'
import { ServeGetGroups } from '@/api/group'
import XNModal from '@/components/x-naive-ui/x-n-modal/index.vue'
const emit = defineEmits(['close', 'on-submit'])
import { Plus } from '@vicons/tabler'
import { CloseCircle } from '@vicons/ionicons5'
interface Item {
id: number
type: number
name: string
avatar: string
remark: string
checked: boolean
keyword: string
}
import customModal from '@/components/common/customModal.vue'
import xSearchForm from '@/components/x-naive-ui/x-search-form/index.vue'
import xNDataTable from '@/components/x-naive-ui/x-n-data-table/index.vue'
import flTree from '@/components/flnlayout/tree/flnindex.vue'
import { processError, processSuccess } from '@/utils/helper/message.js'
import { ServeUserGroupChatList } from '@/api/search'
import { GetContactFriendList } from '@/api/chat'
import { getUserInfoByERPUserId } from '@/api/user'
import { useRouter } from 'vue-router'
import { useUtil } from '@/hooks/useUtil'
import { NButton } from 'naive-ui'
const emit = defineEmits(['close', 'on-submit'])
const { useMessage } = useUtil()
const router = useRouter()
const currentInstance = getCurrentInstance()
const $request = currentInstance?.appContext.config.globalProperties?.$request
const isShowBox = defineModel('show')
const loading = ref(true)
const items = ref<Item[]>([])
const items = ref([])
const keywords = ref('')
const loadGroupStatus = ref(false)
defineProps<{
forwardMode: number
}>()
//
const forwardMode = defineProps(['forwardMode'])
//
const state = reactive({
isShowAddressBookModal: false,
customModalStyle: {
width: '1288px',
height: '846px',
backgroundColor: '#F9F9FD'
},
addressBookSearchConfig: [
{
label: '姓名',
key: 'nickName',
type: 'input',
valueType: 'string'
}
],
groupChatListSearchConfig: [
{
label: '群聊名称',
key: 'groupName',
type: 'input',
valueType: 'string'
}
],
treeData: [],
expandedKeys: [],
clickKey: 3,
treeRefreshCount: 0,
treeSelectData: {},
addressBookColumns: [
{
type: 'selection'
},
{
title: '姓名 【工号】',
field: 'nickname',
width: 200,
ellipsis: {
tooltip: true
},
render(row, index) {
return row.nickName + '【' + row.jobNum + '】'
}
},
{
title: '岗位名称',
field: 'user_position',
width: 400,
ellipsis: {
tooltip: true
},
render(row, index) {
let positionNames = Array.isArray(row.depPositions)
? row.depPositions.flatMap((dep) =>
Array.isArray(dep.positions) ? dep.positions.map((pos) => pos.name) : []
)
: []
return positionNames.join(' , ')
}
}
],
groupChatListColumns: [
{
title: '群聊名称',
field: 'groupName',
width: 400,
ellipsis: {
tooltip: true
},
render(row, index) {
return row.group_name
}
},
{
title: '群类型',
field: 'groupType',
width: 200,
ellipsis: true,
render(row, index) {
let groupType = row.group_type
if (groupType == 1) {
return '普通群'
} else if (groupType == 2) {
return '部门群'
} else if (groupType == 3) {
return '项目群'
} else if (groupType == 4) {
return '公司群'
}
}
}
],
addressBookData: [],
company_name: '',
groupChatListData: [],
addressBookTableHeight: 524,
addressBookTableWidth: 800,
addressBookPage: 1,
addressBookPageSize: 10,
addressBookTotal: 0,
addressBookSearchNickName: '',
addressBookCurrentTab: 'employeeAddressBook',
groupChatListPage: 1,
groupChatListPageSize: 10,
groupChatListTotal: 0,
groupChatListSearchGroupName: '',
selectedRowKeys: []
})
//
const searchFilter = computed(() => {
return items.value.filter((item: Item) => {
return items.value.filter((item) => {
return item.name.toLowerCase().includes(keywords.value.toLowerCase())
})
})
const checkedFilter = computed(() => {
return items.value.filter((item: Item) => item.checked)
return items.value.filter((item) => item.checked)
})
const isCanSubmit = computed(() => {
return !checkedFilter.value.length
})
//
const onLoad = () => {
onLoadContact()
// onLoadGroup()
}
const onLoadContact = () => {
@ -50,7 +168,7 @@ const onLoadContact = () => {
if (res.code == 200) {
let list = res.data.items || []
items.value = list.filter((item: any) => ((item.talk_type === 1 && item.receiver_id !== 2) || item.talk_type !== 1)).map((item: any) => {
items.value = list.filter((item) => ((item.talk_type === 1 && item.receiver_id !== 2) || item.talk_type !== 1)).map((item) => {
return {
...item,
checked: false
@ -63,57 +181,29 @@ const onLoadContact = () => {
})
}
// const onLoadGroup = async () => {
// if (loadGroupStatus.value) {
// return
// }
// loading.value = true
// let { code, data } = await ServeGetGroups()
// if (code != 200) {
// loading.value = false
// return
// }
// let list = data.items.map((item: any) => {
// return {
// id: item.id,
// avatar: item.avatar,
// type: 2,
// name: item.group_name,
// keyword: item.group_name,
// remark: '',
// checked: false
// }
// })
// items.value.push(...list)
// loading.value = false
// loadGroupStatus.value = true
// }
//
const onMaskClick = () => {
emit('close')
}
const onTriggerContact = (item: any) => {
//
const selectType = ref(2)
const onTriggerContact = (item) => {
if (selectType.value === 1) {
items.value.forEach(contact => {
contact.checked = false
})
}
let data = items.value.find((val: any) => val.id === item.id)
let data = items.value.find((val) => val.id === item.id)
if (data) {
data.checked = !data.checked
}
}
const onRemoveContact = (item: any) => {
let data = items.value.find((val: any) => val.id === item.id)
const onRemoveContact = (item) => {
let data = items.value.find((val) => val.id === item.id)
if (data) {
data.checked = false
@ -125,7 +215,7 @@ const onCancel = () => {
}
const onSubmit = () => {
let data = checkedFilter.value.map((item: any) => {
let data = checkedFilter.value.map((item) => {
return {
receiver_id: item.receiver_id,
talk_type: item.talk_type
@ -134,30 +224,208 @@ const onSubmit = () => {
emit('on-submit', data)
}
// 1 2
const selectType = ref(1)
const changeSelectType = () => {
selectType.value = selectType.value == 1 ? 2 : 1
//
items.value.forEach(item => {
item.checked = false
//
const onCreateContact = () => {
state.isShowAddressBookModal = true
getTreeData()
getDepPoisUser()
getUserGroupChatList()
}
const closeAddressBookModal = () => {
state.isShowAddressBookModal = false
resetAddressBookModal()
}
const calcTreeData = (data) => {
for (let item of data) {
item.key = item.ID
item.label = item.name
item.title = item.name
if (item.sons) {
item.children = item.sons
calcTreeData(item.children)
}
delete item.ID
delete item.name
delete item.sons
}
}
const getTreeData = () => {
let url = '/department/v2/tree/filter'
let params = {}
$request.HTTP.components.postDataByParams(url, params).then(
(res) => {
if (res.status === 0 && Array.isArray(res.data.nodes)) {
let data = res.data.nodes
calcTreeData(data)
state.treeData = data
state.treeRefreshCount++
getDepPoisUser()
} else {
processError(res.msg || '获取失败!')
}
},
() => {
processError('获取失败!')
},
() => {
processError('获取失败!')
}
)
}
const getDepPoisUser = () => {
let url = '/user/v2/list'
let params = {
departmentId: state.addressBookSearchNickName ? undefined : state.clickKey,
page: state.addressBookPage,
pageSize: state.addressBookPageSize,
status: 'notactive',
nickName: state.addressBookSearchNickName
}
$request.HTTP.components.postDataByParams(url, params).then((res) => {
if (res.code === 200) {
state.addressBookData = res?.data?.data || []
state.addressBookTotal = res?.data?.count || 0
}
})
}
watch(()=>{
return isShowBox.value
},(newVal)=>{
if(newVal){
onLoad()
const getUserGroupChatList = () => {
let params = {
page: state.groupChatListPage,
page_size: state.groupChatListPageSize,
group_name: state.groupChatListSearchGroupName
}
ServeUserGroupChatList(params).then((res) => {
if (res.code === 200) {
state.groupChatListData = res?.data?.items || []
state.groupChatListTotal = res?.data?.total || 0
}
})
}
const handleTreeClick = ({ selectedKey, tree }) => {
state.clickKey = tree.key
state.treeSelectData = tree
state.addressBookPage = 1
getDepPoisUser()
}
const resetAddressBookModal = () => {
nextTick(() => {
state.addressBookCurrentTab = 'employeeAddressBook'
state.addressBookSearchNickName = ''
state.groupChatListSearchGroupName = ''
state.addressBookTableWidth = 800
state.clickKey = 3
state.treeRefreshCount++
state.addressBookPage = 1
state.addressBookPageSize = 10
state.groupChatListPage = 1
state.groupChatListPageSize = 10
state.selectedRowKeys = []
getDepPoisUser()
getUserGroupChatList()
})
}
const handleAddressBookPagination = (page) => {
state.addressBookPage = page
getDepPoisUser()
}
const handleAddressBookPaginationSize = (pageSize) => {
state.addressBookPageSize = pageSize
state.addressBookPage = 1
getDepPoisUser()
}
const changeAddressBookSearch = (value) => {
if (!value.nickName?.trim()) {
state.addressBookSearchNickName = ''
} else {
state.addressBookSearchNickName = value.nickName
}
}
const handleAddressBookTabChange = (value) => {
state.addressBookCurrentTab = value
}
const changeGroupChatListSearch = (value) => {
if (!value.groupName?.trim()) {
state.groupChatListSearchGroupName = ''
} else {
state.groupChatListSearchGroupName = value.groupName
}
}
const handleGroupChatListPagination = (page) => {
state.groupChatListPage = page
getUserGroupChatList()
}
const handleGroupChatListPaginationSize = (pageSize) => {
state.groupChatListPageSize = pageSize
state.groupChatListPage = 1
getUserGroupChatList()
}
const onAddressBookCancel = () => {
state.isShowAddressBookModal = false
}
const onAddressBookSubmit = () => {
//
console.log('选中的联系人:', state.selectedRowKeys)
}
watch(() => {
return isShowBox.value
}, (newVal) => {
if (newVal) {
onLoad()
}
})
watch(() => state.addressBookSearchNickName, (newValue, oldValue) => {
if (newValue) {
state.addressBookTableWidth = 1142
state.addressBookPage = 1
} else {
state.addressBookTableWidth = 800
state.clickKey = 3
state.treeRefreshCount++
state.addressBookPage = 1
}
getDepPoisUser()
})
watch(() => state.groupChatListSearchGroupName, (newValue, oldValue) => {
if (newValue) {
state.groupChatListPage = 1
} else {
state.groupChatListPage = 1
}
getUserGroupChatList()
})
</script>
<template>
<x-n-modal v-model:show="isShowBox" :title="forwardMode === 2 ? '合并转发' : '逐条转发'" style="width: 997px; height: 740px;background-color: #F9F9FD"
:on-after-leave="onMaskClick" content-style="display: flex; justify-content: center; align-items: center;">
<div class="w-927px h-627px bg-#fff rounded-3px px-35px py-20px">
<div class="w-927px h-627px bg-#fff rounded-3px px-35px pb-20px pt-10px">
<div class="w-100% flex justify-end mb-5px">
<n-button text type="primary" @click="onCreateContact">
<template #icon>
<Plus />
</template>
创建新聊天
</n-button>
</div>
<div class="flex items-center justify-between mb-28px">
<div class="text-#333639">搜索</div>
<div class="w-779px h-34px">
@ -168,11 +436,11 @@ if(newVal){
</div>
<div class="flex justify-between">
<div class="w-260px h-517px rounded-4px border-1px border-solid border-#E5E5E5 px-12px">
<div class="border-b-2px border-b-solid border-b-#FBFBFB h-35px flex items-center justify-end">
<!-- <div class="border-b-2px border-b-solid border-b-#FBFBFB h-35px flex items-center justify-end">
<n-button text color="#46299D" class="text-14px" @click="changeSelectType">
{{ selectType === 1 ? '多选' : '取消多选' }}
</n-button>
</div>
</div> -->
<div>
<n-virtual-list v-if="!loading" style="max-height: 470px" :item-size="65" :items="searchFilter">
<template #default="{ item }">
@ -259,5 +527,173 @@ if(newVal){
</div>
</div>
</div>
</x-n-modal>
<!-- 通讯录弹窗 -->
<customModal
v-model:show="state.isShowAddressBookModal"
title="通讯录"
:style="state.customModalStyle"
:customCloseBtn="true"
:closable="false"
:customCloseEvent="true"
@customCloseModal="closeAddressBookModal"
>
<template #content>
<div class="custom-modal-content">
<n-card style="padding: 0 12px">
<n-tabs
type="line"
@update:value="handleAddressBookTabChange"
tab-style="font-size: 16px; font-weight: 600;color: #8B8B8B;"
>
<n-tab name="employeeAddressBook">员工通讯录</n-tab>
<n-tab name="groupChatList">群聊列表</n-tab>
</n-tabs>
<xSearchForm
v-if="state.addressBookCurrentTab == 'employeeAddressBook'"
:search-config="state.addressBookSearchConfig"
customInputPlaceholder="请输入姓名"
@change="changeAddressBookSearch"
:cols="3"
></xSearchForm>
<xSearchForm
v-if="state.addressBookCurrentTab == 'groupChatList'"
:search-config="state.groupChatListSearchConfig"
customInputPlaceholder="请输入群聊名称"
@change="changeGroupChatListSearch"
:cols="3"
></xSearchForm>
<div class="addressBook-content" v-if="state.addressBookCurrentTab == 'employeeAddressBook'">
<div class="addressBook-tree" v-if="!state.addressBookSearchNickName">
<fl-tree
:data="state.treeData"
:expandedKeys="state.expandedKeys"
:refreshCount="state.treeRefreshCount"
:clickKey="state.clickKey"
@triggerTreeClick="handleTreeClick"
></fl-tree>
</div>
<div class="addressBook-table">
<xNDataTable
:columns="state.addressBookColumns"
:data="state.addressBookData"
:style="{
height: `${state.addressBookTableHeight}px`,
width: `${state.addressBookTableWidth}px`
}"
:row-key="row => row.ID"
v-model:checked-row-keys="state.selectedRowKeys"
flex-height
></xNDataTable>
<div class="addressBook-pagination">
<n-pagination
v-model:page="state.addressBookPage"
v-model:page-size="state.addressBookPageSize"
:item-count="state.addressBookTotal"
show-quick-jumper
show-size-picker
:page-sizes="[10, 20, 50]"
:on-update:page="handleAddressBookPagination"
:on-update:page-size="handleAddressBookPaginationSize"
>
<template #prefix="{ itemCount }"> {{ itemCount }} 条记录 </template>
</n-pagination>
</div>
</div>
</div>
<div class="groupChatList-content" v-if="state.addressBookCurrentTab == 'groupChatList'">
<div class="groupChatList-table">
<xNDataTable
:columns="state.groupChatListColumns"
:data="state.groupChatListData"
:style="{
height: '523px',
width: '1148px'
}"
flex-height
></xNDataTable>
<div class="groupChatList-pagination">
<n-pagination
v-model:page="state.groupChatListPage"
v-model:page-size="state.groupChatListPageSize"
:item-count="state.groupChatListTotal"
show-quick-jumper
show-size-picker
:page-sizes="[10, 20, 50]"
:on-update:page="handleGroupChatListPagination"
:on-update:page-size="handleGroupChatListPaginationSize"
>
<template #prefix="{ itemCount }"> {{ itemCount }} 条记录 </template>
</n-pagination>
</div>
</div>
</div>
<!-- 底部操作按钮仅保留展示 -->
<div style="display: flex; justify-content: center; align-items: center; padding: 16px 0;">
<n-button color="#C7C7C9" class="w-250px h-34px text-14px text-#fff mr-10px" @click="onAddressBookCancel">取消</n-button>
<n-button color="#46299D" class="w-250px h-34px text-14px text-#fff" @click="onAddressBookSubmit">发送</n-button>
</div>
</n-card>
</div>
</template>
</customModal>
</template>
<style scoped lang="scss">
.custom-modal-content {
.addressBook-content {
display: flex;
flex-direction: row;
gap: 20px;
.addressBook-tree {
width: 328px;
height: 524px;
overflow: auto;
border: 1px solid #efeff5;
border-radius: 4px;
padding: 12px 20px;
box-sizing: border-box;
}
.addressBook-table {
:deep(.n-data-table-th) {
background-color: #46299d;
color: #fff;
}
.addressBook-pagination {
display: flex;
justify-content: flex-end;
align-items: center;
padding: 22px 0 0;
box-sizing: border-box;
}
}
}
.groupChatList-content {
display: flex;
flex-direction: row;
gap: 20px;
.groupChatList-table {
:deep(.n-data-table-th) {
background-color: #46299d;
color: #fff;
}
.groupChatList-pagination {
display: flex;
justify-content: flex-end;
align-items: center;
padding: 22px 0 0;
box-sizing: border-box;
}
}
}
}
</style>

View File

@ -18,7 +18,7 @@ export function isLoggedIn() {
*/
export function getAccessToken() {
// return storage.get(AccessToken) || ''
return JSON.parse(localStorage.getItem('token'))||''
return JSON.parse(localStorage.getItem('token'))||'46d71a72d8d845ad7ed23eba9bdde260e635407190c2ce1bf7fd22088e41682ea07773ec65cae8946d2003f264d55961f96e0fc5da10eb96d3a348c1664e9644ce2108c311309f398ae8ea1b8200bfd490e5cb6e8c52c9e5d493cbabb163368f8351420451a631dbfa749829ee4cda49b77b5ed2d3dced5d0f2b7dd9ee76ba5465c84a17c23af040cd92b6b2a4ea48befbb5c729dcdad0a9c9668befe84074cc24f78899c1d947f8e7f94c7eda5325b8ed698df729e76febb98549ef3482ae942fb4f4a1c92d21836fa784728f0c5483aab2760a991b6b36e6b10c84f840a6433a6ecc31dee36e8f1c6158818bc89d22184be97f604c74931ddd023225ae1a2ab29c0eb046e9a20174f1110532e4af8498ab46f4375f2e210bae827a6146183f385fdee154fd7bee38deb6ac4c00ae82c77150d5663288f78d678db028bfb7db'
}
/**

View File

@ -46,9 +46,9 @@ export default defineConfig(({ mode }) => {
vueJsx({}),
compressPlugin(),
UnoCSS(),
// vueDevTools({
// launchEditor: 'trae',
// })
vueDevTools({
launchEditor: 'trae',
})
],
define: {
__APP_ENV__: env.APP_ENV