HarmonyOS开发:通讯录——企业通讯录

举报
Jack20 发表于 2026/06/26 16:53:18 2026/06/26
【摘要】 HarmonyOS开发:通讯录——企业通讯录📌 核心要点:企业通讯录不是"存个电话号码"——部门树层级深、人员搜索要快、同步策略要省流量、一键拨号和发消息要丝滑,四个能力一个都不能少。 背景与动机你想给市场部的王总打个电话。打开通讯录,翻了两分钟没找到——部门太多了,层级太深了。用搜索?输入"王",出来20个王总、王经理、王小明,哪个是你要找的?更头疼的是同步。公司有5000人,每次打开...

HarmonyOS开发:通讯录——企业通讯录

📌 核心要点:企业通讯录不是"存个电话号码"——部门树层级深、人员搜索要快、同步策略要省流量、一键拨号和发消息要丝滑,四个能力一个都不能少。

背景与动机

你想给市场部的王总打个电话。打开通讯录,翻了两分钟没找到——部门太多了,层级太深了。用搜索?输入"王",出来20个王总、王经理、王小明,哪个是你要找的?

更头疼的是同步。公司有5000人,每次打开通讯录都要全量同步?5000人的头像、手机号、邮箱、职位,一次拉下来得好几MB。如果只同步变化的呢?你怎么知道谁入职了、谁离职了、谁换部门了?

企业通讯录的核心问题:

  1. 数据模型:部门树+人员信息,层级可能5-6层深,怎么高效存储和查询?
  2. 人员搜索:拼音搜索、模糊匹配、按部门筛选,怎么搜得快?
  3. 同步策略:增量同步、离线可用、头像按需加载,怎么省流量?
  4. 快捷操作:一键拨号、发消息、发邮件,怎么做到丝滑?

鸿蒙做通讯录有个天然优势——通讯录数据可以写入系统通讯录,拨号和消息可以直接调用系统能力。

核心原理

企业通讯录的架构核心:部门树存储 + 拼音索引 + 增量同步 + 系统能力调用

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"要能搜到"张三"。

拼音索引方案:

  1. 同步时预计算每个人员的姓名拼音首字母
  2. 存入数据库的拼音字段
  3. 搜索时同时匹配姓名和拼音

同步策略

阶段 策略 数据量
首次安装 全量同步 全部人员数据
日常使用 增量同步(版本号比对) 只有变化的数据
头像加载 按需加载 + 缩略图 只加载当前可见的
离线使用 本地缓存 已同步的数据

代码实战

基础用法:企业通讯录数据模型

先定义部门树和人员信息的数据模型,实现部门树的存储和查询。

// 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对通讯录相关能力的增强:

  1. 系统通讯录集成增强:新增企业通讯录Account类型,企业联系人和个人联系人完全隔离,卸载App自动清除企业联系人。

  2. 智能搜索:AI增强搜索能力,支持模糊搜索、语义搜索。"市场部负责人"直接返回市场部总监的联系方式。

  3. 名片识别:拍照识别名片,自动提取姓名、手机号、邮箱、公司,一键保存到通讯录。

  4. 分布式通讯录:通讯录可以在设备间同步。手机上添加的联系人,平板和PC上自动出现。

  5. 联系人关系图谱:AI分析组织架构和协作关系,推荐"你可能认识的人"。

总结

企业通讯录的核心是部门树存储和拼音搜索。部门树用路径枚举方案,一条SQL查完子树;拼音搜索预计算拼音字段,多字段模糊匹配;增量同步用版本号比对,只拉变化的数据。

核心记住三点:

  • 部门树用路径枚举,不要用邻接表递归查询,读多写少的场景路径枚举完胜
  • 拼音搜索预计算,不要实时转换,5000人的拼音转换要几十毫秒
  • 头像懒加载,不要一次全加载,内存会爆
评估维度 说明
学习难度 ⭐⭐⭐ 部门树和搜索不难,同步策略需要仔细设计
使用频率 ⭐⭐⭐⭐⭐ 找人是最常用的操作,每天都要用
重要程度 ⭐⭐⭐⭐ 通讯录是OA的"人脉基础设施",做不好用户直接用系统通讯录

通讯录是OA的"人脉入口"。找不到人,什么都做不了——审批找不到审批人,会议找不到参会人,邮件找不到收件人。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。