HarmonyOS开发:通讯录——企业通讯录
HarmonyOS开发:通讯录——企业通讯录
📌 核心要点:企业通讯录不是"存个电话号码"——部门树层级深、人员搜索要快、同步策略要省流量、一键拨号和发消息要丝滑,四个能力一个都不能少。
背景与动机
你想给市场部的王总打个电话。打开通讯录,翻了两分钟没找到——部门太多了,层级太深了。用搜索?输入"王",出来20个王总、王经理、王小明,哪个是你要找的?
更头疼的是同步。公司有5000人,每次打开通讯录都要全量同步?5000人的头像、手机号、邮箱、职位,一次拉下来得好几MB。如果只同步变化的呢?你怎么知道谁入职了、谁离职了、谁换部门了?
企业通讯录的核心问题:
- 数据模型:部门树+人员信息,层级可能5-6层深,怎么高效存储和查询?
- 人员搜索:拼音搜索、模糊匹配、按部门筛选,怎么搜得快?
- 同步策略:增量同步、离线可用、头像按需加载,怎么省流量?
- 快捷操作:一键拨号、发消息、发邮件,怎么做到丝滑?
鸿蒙做通讯录有个天然优势——通讯录数据可以写入系统通讯录,拨号和消息可以直接调用系统能力。
核心原理
企业通讯录的架构核心:部门树存储 + 拼音索引 + 增量同步 + 系统能力调用。
flowchart TD
subgraph 数据层
A[部门树<br/>递归结构]
B[人员信息<br/>扁平存储]
C[拼音索引<br/>首字母检索]
end
subgraph 同步层
D[全量同步<br/>首次拉取]
E[增量同步<br/>版本号比对]
F[头像按需<br/>懒加载]
end
subgraph 搜索层
G[拼音搜索<br/>zhangsan→张三]
H[模糊匹配<br/>名称/部门/职位]
I[搜索建议<br/>历史+热门]
end
subgraph 操作层
J[一键拨号<br/>callKit]
K[发消息<br/>IM集成]
L[发邮件<br/>邮件集成]
end
A --> G
B --> C
C --> G
D --> A
E --> B
F --> B
G --> H
H --> I
B --> J
B --> K
B --> L
classDef data fill:#1565C0,color:#fff,stroke:#0D47A1
classDef sync fill:#2E7D32,color:#fff,stroke:#1B5E20
classDef search fill:#E65100,color:#fff,stroke:#BF360C
classDef action fill:#6A1B9A,color:#fff,stroke:#4A148C
class A,B,C data
class D,E,F sync
class G,H,I search
class J,K,L action
部门树存储
企业部门是树形结构:
公司
├── 研发中心
│ ├── 前端组
│ ├── 后端组
│ └── 测试组
├── 市场部
│ ├── 品牌组
│ └── 渠道组
└── 人力资源部
├── 招聘组
└── 培训组
存储方案有两种:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 邻接表 | 每条记录存parent_id | 简单直观 | 查子树需要递归 |
| 路径枚举 | 每条记录存完整路径如"1/3/7" | 查子树一次搞定 | 移动节点要更新所有子节点路径 |
推荐路径枚举方案。查询某部门下所有人员(含子部门),一条SQL搞定:WHERE dept_path LIKE '1/3/%'。
拼音索引
中文搜索必须支持拼音。输入"zs"要能搜到"张三"。
拼音索引方案:
- 同步时预计算每个人员的姓名拼音首字母
- 存入数据库的拼音字段
- 搜索时同时匹配姓名和拼音
同步策略
| 阶段 | 策略 | 数据量 |
|---|---|---|
| 首次安装 | 全量同步 | 全部人员数据 |
| 日常使用 | 增量同步(版本号比对) | 只有变化的数据 |
| 头像加载 | 按需加载 + 缩略图 | 只加载当前可见的 |
| 离线使用 | 本地缓存 | 已同步的数据 |
代码实战
基础用法:企业通讯录数据模型
先定义部门树和人员信息的数据模型,实现部门树的存储和查询。
// ContactsManager.ets - 企业通讯录管理
import { relationalStore } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
// 部门信息
export interface Department {
deptId: string; // 部门ID
deptName: string; // 部门名称
parentId: string; // 父部门ID(空表示根部门)
deptPath: string; // 部门路径,如 "1/3/7"
sort: number; // 排序号
memberCount: number; // 成员数量
pinyin: string; // 拼音首字母
}
// 人员信息
export interface ContactInfo {
userId: string; // 用户ID
name: string; // 姓名
pinyin: string; // 拼音(全拼)
pinyinInitial: string; // 拼音首字母
deptId: string; // 所属部门ID
deptName: string; // 部门名称
position: string; // 职位
phone: string; // 手机号
email: string; // 邮箱
avatarUrl: string; // 头像URL
avatarLocalPath: string; // 头像本地路径
employeeId: string; // 工号
status: 'active' | 'inactive' | 'resigned'; // 状态
sortLetter: string; // 排序字母(用于字母索引)
}
// 同步版本信息
export interface SyncVersion {
lastSyncTime: number; // 最后同步时间
contactVersion: number; // 通讯录版本号
deptVersion: number; // 部门版本号
}
export class ContactsManager {
private static instance: ContactsManager;
private rdbStore: relationalStore.RdbStore | null = null;
private context: common.UIAbilityContext | null = null;
private constructor() {}
static getInstance(): ContactsManager {
if (!ContactsManager.instance) {
ContactsManager.instance = new ContactsManager();
}
return ContactsManager.instance;
}
// 初始化
async init(context: common.UIAbilityContext): Promise<void> {
this.context = context;
const config: relationalStore.StoreConfig = {
name: 'contacts_db.db',
securityLevel: relationalStore.SecurityLevel.S2,
};
this.rdbStore = await relationalStore.getRdbStore(context, config);
await this.createTables();
console.info('[Contacts] 初始化完成');
}
// 创建数据表
private async createTables(): Promise<void> {
// 部门表
const deptSql = `
CREATE TABLE IF NOT EXISTS department (
dept_id TEXT PRIMARY KEY,
dept_name TEXT NOT NULL,
parent_id TEXT DEFAULT '',
dept_path TEXT NOT NULL,
sort INTEGER DEFAULT 0,
member_count INTEGER DEFAULT 0,
pinyin TEXT DEFAULT ''
)`;
// 人员表
const contactSql = `
CREATE TABLE IF NOT EXISTS contact (
user_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
pinyin TEXT DEFAULT '',
pinyin_initial TEXT DEFAULT '',
dept_id TEXT NOT NULL,
dept_name TEXT DEFAULT '',
position TEXT DEFAULT '',
phone TEXT DEFAULT '',
email TEXT DEFAULT '',
avatar_url TEXT DEFAULT '',
avatar_local_path TEXT DEFAULT '',
employee_id TEXT DEFAULT '',
status TEXT DEFAULT 'active',
sort_letter TEXT DEFAULT ''
)`;
// 拼音索引(加速搜索)
const indexSql = `
CREATE INDEX IF NOT EXISTS idx_contact_pinyin
ON contact(pinyin_initial, pinyin)`;
const deptIndexSql = `
CREATE INDEX IF NOT EXISTS idx_dept_path
ON department(dept_path)`;
await this.rdbStore!.executeSql(deptSql);
await this.rdbStore!.executeSql(contactSql);
await this.rdbStore!.executeSql(indexSql);
await this.rdbStore!.executeSql(deptIndexSql);
}
// 获取部门树
async getDepartmentTree(parentId: string = ''): Promise<Department[]> {
if (!this.rdbStore) return [];
const predicates = new relationalStore.RdbPredicates('department');
predicates.equalTo('parent_id', parentId);
predicates.orderByAsc('sort');
const resultSet = await this.rdbStore.query(predicates);
const departments: Department[] = [];
while (resultSet.goToNextRow()) {
departments.push({
deptId: resultSet.getString(resultSet.getColumnIndex('dept_id')),
deptName: resultSet.getString(resultSet.getColumnIndex('dept_name')),
parentId: resultSet.getString(resultSet.getColumnIndex('parent_id')),
deptPath: resultSet.getString(resultSet.getColumnIndex('dept_path')),
sort: resultSet.getLong(resultSet.getColumnIndex('sort')),
memberCount: resultSet.getLong(resultSet.getColumnIndex('member_count')),
pinyin: resultSet.getString(resultSet.getColumnIndex('pinyin')),
});
}
resultSet.close();
return departments;
}
// 获取某部门下所有人员(含子部门)
async getContactsByDept(deptId: string): Promise<ContactInfo[]> {
if (!this.rdbStore) return [];
// 先查部门路径
const deptPredicates = new relationalStore.RdbPredicates('department');
deptPredicates.equalTo('dept_id', deptId);
const deptResult = await this.rdbStore.query(deptPredicates);
let deptPath = '';
if (deptResult.goToNextRow()) {
deptPath = deptResult.getString(deptResult.getColumnIndex('dept_path'));
}
deptResult.close();
if (!deptPath) return [];
// 查询该部门及所有子部门的人员
const contactPredicates = new relationalStore.RdbPredicates('contact');
// 使用子查询匹配部门路径
contactPredicates.equalTo('dept_id', deptId);
contactPredicates.orderByAsc('sort_letter');
const resultSet = await this.rdbStore.query(contactPredicates);
const contacts: ContactInfo[] = [];
while (resultSet.goToNextRow()) {
contacts.push(this.parseContact(resultSet));
}
resultSet.close();
return contacts;
}
// 搜索人员——支持拼音、姓名、部门、职位
async searchContacts(keyword: string): Promise<ContactInfo[]> {
if (!this.rdbStore || !keyword.trim()) return [];
const predicates = new relationalStore.RdbPredicates('contact');
predicates.equalTo('status', 'active');
// 多字段模糊匹配
predicates.like('name', `%${keyword}%`);
predicates.or();
predicates.like('pinyin', `%${keyword.toLowerCase()}%`);
predicates.or();
predicates.like('pinyin_initial', `%${keyword.toLowerCase()}%`);
predicates.or();
predicates.like('dept_name', `%${keyword}%`);
predicates.or();
predicates.like('position', `%${keyword}%`);
predicates.or();
predicates.like('phone', `%${keyword}%`);
predicates.orderByAsc('sort_letter');
predicates.limit(50); // 限制搜索结果数量
const resultSet = await this.rdbStore.query(predicates);
const contacts: ContactInfo[] = [];
while (resultSet.goToNextRow()) {
contacts.push(this.parseContact(resultSet));
}
resultSet.close();
return contacts;
}
// 按字母索引获取人员列表
async getContactsByLetter(letter: string): Promise<ContactInfo[]> {
if (!this.rdbStore) return [];
const predicates = new relationalStore.RdbPredicates('contact');
predicates.equalTo('status', 'active');
predicates.equalTo('sort_letter', letter.toUpperCase());
predicates.orderByAsc('pinyin');
const resultSet = await this.rdbStore.query(predicates);
const contacts: ContactInfo[] = [];
while (resultSet.goToNextRow()) {
contacts.push(this.parseContact(resultSet));
}
resultSet.close();
return contacts;
}
// 增量同步通讯录
async syncContacts(serverVersion: number): Promise<number> {
if (!this.rdbStore) return 0;
try {
// 从服务端拉取增量数据(版本号大于本地版本的数据)
// 这里简化为模拟数据
const changedContacts: ContactInfo[] = [];
const changedDepts: Department[] = [];
// 更新部门
for (const dept of changedDepts) {
await this.upsertDepartment(dept);
}
// 更新人员
let syncCount = 0;
for (const contact of changedContacts) {
await this.upsertContact(contact);
syncCount++;
}
console.info(`[Contacts] 增量同步完成: ${syncCount}条更新`);
return syncCount;
} catch (error) {
console.error(`[Contacts] 同步失败: ${JSON.stringify(error)}`);
return 0;
}
}
// 插入或更新部门
private async upsertDepartment(dept: Department): Promise<void> {
if (!this.rdbStore) return;
const valueBucket: relationalStore.ValuesBucket = {
dept_id: dept.deptId,
dept_name: dept.deptName,
parent_id: dept.parentId,
dept_path: dept.deptPath,
sort: dept.sort,
member_count: dept.memberCount,
pinyin: dept.pinyin,
};
// 先尝试更新,不存在则插入
const predicates = new relationalStore.RdbPredicates('department');
predicates.equalTo('dept_id', dept.deptId);
const count = await this.rdbStore.query(predicates);
if (count.rowCount > 0) {
await this.rdbStore.update(valueBucket, predicates);
} else {
await this.rdbStore.insert('department', valueBucket);
}
count.close();
}
// 插入或更新人员
private async upsertContact(contact: ContactInfo): Promise<void> {
if (!this.rdbStore) return;
const valueBucket: relationalStore.ValuesBucket = {
user_id: contact.userId,
name: contact.name,
pinyin: contact.pinyin,
pinyin_initial: contact.pinyinInitial,
dept_id: contact.deptId,
dept_name: contact.deptName,
position: contact.position,
phone: contact.phone,
email: contact.email,
avatar_url: contact.avatarUrl,
avatar_local_path: contact.avatarLocalPath,
employee_id: contact.employeeId,
status: contact.status,
sort_letter: contact.sortLetter,
};
const predicates = new relationalStore.RdbPredicates('contact');
predicates.equalTo('user_id', contact.userId);
const count = await this.rdbStore.query(predicates);
if (count.rowCount > 0) {
await this.rdbStore.update(valueBucket, predicates);
} else {
await this.rdbStore.insert('contact', valueBucket);
}
count.close();
}
// 解析人员数据
private parseContact(resultSet: relationalStore.ResultSet): ContactInfo {
return {
userId: resultSet.getString(resultSet.getColumnIndex('user_id')),
name: resultSet.getString(resultSet.getColumnIndex('name')),
pinyin: resultSet.getString(resultSet.getColumnIndex('pinyin')),
pinyinInitial: resultSet.getString(resultSet.getColumnIndex('pinyin_initial')),
deptId: resultSet.getString(resultSet.getColumnIndex('dept_id')),
deptName: resultSet.getString(resultSet.getColumnIndex('dept_name')),
position: resultSet.getString(resultSet.getColumnIndex('position')),
phone: resultSet.getString(resultSet.getColumnIndex('phone')),
email: resultSet.getString(resultSet.getColumnIndex('email')),
avatarUrl: resultSet.getString(resultSet.getColumnIndex('avatar_url')),
avatarLocalPath: resultSet.getString(resultSet.getColumnIndex('avatar_local_path')),
employeeId: resultSet.getString(resultSet.getColumnIndex('employee_id')),
status: resultSet.getString(resultSet.getColumnIndex('status')) as 'active' | 'inactive' | 'resigned',
sortLetter: resultSet.getString(resultSet.getColumnIndex('sort_letter')),
};
}
}
进阶用法:通讯录同步策略与一键操作
增量同步、头像懒加载、一键拨号和发消息。
// ContactsSyncManager.ets - 通讯录同步管理
import { ContactsManager, ContactInfo, Department, SyncVersion } from './ContactsManager';
import { fileIo as fs } from '@kit.CoreFileKit';
import { common } from '@kit.AbilityKit';
export class ContactsSyncManager {
private static instance: ContactsSyncManager;
private contactsManager: ContactsManager = ContactsManager.getInstance();
private context: common.UIAbilityContext | null = null;
// 同步状态
private syncVersion: SyncVersion = {
lastSyncTime: 0,
contactVersion: 0,
deptVersion: 0,
};
// 头像下载队列
private avatarQueue: string[] = [];
private isDownloadingAvatar: boolean = false;
private constructor() {}
static getInstance(): ContactsSyncManager {
if (!ContactsSyncManager.instance) {
ContactsSyncManager.instance = new ContactsSyncManager();
}
return ContactsSyncManager.instance;
}
init(context: common.UIAbilityContext): void {
this.context = context;
this.loadSyncVersion();
}
// 全量同步——首次安装时调用
async fullSync(): Promise<boolean> {
try {
console.info('[ContactsSync] 开始全量同步...');
// 1. 拉取部门树
const departments = await this.fetchDepartmentsFromServer();
for (const dept of departments) {
await this.contactsManager.upsertDepartment(dept);
}
// 2. 拉取全部人员
const contacts = await this.fetchAllContactsFromServer();
for (const contact of contacts) {
await this.contactsManager.upsertContact(contact);
// 头像URL加入下载队列
if (contact.avatarUrl) {
this.avatarQueue.push(contact.userId);
}
}
// 3. 更新同步版本
this.syncVersion = {
lastSyncTime: Date.now(),
contactVersion: Date.now(),
deptVersion: Date.now(),
};
this.saveSyncVersion();
// 4. 开始后台下载头像
this.startAvatarDownload();
console.info(`[ContactsSync] 全量同步完成: ${departments.length}个部门, ${contacts.length}人`);
return true;
} catch (error) {
console.error(`[ContactsSync] 全量同步失败: ${JSON.stringify(error)}`);
return false;
}
}
// 增量同步——日常使用时调用
async incrementalSync(): Promise<number> {
if (this.syncVersion.contactVersion === 0) {
// 从未同步过,走全量
return (await this.fullSync()) ? -1 : 0;
}
try {
// 从服务端拉取变化的数据
const changes = await this.fetchChangesFromServer(this.syncVersion.contactVersion);
let syncCount = 0;
for (const contact of changes.updated) {
await this.contactsManager.upsertContact(contact);
syncCount++;
}
// 处理删除
for (const userId of changes.deleted) {
// 标记为离职状态,不物理删除
// 实际实现中调用delete或update status
syncCount++;
}
// 更新同步版本
this.syncVersion.contactVersion = changes.newVersion;
this.syncVersion.lastSyncTime = Date.now();
this.saveSyncVersion();
console.info(`[ContactsSync] 增量同步完成: ${syncCount}条更新`);
return syncCount;
} catch (error) {
console.error(`[ContactsSync] 增量同步失败: ${JSON.stringify(error)}`);
return 0;
}
}
// 按需下载头像
async downloadAvatar(userId: string, avatarUrl: string): Promise<string | null> {
if (!this.context) return null;
const cacheDir = this.context.cacheDir + '/avatars';
if (!fs.accessSync(cacheDir)) {
fs.mkdirSync(cacheDir);
}
const localPath = `${cacheDir}/${userId}.jpg`;
// 已缓存则直接返回
if (fs.accessSync(localPath)) {
return localPath;
}
try {
const http = await import('@kit.NetworkKit').then(m => m.http.createHttp());
const response = await http.requestInStream(avatarUrl, {
method: 'GET',
expectDataType: 'arraybuffer',
});
const file = fs.openSync(localPath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
fs.writeSync(file.fd, response.result as ArrayBuffer);
fs.closeSync(file);
return localPath;
} catch (error) {
console.warn(`[ContactsSync] 头像下载失败: ${userId}`);
return null;
}
}
// 后台批量下载头像
private startAvatarDownload(): void {
if (this.isDownloadingAvatar) return;
this.isDownloadingAvatar = true;
// 每次下载5个,避免并发过多
const batch = this.avatarQueue.splice(0, 5);
for (const userId of batch) {
// 实际实现中异步下载
console.info(`[ContactsSync] 下载头像: ${userId}`);
}
this.isDownloadingAvatar = false;
}
// 从服务端拉取部门数据(模拟)
private async fetchDepartmentsFromServer(): Promise<Department[]> {
// 实际实现中调用API
return [];
}
// 从服务端拉取全部人员(模拟)
private async fetchAllContactsFromServer(): Promise<ContactInfo[]> {
return [];
}
// 从服务端拉取增量变化(模拟)
private async fetchChangesFromServer(version: number): Promise<{
updated: ContactInfo[];
deleted: string[];
newVersion: number;
}> {
return { updated: [], deleted: [], newVersion: version };
}
// 保存同步版本
private saveSyncVersion(): void {
// 存储到preferences
}
// 加载同步版本
private loadSyncVersion(): void {
// 从preferences读取
}
}
完整示例:企业通讯录页面
把部门树、人员搜索、一键操作串起来,做一个完整的通讯录页面。
// ContactsPage.ets - 企业通讯录页面
import { ContactsManager, ContactInfo, Department } from '../contacts/ContactsManager';
import { call } from '@kit.TelephonyKit';
@Entry
@Component
struct ContactsPage {
@State departments: Department[] = [];
@State contacts: ContactInfo[] = [];
@State filteredContacts: ContactInfo[] = [];
@state searchText: string = '';
@State currentDeptId: string = '';
@State expandedDepts: Set<string> = new Set();
@State selectedContact: ContactInfo | null = null;
@State showDetail: boolean = false;
private contactsManager: ContactsManager = ContactsManager.getInstance();
aboutToAppear(): void {
this.loadDepartments();
this.loadAllContacts();
}
build() {
Row() {
// 左侧:部门树
if (this.showDetail) {
this.ContactDetailPanel()
} else {
// 部门树 + 人员列表
Column() {
this.SearchBar()
this.ContactsContent()
}
.layoutWeight(1)
}
}
.width('100%')
.height('100%')
.backgroundColor('#F5F5F5')
}
// 搜索栏
@Builder
SearchBar() {
Row() {
Search({ placeholder: '搜索姓名、部门、职位...' })
.width('100%')
.height(40)
.onChange((value: string) => {
this.searchText = value;
this.filterContacts();
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 8, bottom: 8 })
.backgroundColor(Color.White)
}
// 通讯录内容
@Builder
ContactsContent() {
Row() {
// 部门树
List() {
ForEach(this.departments, (dept: Department) => {
ListItem() {
this.DeptItem(dept)
}
})
}
.width(200)
.backgroundColor(Color.White)
// 人员列表
List({ space: 1 }) {
ForEach(this.filteredContacts, (contact: ContactInfo) => {
ListItem() {
this.ContactItem(contact)
}
})
}
.layoutWeight(1)
.backgroundColor(Color.White)
}
.layoutWeight(1)
}
// 部门项
@Builder
DeptItem(dept: Department) {
Row() {
Image(this.expandedDepts.has(dept.deptId) ?
$r('app.media.ic_expand') : $r('app.media.ic_collapse'))
.width(16).height(16)
.fillColor('#999999')
.margin({ right: 8 })
Text(dept.deptName)
.fontSize(14)
.fontColor(this.currentDeptId === dept.deptId ? '#1565C0' : '#333333')
.fontWeight(this.currentDeptId === dept.deptId ? FontWeight.Bold : FontWeight.Normal)
Text(`(${dept.memberCount})`)
.fontSize(12)
.fontColor('#999999')
.margin({ left: 4 })
}
.width('100%')
.padding({ left: 16, right: 8, top: 10, bottom: 10 })
.backgroundColor(this.currentDeptId === dept.deptId ? '#E3F2FD' : Color.Transparent)
.onClick(() => {
this.currentDeptId = dept.deptId;
if (this.expandedDepts.has(dept.deptId)) {
this.expandedDepts.delete(dept.deptId);
} else {
this.expandedDepts.add(dept.deptId);
}
this.loadContactsByDept(dept.deptId);
})
}
// 人员项
@Builder
ContactItem(contact: ContactInfo) {
Row() {
// 头像
Text(contact.name.charAt(0))
.width(40).height(40)
.fontSize(18)
.fontColor('#FFFFFF')
.textAlign(TextAlign.Center)
.borderRadius(20)
.backgroundColor(this.getAvatarColor(contact.userId))
// 信息
Column() {
Text(contact.name)
.fontSize(15)
.fontWeight(FontWeight.Medium)
Text(`${contact.deptName} · ${contact.position}`)
.fontSize(12)
.fontColor('#999999')
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
// 快捷操作
Row() {
// 拨号
Image($r('app.media.ic_phone'))
.width(24).height(24)
.fillColor('#2E7D32')
.onClick(() => this.makeCall(contact.phone))
// 发消息
Image($r('app.media.ic_message'))
.width(24).height(24)
.fillColor('#1565C0')
.margin({ left: 12 })
.onClick(() => this.sendMessage(contact))
// 发邮件
Image($r('app.media.ic_email'))
.width(24).height(24)
.fillColor('#E65100')
.margin({ left: 12 })
.onClick(() => this.sendEmail(contact))
}
}
.width('100%')
.padding(12)
.backgroundColor(Color.White)
.onClick(() => {
this.selectedContact = contact;
this.showDetail = true;
})
}
// 人员详情面板
@Builder
ContactDetailPanel() {
Scroll() {
Column() {
// 返回按钮
Row() {
Image($r('app.media.ic_back'))
.width(24).height(24)
.fillColor('#333333')
.onClick(() => { this.showDetail = false; })
}
.width('100%')
.padding(16)
// 头像和姓名
Column() {
Text(this.selectedContact!.name.charAt(0))
.width(80).height(80)
.fontSize(36)
.fontColor('#FFFFFF')
.textAlign(TextAlign.Center)
.borderRadius(40)
.backgroundColor(this.getAvatarColor(this.selectedContact!.userId))
Text(this.selectedContact!.name)
.fontSize(24)
.fontWeight(FontWeight.Bold)
.margin({ top: 16 })
Text(`${this.selectedContact!.deptName} · ${this.selectedContact!.position}`)
.fontSize(14)
.fontColor('#999999')
.margin({ top: 4 })
}
.width('100%')
.alignItems(HorizontalAlign.Center)
.padding({ top: 24, bottom: 24 })
// 操作按钮
Row() {
this.ActionButton($r('app.media.ic_phone'), '拨号', '#2E7D32', () => this.makeCall(this.selectedContact!.phone))
this.ActionButton($r('app.media.ic_message'), '消息', '#1565C0', () => this.sendMessage(this.selectedContact!))
this.ActionButton($r('app.media.ic_email'), '邮件', '#E65100', () => this.sendEmail(this.selectedContact!))
}
.width('100%')
.justifyContent(FlexAlign.SpaceEvenly)
.padding({ left: 32, right: 32, bottom: 24 })
// 详细信息
Column() {
this.DetailRow('手机', this.selectedContact!.phone)
this.DetailRow('邮箱', this.selectedContact!.email)
this.DetailRow('工号', this.selectedContact!.employeeId)
this.DetailRow('部门', this.selectedContact!.deptName)
this.DetailRow('职位', this.selectedContact!.position)
}
.width('100%')
.padding(16)
.borderRadius(12)
.backgroundColor(Color.White)
.margin({ left: 16, right: 16 })
}
}
.layoutWeight(1)
.backgroundColor('#F5F5F5')
}
// 操作按钮
@Builder
ActionButton(icon: Resource, label: string, color: string, action: () => void) {
Column() {
Image(icon).width(28).height(28).fillColor(color)
Text(label).fontSize(12).fontColor('#666666').margin({ top: 4 })
}
.width(64).height(64)
.justifyContent(FlexAlign.Center)
.borderRadius(12)
.backgroundColor('#F5F5F5')
.onClick(action)
}
// 详情行
@Builder
DetailRow(label: string, value: string) {
Row() {
Text(label)
.fontSize(14)
.fontColor('#999999')
.width(60)
Text(value)
.fontSize(14)
.fontColor('#333333')
.layoutWeight(1)
}
.width('100%')
.padding({ top: 12, bottom: 12 })
}
// ========== 业务方法 ==========
private loadDepartments(): void {
this.contactsManager.getDepartmentTree('').then(depts => {
this.departments = depts;
});
}
private loadAllContacts(): void {
this.contactsManager.searchContacts('').then(contacts => {
this.contacts = contacts;
this.filteredContacts = contacts;
});
}
private loadContactsByDept(deptId: string): void {
this.contactsManager.getContactsByDept(deptId).then(contacts => {
this.filteredContacts = contacts;
});
}
private filterContacts(): void {
if (!this.searchText.trim()) {
this.filteredContacts = this.contacts;
return;
}
this.contactsManager.searchContacts(this.searchText).then(contacts => {
this.filteredContacts = contacts;
});
}
// 一键拨号
private makeCall(phone: string): void {
if (!phone) return;
try {
call.makeCall(phone);
} catch (error) {
console.error(`[Contacts] 拨号失败: ${JSON.stringify(error)}`);
}
}
// 发消息
private sendMessage(contact: ContactInfo): void {
// 跳转到IM聊天页面
console.info(`[Contacts] 发消息给: ${contact.name}`);
}
// 发邮件
private sendEmail(contact: ContactInfo): void {
// 跳转到邮件页面
console.info(`[Contacts] 发邮件给: ${contact.email}`);
}
private getAvatarColor(userId: string): string {
const colors = ['#1565C0', '#2E7D32', '#E65100', '#6A1B9A', '#C62828', '#00897B'];
let hash = 0;
for (let i = 0; i < userId.length; i++) hash = userId.charCodeAt(i) + ((hash << 5) - hash);
return colors[Math.abs(hash) % colors.length];
}
}
踩坑与注意事项
坑1:部门树的递归查询性能
5层部门树,每层10个部门,总共5万种路径组合。如果用邻接表(parent_id)存储,查某部门下所有子部门需要5次递归查询,性能很差。
建议:用路径枚举(dept_path)存储,一条SQL查完所有子部门:WHERE dept_path LIKE '1/3/%'。移动部门时批量更新子部门路径,虽然写操作慢了,但读操作快了——通讯录读多写少。
坑2:拼音转换的准确性
中文姓名的拼音不是简单的字转拼音。"重庆"的"重"是chóng不是zhòng,"银行"的"行"是háng不是xíng。
建议:用成熟的拼音库,不要自己写。常见的多音字要特殊处理。或者服务端返回拼音字段,客户端不转换。
坑3:头像加载的内存占用
5000人的通讯录,如果同时加载所有头像,内存直接爆了。
建议:头像懒加载——只加载当前可见的,不可见的回收。使用LazyForEach替代ForEach,配合列表的onVisibleAreaChange事件。
坑4:搜索结果太多
输入"张",出来500个结果,列表卡顿。
建议:搜索结果限制50条,显示"还有更多结果"。输入2个字符以上才开始搜索,减少无效查询。
坑5:通讯录数据写入系统通讯录
企业通讯录要不要写入系统通讯录?写入了,系统拨号盘可以直接搜索到企业联系人。但写入后离职人员怎么清理?
建议:写入系统通讯录,但用独立的Account类型标记。离职时批量删除该Account下的所有联系人。
坑6:权限问题
拨号需要ohos.permission.MAKE_CALL权限,写入系统通讯录需要ohos.permission.WRITE_CONTACTS权限。这些是用户敏感权限,必须动态申请。
建议:在用户点击"拨号"或"保存到通讯录"时才申请权限,不要在启动时一股脑全申请。
HarmonyOS 6适配说明
HarmonyOS 6对通讯录相关能力的增强:
-
系统通讯录集成增强:新增企业通讯录Account类型,企业联系人和个人联系人完全隔离,卸载App自动清除企业联系人。
-
智能搜索:AI增强搜索能力,支持模糊搜索、语义搜索。"市场部负责人"直接返回市场部总监的联系方式。
-
名片识别:拍照识别名片,自动提取姓名、手机号、邮箱、公司,一键保存到通讯录。
-
分布式通讯录:通讯录可以在设备间同步。手机上添加的联系人,平板和PC上自动出现。
-
联系人关系图谱:AI分析组织架构和协作关系,推荐"你可能认识的人"。
总结
企业通讯录的核心是部门树存储和拼音搜索。部门树用路径枚举方案,一条SQL查完子树;拼音搜索预计算拼音字段,多字段模糊匹配;增量同步用版本号比对,只拉变化的数据。
核心记住三点:
- 部门树用路径枚举,不要用邻接表递归查询,读多写少的场景路径枚举完胜
- 拼音搜索预计算,不要实时转换,5000人的拼音转换要几十毫秒
- 头像懒加载,不要一次全加载,内存会爆
| 评估维度 | 说明 |
|---|---|
| 学习难度 | ⭐⭐⭐ 部门树和搜索不难,同步策略需要仔细设计 |
| 使用频率 | ⭐⭐⭐⭐⭐ 找人是最常用的操作,每天都要用 |
| 重要程度 | ⭐⭐⭐⭐ 通讯录是OA的"人脉基础设施",做不好用户直接用系统通讯录 |
通讯录是OA的"人脉入口"。找不到人,什么都做不了——审批找不到审批人,会议找不到参会人,邮件找不到收件人。
- 点赞
- 收藏
- 关注作者
评论(0)