feat(contacts): 通讯录支持按部门显示,成员按需加载

- 新增 contacts/departments 接口,返回部门树及成员数
- 通讯录搜索框右侧新增字母/部门视图切换,部门视图可折叠,展开时才加载成员
- users/search 的 keys.departments 改为参数绑定(修复 SQL 注入),0 表示未加入任何部门
- 同步翻译与 ai-kb
This commit is contained in:
kuaifan 2026-10-10 04:47:34 +00:00
parent adac87bf45
commit ca1a1b5415
20 changed files with 443 additions and 38 deletions

View File

@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\User;
use App\Module\Base;
use App\Module\ContactsDepartment;
/**
* @apiDefine contacts
*
* 通讯录
*/
class ContactsController extends AbstractController
{
/**
* @api {get} api/contacts/departments 通讯录部门树
*
* @apiDescription 需要token身份,返回深度优先展开的部门列表;部门成员通过 api/users/search(keys.departments)按需加载
* @apiVersion 1.0.0
* @apiGroup contacts
* @apiName departments
*
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
* - total: 联系人总数
* - no_department_count: 未加入任何部门的联系人数
* - departments: 部门列表,字段 id、name、parent_id、owner_userid、level(层级,顶级为0)、member_count(直属成员数)、total_count(含下级部门的成员数)
*/
public function departments()
{
User::auth();
//
return Base::retSuccess('success', ContactsDepartment::tree());
}
}

View File

@ -658,7 +658,7 @@ class UsersController extends AbstractController
* - keys.project_id 在指定项目ID
* - keys.no_project_id 不在指定项目ID
* - keys.dialog_id 在指定对话ID
* - keys.departments 部门ID(多个用逗号分隔)
* - keys.departments 部门ID(多个用逗号分隔,0 表示未加入任何部门)
* @apiParam {Object} sorts 排序方式
* - sorts.az 按字母:asc|desc
* @apiParam {Number} updated_time 在这个时间戳之后更新的
@ -740,13 +740,20 @@ class UsersController extends AbstractController
$query->select('userid')->from('web_socket_dialog_users')->where('dialog_id', $keys['dialog_id']);
});
}
if ($keys['departments']) {
if (!is_array($keys['departments'])) {
$keys['departments'] = explode(",", $keys['departments']);
}
$builder->where(function($query) use ($keys) {
foreach ($keys['departments'] AS $department) {
$query->orWhereRaw("FIND_IN_SET('{$department}', department)");
if (isset($keys['departments']) && $keys['departments'] !== '' && $keys['departments'] !== []) {
$departments = is_array($keys['departments']) ? $keys['departments'] : explode(",", $keys['departments']);
// 只保留数字项,避免空串/非法值被 intval 成 0 后误匹配“未加入任何部门”
$departments = array_map('intval', array_filter($departments, fn($item) => is_numeric($item)));
$builder->where(function($query) use ($departments) {
foreach ($departments AS $department) {
if ($department === 0) {
// 0 表示未加入任何部门
$query->orWhere(function($q) {
$q->whereNull("department")->orWhere("department", "");
});
} else {
$query->orWhereRaw("FIND_IN_SET(?, department)", [$department]);
}
}
});
}

View File

@ -0,0 +1,90 @@
<?php
namespace App\Module;
use App\Models\User;
use App\Models\UserDepartment;
/**
* 通讯录部门树
*/
class ContactsDepartment
{
/**
* 构建通讯录部门树(深度优先展开的扁平列表)
*
* 成员口径与 users/search 默认一致:未禁用、非机器人。
* - member_count: 直属成员数
* - total_count: 含全部下级部门的成员数(去重)
*
* @return array{total: int, no_department_count: int, departments: array}
*/
public static function tree(): array
{
$departments = UserDepartment::select(['id', 'name', 'parent_id', 'owner_userid'])->orderBy('id')->get()->keyBy('id');
// 部门 => 直属成员
$members = [];
$noDepartmentCount = 0;
$total = 0;
User::whereNull('disable_at')->where('bot', 0)->pluck('department', 'userid')->each(function ($department, $userid) use ($departments, &$members, &$noDepartmentCount, &$total) {
$total++;
// pluck 会经过 User::getDepartmentAttribute,得到的已是 int 数组
$ids = array_filter((array)$department, fn($id) => $departments->has($id));
if (empty($ids)) {
$noDepartmentCount++;
return;
}
foreach ($ids as $id) {
$members[$id][$userid] = true;
}
});
// 上级部门不存在时视为顶级
$children = [];
foreach ($departments as $department) {
$parentId = $departments->has($department->parent_id) && $department->parent_id != $department->id ? $department->parent_id : 0;
$children[$parentId][] = $department->id;
}
$list = [];
$visited = [];
$walk = function ($id, $level) use (&$walk, &$list, &$visited, &$children, &$members, $departments) {
$visited[$id] = true;
$department = $departments[$id];
$index = count($list);
$list[] = [
'id' => $department->id,
'name' => $department->name,
'parent_id' => (int)$department->parent_id,
'owner_userid' => (int)$department->owner_userid,
'level' => $level,
'member_count' => count($members[$id] ?? []),
'total_count' => 0,
];
$all = $members[$id] ?? [];
foreach ($children[$id] ?? [] as $childId) {
if (isset($visited[$childId])) {
continue; // 防止异常数据形成环
}
$all += $walk($childId, $level + 1);
}
$list[$index]['total_count'] = count($all);
return $all;
};
foreach ($children[0] ?? [] as $rootId) {
$walk($rootId, 0);
}
foreach ($departments as $id => $department) {
if (!isset($visited[$id])) {
$walk($id, 0); // 异常数据形成环时兜底,避免部门丢失
}
}
return [
'total' => $total,
'no_department_count' => $noDepartmentCount,
'departments' => $list,
];
}
}

View File

@ -2842,3 +2842,5 @@ APP 新版本
用时 (*)
暂不支持在线更新
更新内容
按字母显示
按部门显示

View File

@ -40930,5 +40930,29 @@
"fr": "Mise à jour du système lancée",
"id": "Pembaruan sistem dimulai",
"ru": "Обновление системы запущено"
},
{
"key": "按字母显示",
"zh": "",
"zh-CHT": "按字母顯示",
"en": "Sort by Letter",
"ko": "알파벳순 보기",
"ja": "文字順で表示",
"de": "Nach Buchstaben anzeigen",
"fr": "Afficher par ordre alphabétique",
"id": "Tampilkan menurut abjad",
"ru": "Показать по алфавиту"
},
{
"key": "按部门显示",
"zh": "",
"zh-CHT": "按部門顯示",
"en": "Group by Department",
"ko": "부서별 보기",
"ja": "部門別に表示",
"de": "Nach Abteilung anzeigen",
"fr": "Afficher par département",
"id": "Tampilkan menurut departemen",
"ru": "Показать по отделам"
}
]
]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -17,7 +17,7 @@ prerequisites: []
negative:
- 不支持完全隐藏消息模块,仅可逐个隐藏单条会话
- 没有「未登录" 访客访问入口
last_verified: v1.7.90
last_verified: v1.9.59
---
# 消息列表入口在哪
@ -47,4 +47,4 @@ last_verified: v1.7.90
## 相关
- 入口详解:[[messenger.entry.menu-map]]
- 通讯录:联系人列表
- 通讯录:联系人列表,默认按字母分组;点击搜索框右侧的图标可切换为「按部门显示」(部门树,点击部门标题展开/收起并查看成员,部门负责人排在最前,标题右侧为含下级部门的成员数),再次点击切回按字母显示;搜索时统一显示平铺的搜索结果;所选显示方式会被记住

View File

@ -28,6 +28,15 @@
clearable/>
</Form>
</div>
<div
v-if="tabActive==='contacts'"
class="search-mode"
:class="{active: contactsViewMode==='department'}"
:title="$L(contactsViewMode==='department' ? '按字母显示' : '按部门显示')"
@click="toggleContactsView">
<i v-if="contactsViewMode==='department'" class="taskfont">&#xe634;</i>
<i v-else class="taskfont team">&#xe75c;</i>
</div>
</div>
<div v-if="tabActive==='dialog' && !dialogSearchKey" class="messenger-nav">
<EDropdown
@ -131,10 +140,16 @@
</li>
</ul>
<ul v-else class="contacts">
<template v-if="contactsFilter.length > 0">
<li v-for="items in contactsList">
<div class="label">{{items.az}}</div>
<template v-if="contactsList.length > 0">
<li v-for="items in contactsList" :key="items.key" :style="contactsGroupStyle(items)">
<div v-if="items.dept" class="label department-label" @click="toggleDepartment(items.dept)">
<Icon class="arrow" :class="{expanded: items.expanded}" type="ios-arrow-forward"/>
<span class="name">{{items.az}}</span>
<span class="count">{{items.dept.total_count}}</span>
</div>
<div v-else class="label">{{items.az}}</div>
<ul>
<li v-if="items.loading && items.list.length === 0" class="members-loading"><Loading/></li>
<li
v-for="(user, index) in items.list"
:key="index"
@ -156,10 +171,10 @@
</li>
<li class="loaded">
<template v-if="contactsKey">{{$L('搜索到(*)位联系人', contactsFilter.length)}}</template>
<template v-else>{{$L('共(*)位联系人', contactsTotal)}}</template>
<template v-else>{{$L('共(*)位联系人', contactsDepartmentMode ? departmentData.total : contactsTotal)}}</template>
</li>
</template>
<li v-else-if="contactsLoad == 0" class="nothing">
<li v-else-if="contactsLoad == 0 && departmentLoad == 0" class="nothing">
{{$L(contactsKey ? `没有任何与"${contactsKey}"相关的结果` : `没有任何联系人`)}}
</li>
</ul>
@ -280,6 +295,8 @@ import TransferDom from "../../directives/transfer-dom";
import emitter from "../../store/events";
import transformEmojiToHtml from "../../utils/emoji";
let contactsViewModeCache = 'list';
const navDatas = {
menus: [
{type: '', name: '全部'},
@ -319,6 +336,13 @@ export default {
contactsCurrentPage: 1,
contactsHasMorePages: false,
contactsLastTime: 0,
contactsPartial: false, // 部门视图下搜索会往 contactsData 混入部分数据,切回列表视图时需重新加载
contactsViewMode: contactsViewModeCache, // list|department
departmentLoad: 0,
departmentData: null,
departmentExpanded: {}, // 部门ID => 是否展开
departmentMembers: {}, // 部门ID => {list, loading}
operateItem: {},
operateStyles: {},
@ -332,6 +356,8 @@ export default {
if (navDatas.historys.length === 0) {
navDatas.historys = navDatas.menus.map(item => Object.assign(item, {time: 0}))
}
const viewMode = await $A.IDBString("messengerContactsViewMode", contactsViewModeCache)
contactsViewModeCache = ['list', 'department'].includes(viewMode) ? viewMode : 'list'
next()
},
@ -486,7 +512,58 @@ export default {
return contactsData;
},
contactsDepartmentMode() {
return this.contactsViewMode === 'department' && !this.contactsKey
},
departmentGroups() {
const {departmentData, departmentExpanded, departmentMembers} = this;
if (departmentData === null) {
return [];
}
const departments = departmentData.departments.slice();
if (departmentData.no_department_count > 0) {
departments.push({
id: 0,
name: this.$L('默认部门'),
owner_userid: 0,
level: 0,
total_count: departmentData.no_department_count,
});
}
const groups = [];
let skipLevel = null;
departments.forEach(dept => {
if (skipLevel !== null && dept.level > skipLevel) {
return;
}
skipLevel = null;
const expanded = !!departmentExpanded[dept.id];
if (!expanded) {
skipLevel = dept.level;
}
const members = departmentMembers[dept.id];
let list = expanded && members ? members.list : [];
if (dept.owner_userid > 0) {
// 部门负责人排在最前
list = [...list.filter(u => u.userid == dept.owner_userid), ...list.filter(u => u.userid != dept.owner_userid)];
}
groups.push({
key: `department-${dept.id}`,
az: dept.name,
dept,
expanded,
loading: expanded && !!members?.loading,
list,
});
});
return groups;
},
contactsList() {
if (this.contactsDepartmentMode) {
return this.departmentGroups;
}
const {contactsKey} = this;
const list = [];
this.contactsFilter.some(data => {
@ -502,6 +579,7 @@ export default {
}
} else {
list.push({
key: az,
az,
list: [user]
})
@ -547,11 +625,11 @@ export default {
}
},
searchLoading({tabActive, loadDialogs, dialogSearchLoad, contactsLoad}) {
searchLoading({tabActive, loadDialogs, dialogSearchLoad, contactsLoad, departmentLoad}) {
if (tabActive === 'dialog') {
return loadDialogs > 0 || dialogSearchLoad > 0
} else {
return contactsLoad > 0
return contactsLoad > 0 || departmentLoad > 0
}
},
},
@ -615,14 +693,7 @@ export default {
tabActive: {
handler(val) {
if (val == 'contacts') {
if ($A.dayjs().unix() - this.contactsLastTime > 24 * 3600) {
this.contactsData = null; // 24个小时重新加载列表
}
if (this.contactsData === null) {
this.getContactsList(1);
} else {
this.updateContactsList(1000);
}
this.loadContactsView();
this.dialogSearchSelectedParams = null;
} else {
this.updateDialogs(1000);
@ -632,6 +703,14 @@ export default {
immediate: true
},
contactsViewMode(val) {
contactsViewModeCache = val
$A.IDBSet("messengerContactsViewMode", val)
if (this.tabActive === 'contacts') {
this.loadContactsView()
}
},
dialogId: {
handler(id) {
if (id > 0) {
@ -949,7 +1028,108 @@ export default {
});
},
contactsGroupStyle(items) {
if (items.dept && items.dept.level > 0) {
return {marginLeft: `${24 + items.dept.level * 14}px`}
}
return null
},
toggleContactsView() {
this.contactsViewMode = this.contactsViewMode === 'department' ? 'list' : 'department'
},
loadContactsView() {
if (this.contactsViewMode === 'department') {
this.getDepartmentList();
return;
}
if (this.contactsPartial || $A.dayjs().unix() - this.contactsLastTime > 24 * 3600) {
this.contactsData = null; // 24个小时重新加载列表
this.contactsPartial = false;
}
if (this.contactsData === null) {
this.getContactsList(1);
} else {
this.updateContactsList(1000);
}
},
toggleDepartment(dept) {
const expanded = !this.departmentExpanded[dept.id]
this.$set(this.departmentExpanded, dept.id, expanded)
if (expanded) {
this.getDepartmentMembers(dept.id) // 每次展开都刷新,已有缓存时静默更新
}
},
getDepartmentList() {
this.departmentLoad++;
this.$store.dispatch("call", {
url: 'contacts/departments',
}).then(({data}) => {
if (this.departmentData === null) {
// 首次加载:展开顶级部门(没有任何部门时展开默认部门)
data.departments.forEach(dept => {
if (dept.level === 0) {
this.$set(this.departmentExpanded, dept.id, true)
}
})
if (data.departments.length === 0) {
this.$set(this.departmentExpanded, 0, true)
}
}
this.departmentData = data;
// 加载或刷新已展开部门的成员
Object.keys(this.departmentExpanded).forEach(id => {
if (this.departmentExpanded[id]) {
this.getDepartmentMembers(Number(id))
}
})
}).catch(() => {}).finally(_ => {
this.departmentLoad--;
});
},
getDepartmentMembers(id) {
let state = this.departmentMembers[id]
if (state?.loading) {
return;
}
if (!state) {
this.$set(this.departmentMembers, id, {list: [], loading: true})
state = this.departmentMembers[id]
} else {
state.loading = true
}
const list = []
const load = page => {
return this.$store.dispatch("call", {
url: 'users/search',
data: {
keys: {departments: String(id)},
sorts: {az: 'asc'},
page,
pagesize: 100
},
}).then(({data}) => {
list.push(...data.data)
if (data.current_page < data.last_page && page < 200) {
return load(page + 1)
}
})
}
load(1).then(() => {
state.list = list
}).catch(() => {}).finally(() => {
state.loading = false
})
},
getContactsList(page) {
if (this.contactsViewMode === 'department') {
this.contactsPartial = true;
}
this.contactsLoad++;
const key = this.contactsKey
this.$store.dispatch("call", {
@ -991,6 +1171,7 @@ export default {
getContactsNextPage() {
if (this.scrollE() < 10
&& this.tabActive === 'contacts'
&& !this.contactsDepartmentMode
&& this.contactsLoad === 0
&& this.contactsHasMorePages) {
this.getContactsList(this.contactsCurrentPage + 1);
@ -1001,7 +1182,7 @@ export default {
this.__updateContactsList && clearTimeout(this.__updateContactsList)
if (timeout > -1) {
this.__updateContactsList = setTimeout(_ => {
if (this.tabActive === 'contacts') {
if (this.tabActive === 'contacts' && this.contactsViewMode === 'list' && this.contactsData !== null) {
this.$store.dispatch("call", {
url: 'users/search',
data: {

View File

@ -21,6 +21,25 @@
height: 54px;
padding: 0 12px;
flex-shrink: 0;
.search-mode {
flex-shrink: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
color: #808695;
cursor: pointer;
border-radius: 8px;
.team {
font-size: 20px;
}
&:hover,
&.active {
color: #84C56A;
}
}
.search-wrapper {
flex: 1;
background-color: #F7F7F7;
@ -424,6 +443,36 @@
background-color: #f2f2f2;
transform: scaleY(0.5);
}
&.department-label {
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
.arrow {
flex-shrink: 0;
font-size: 12px;
color: #808695;
margin-right: 6px;
transition: transform 0.2s;
&.expanded {
transform: rotate(90deg);
}
}
.name {
flex: 1;
width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.count {
flex-shrink: 0;
margin-left: 8px;
padding-right: 8px;
font-size: 12px;
color: #808695;
}
}
}
&.loaded {
margin: 0;
@ -433,6 +482,11 @@
justify-content: center;
}
> ul {
> li.members-loading {
height: 40px;
cursor: default;
justify-content: center;
}
> li {
list-style: none;
display: flex;

View File

@ -2,7 +2,7 @@
> 此文件由 `php artisan doc:api-map` 生成,勿手改。
接口总数:333
接口总数:334
## 路由规则
@ -86,6 +86,12 @@ API 使用动态路由(见 `routes/web.php`),URL 段映射为控制器方
| api/users/favorites/clean | favorites__clean() | post | 清理用户收藏 |
| api/users/favorite/check | favorite__check() | get | 检查收藏状态 |
## contacts(ContactsController)
| URL | 方法名 | HTTP | 说明 |
| --- | --- | --- | --- |
| api/contacts/departments | departments() | get | 通讯录部门树 |
## project(ProjectController)
| URL | 方法名 | HTTP | 说明 |

View File

@ -7,6 +7,7 @@ use App\Http\Controllers\Api\TestController;
use App\Http\Controllers\Api\FileController;
use App\Http\Controllers\Api\FileDavController;
use App\Http\Controllers\Api\UsersController;
use App\Http\Controllers\Api\ContactsController;
use App\Http\Controllers\Api\DialogController;
use App\Http\Controllers\Api\PublicController;
use App\Http\Controllers\Api\ReportController;
@ -40,6 +41,9 @@ Route::prefix('api')->middleware(['webapi'])->group(function () {
// 会员
Route::any('users/{method}', UsersController::class);
Route::any('users/{method}/{action}', UsersController::class);
// 通讯录
Route::any('contacts/{method}', ContactsController::class);
Route::any('contacts/{method}/{action}', ContactsController::class);
// 项目
Route::any('project/{method}', ProjectController::class);
Route::any('project/{method}/{action}', ProjectController::class);