在这里插入图片描述
在这里插入图片描述

实例:文件资料库(FileLib)|风格:图标行文件浏览器

一、设计理念:文件库要「扫一眼认文件」

文件资料库的 UI 目标是电脑资源管理器/手机文件管理器——每行一个文件:图标(类型)+ 名称 + 元信息(类型标签/大小/日期/标签)。实例 16 的风格「图标行文件浏览器」:搜索框 + 类型筛选条 + 图标行列表

页面信息架构:

  1. 标题栏:文件库 + 文件总数/总大小 + 刷新;
  2. 搜索框:按名称/标签模糊搜索(LIKE);
  3. 类型筛选条:全部/文档/图片/视频/音频/压缩包/其他(动态——来自统计结果);
  4. 文件列表:图标行(类型 emoji 图标 + 文件名 + 类型标签/大小/日期 + 标签 + 删除)。

二、页面骨架与核心状态

@State files: FileRecord[] = [];
@State typeFilter: string = '全部';
@State keyword: string = '';
@State totalCount: number = 0;
@State totalSize: number = 0;
@State typeStats: TypeStat[] = [];
private readonly icons: Record<string, string> = {
  '文档': '📄', '图片': '🖼️', '视频': '🎬', '音频': '🎵', '压缩包': '🗜️', '其他': '📁',
};

icons 类型映射Record<string, string>——类型 → emoji 图标的映射表(ArkTS 对象字面量需声明类型)。列表渲染时 this.icons[f.fileType] ?? '📁':未知类型兜底「📁」。

build 骨架

Column() {
  // 标题栏
  // 搜索框
  // 类型筛选条(动态)
  // 文件列表(List 图标行)
}

三、搜索框:实时模糊搜索

TextInput({ placeholder: '🔍 按名称 / 标签搜索…', text: this.keyword })
  .width('94%').height(40).margin({ top: 10 })
  .backgroundColor(Color.White).borderRadius(20)
  .onChange((v: string) => this.onSearch(v))
onSearch(v: string): void {
  this.keyword = v;
  this.applyFilter();
}

async applyFilter(): Promise<void> {
  if (this.keyword.trim()) {
    this.files = await FileLibDao.search(this.context, this.keyword.trim());
  } else {
    this.files = await FileLibDao.queryByType(this.context, this.typeFilter);
  }
}

搜索优先:关键词非空 → search(名称/标签 LIKE);为空 → 类型筛选。「搜索与筛选互斥」:搜索时不叠加类型过滤(简化交互);placeholder 说明搜索范围「按名称 / 标签搜索…」——用户知道搜什么。圆角 20 胶囊搜索框——常见搜索框样式。

四、类型筛选条:动态生成

类型筛选条的特殊之处——选项来自数据库统计(不是写死):

Scroll() {
  Row({ space: 8 }) {
    ForEach(['全部'].concat(this.typeStats.map((s: TypeStat) => s.fileType)), (t: string) => {
      Text(`${t}`)
        .fontSize(13).padding({ left: 12, right: 12, top: 6, bottom: 6 })
        .borderRadius(16)
        .backgroundColor(this.typeFilter === t ? '#111827' : '#FFFFFF')
        .fontColor(this.typeFilter === t ? Color.White : '#4B5563')
        .onClick(() => this.switchType(t))
    }, (t: string) => t)
  }.padding({ left: 16, right: 16, top: 10 })
}.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).width('100%')

动态选项['全部'].concat(typeStats.map(s => s.fileType))——「全部」+ 数据库里实际存在的类型(GROUP BY 结果)。为什么动态? 数据库只有 6 类时显示 7 个 tab;某类型文件全删了,tab 自动消失——筛选条与数据同步,不显示空类型。

switchType 清空搜索

async switchType(t: string): Promise<void> {
  this.typeFilter = t;
  this.keyword = '';
  await this.applyFilter();
}

切类型清空关键词——搜索与筛选互斥的一致性维护(从搜索状态切到类型筛选,清掉搜索词避免残留过滤)。

五、文件列表:图标行

每行 = 类型图标 + 名称 + 元信息 + 删除:

List({ space: 8 }) {
  ForEach(this.files, (f: FileRecord) => {
    ListItem() {
      Row({ space: 12 }) {
        Text(this.icons[f.fileType] ?? '📁').fontSize(28)
          .width(48).height(48).borderRadius(10).backgroundColor('#F3F4F6')
          .textAlign(TextAlign.Center)
        Column({ space: 3 }) {
          Text(f.name).fontSize(15).fontWeight(FontWeight.Medium).maxLines(1)
          Row({ space: 8 }) {
            Text(f.fileType).fontSize(11).padding({ left: 6, right: 6, top: 2, bottom: 2 })
              .borderRadius(8).backgroundColor('#DBEAFE').fontColor('#3B82F6')
            Text(this.fmtSize(f.size)).fontSize(11).fontColor('#9CA3AF')
            Text(this.fmtTime(f.createdTime)).fontSize(11).fontColor('#9CA3AF')
          }
          if (f.tags) {
            Text(`🏷 ${f.tags}`).fontSize(11).fontColor('#9CA3AF')
          }
        }.alignItems(HorizontalAlign.Start).layoutWeight(1)
        Text('✕').fontSize(14).fontColor('#CCCCCC').onClick(() => this.deleteFile(f))
      }
      .width('100%').padding(12).backgroundColor(Color.White).borderRadius(10)
    }
  }, (f: FileRecord) => `${f.id}-${f.name}`)
}
.width('94%').layoutWeight(1).margin({ top: 10 })
.scrollBar(BarState.Off)

图标行五要素

  1. 类型图标:28fp emoji 放 48×48 浅灰圆角块(#F3F4F6)——像文件图标位;
  2. 文件名:15fp 中等字重,单行截断;
  3. 类型标签:蓝色小胶囊(#DBEAFE#3B82F6 字)——类型可扫读;
  4. 大小 + 日期:灰字(fmtSize 换算 + fmtTime 月日)——文件元信息;
  5. 标签行🏷 需求,产品(有 tags 才显示)——if 条件渲染。

fmtSize 大小换算(字节 → 人类可读):

private fmtSize(size: number): string {
  if (size >= 1073741824) {
    return (size / 1073741824).toFixed(2) + ' GB';
  }
  if (size >= 1048576) {
    return (size / 1048576).toFixed(1) + ' MB';
  }
  if (size >= 1024) {
    return (size / 1024).toFixed(0) + ' KB';
  }
  return size + ' B';
}

分级换算:GB(2 位小数)/ MB(1 位)/ KB(0 位)/ B——按数量级选单位与精度。800MB → 「800.0 MB」、350MB → 「350.0 MB」、120MB → 「120.0 MB」;2.3MB → 「2.3 MB」。

六、UI 风格要素一览

风格项 取值 说明
页面背景 #F8FAFC 极浅灰蓝 文件管理清爽
搜索框 白色胶囊圆角 20 搜索入口
筛选条 动态选项 + 黑底选中 与数据同步
类型图标 28fp emoji + 浅灰底 文件图标位
类型标签 蓝底胶囊 类型可扫读
元信息 灰字大小/日期 次级信息
删除 行尾灰色 ✕ 轻量删除

七、文章小结

本篇文章完成了实例 16 的 UI 层:搜索框(实时 LIKE 搜索)+ 动态类型筛选条(选项来自 GROUP BY 统计,与数据同步)+ 图标行文件列表(类型 emoji 图标 + 名称 + 类型胶囊 + 大小/日期 + 标签 + 删除)。设计核心是「资源管理器感」——图标行五要素扫一眼认文件;技术亮点是动态筛选条(typeStats 驱动选项,无空类型残留)与 fmtSize 分级换算(字节 → GB/MB/KB 按数量级选单位)。

下一篇(16-3)深入数据层,讲解 LIKE 通配符搜索、GROUP BY 类型统计与批量导入去重。

八、搜索框实时交互:击键即查库的节奏

「实时」的含义onChange 在用户每敲一个字符时触发——无需回车、无需按钮。击键 → onSearch(v)applyFilter() → 数据库查询 → this.files 更新 → List 差量重渲染,整条链路毫秒级完成:

环节 触发点 职责
onChange 每敲一个字符 更新 keyword 并触发查询
applyFilter onSearch 内 关键词非空走 search;为空回落到类型筛选
ForEach files 变化 key 相同条目复用,差异条目重渲染

小数据量直接查库:18 个文件的本地 SQLite 查询是微秒~毫秒级,击键即查无压力,不必防抖。数据量上千条后可加 300ms 防抖——只查最后一次击键后的结果:

private timer: number = -1;

onSearch(v: string): void {
  this.keyword = v;
  if (this.timer !== -1) {
    clearTimeout(this.timer);
  }
  this.timer = setTimeout(() => {
    this.applyFilter();
  }, 300);
}

防抖原理:连续击键只重置定时器不查询,停顿 300ms 后才真正查一次——把「每键一查」降为「停顿一查」。注意 setTimeout 在 ArkTS 中返回 number,定时器句柄必须显式声明类型。清空搜索词:删除最后一个字符时 keyword 变空串,applyFilter 自动回落到类型筛选——搜索与筛选状态无缝切换,无需额外分支。

九、typeStats 驱动的筛选条:统计结果长成 UI

筛选条不是写死的 7 个 tab,而是由数据库 GROUP BY 统计结果生成。数据流:

interface TypeStat {
  fileType: string;
  count: number;
  totalSize: number;
}

onPageShow(): void {
  this.loadStats();
  this.applyFilter();
}

async loadStats(): Promise<void> {
  this.typeStats = await FileLibDao.queryTypeStats(this.context);
}

渲染侧 ['全部'].concat(this.typeStats.map((s: TypeStat) => s.fileType))——「全部」永远第一,其余 tab 与数据库实际存在的类型一一对应。动态 vs 静态:

场景 静态写死 typeStats 动态
某类型文件全删 tab 残留,点开为空 tab 自动消失
新增类型 需改代码 自动出现
tab 数量 固定 随数据变化

typeStats 的刷新时机onPageShow(每次进入页面)+ 删除文件后重查——保证「tab 与数据库一致」,绝不出现空类型 tab。

十、图标行五要素逐块拆解

图标行的「资源管理器感」来自五要素各司其职:

1. 类型 emoji(图标位)this.icons[f.fileType] ?? '📁'——映射表命中返回对应 emoji,未知类型兜底「📁」,数据库出现新类型 UI 也不空白。48×48 浅灰圆角块 + 28fp emoji 居中,模拟文件图标位。

2. 名称(主信息):15fp Medium 字重 + maxLines(1) 单行截断——超长省略号收尾,行高稳定,列表不因文件名长短参差。

3. 类型胶囊(类型可扫读)#DBEAFE 底 + #3B82F6 蓝字小胶囊——与 emoji 双保险,扫一眼既能从图标也能从文字认出类型。

4. 大小 + 日期(元信息)fmtSize(f.size) 换算可读大小、fmtTime(f.createdTime) 输出月日时间。两个 11fp 灰字(#9CA3AF弱化视觉——元信息是辅助,不抢主信息权重。

5. 标签行(扩展信息)if (f.tags) Text('🏷 ' + f.tags)——有标签才渲染,无标签的行不高出一行,列表行高统一。

要素 代码要点 视觉 职责
类型 emoji icons 映射 + ?? '📁' 28fp 浅灰底 图标识别
名称 maxLines(1) 15fp Medium 主信息
类型胶囊 蓝底蓝字 11fp 胶囊 类型识别
大小/日期 fmtSize + fmtTime 11fp 灰字 元信息
标签 if 条件渲染 11fp 灰字 🏷 扩展信息

十一、fmtSize 分级换算:数量级决定单位与精度

private fmtSize(size: number): string {
  if (size >= 1073741824) {
    return (size / 1073741824).toFixed(2) + ' GB';
  }
  if (size >= 1048576) {
    return (size / 1048576).toFixed(1) + ' MB';
  }
  if (size >= 1024) {
    return (size / 1024).toFixed(0) + ' KB';
  }
  return size + ' B';
}

阈值即 1024 的幂:1073741824 = 1024³(GB)、1048576 = 1024²(MB)、1024 = 1024¹(KB)——文件大小是二进制单位,除数与阈值必须同源,不能拿 1000 除却拿 1024 判断,否则出现「1023.9 KB」这类怪异值。

数量级 阈值 除数 精度 示例
GB ≥ 1024³ 1073741824 2 位小数 1.25 GB
MB ≥ 1024² 1048576 1 位小数 800.0 MB
KB ≥ 1024 1024 0 位小数 800 KB
B < 1024 整数 512 B

精度随数量级递减:MB 显示 1 位小数已够精确,KB 若也显示小数(「800.5 KB」)占位更宽且无信息量——单位越大,需要的有效数字越少。边界值:1024 字节落入 KB 分支输出「1 KB」;0 字节输出「0 B」。fmtTime 同理分级:1 小时内「x 分钟前」、当天「今天 HH:mm」、更早「M月d日」——时间也按距离分级

十二、删除交互:确认 + 双通道刷新

行尾灰色 ✕ 是轻量删除入口,但直接删有误触风险,用 promptAction.showDialog 确认:

async deleteFile(f: FileRecord): Promise<void> {
  const res = await promptAction.showDialog({
    title: '删除文件',
    message: `确定删除「${f.name}」?`,
    buttons: [
      { text: '取消', color: '#9CA3AF' },
      { text: '删除', color: '#EF4444' },
    ],
  });
  if (res.index === 1) {
    await FileLibDao.deleteById(this.context, f.id);
    this.files = this.files.filter((x: FileRecord) => x.id !== f.id);
    this.loadStats();
  }
}

删除后两条刷新链路:① 列表——本地 filter 移除已删行(比重新查库快);② 统计——loadStats() 重查 typeStats,被删类型的 tab 计数归零后自动消失res.index === 1 对应「删除」按钮(buttons 从 0 编号,index 0 是取消)。

十三、ArkTS 细节清单

细节 写法 说明
对象字面量 readonly icons: Record<string, string> ArkTS 要求字面量带显式类型
兜底取值 this.icons[f.fileType] ?? '📁' 索引可能未命中,?? 兜底
ForEach key ${f.id}-${f.name} key 唯一稳定,差量更新
回调参数 (t: string) => t 必须显式标注类型,无隐式 any
组件方法 private fmtSize 方法声明为私有
setTimeout 句柄 private timer: number = -1 定时器返回 number

ForEach key 的坑:key 必须全局唯一且稳定——只用 f.name 会因重名文件 key 冲突导致渲染错乱;${f.id}-${f.name} 用自增主键保证唯一。无 any 约束:ForEach/map 回调、对象属性访问全部显式类型——编译期强约束,杜绝隐式 any 的运行时风险。

十四、FAQ 问答

Q:搜索时为什么不做类型叠加过滤?
A:搜索(LIKE 全库模糊)与筛选(类型等值)是两种心智模型,叠加会让结果语义混乱,SQL 也要拼 OR+AND 复合条件。本实例选择互斥:搜索时忽略筛选,切筛选时清空搜索词——交互简单、语义清晰。

Q:typeStats 会不会和列表不同步?
A:三个刷新时机:onPageShow、删除后重查、种子数据导入后。选项来自 GROUP BY 统计而非写死,数据变了 tab 跟着变,天然一致。

Q:fmtSize 为什么 KB 不保留小数、GB 却保留两位?
A:按数量级给精度:KB 数值大,整数足够表达;GB 数值小(1.25GB 与 1.3GB 差约 50MB),两位小数才看得出差异。单位越大精度越高——这是「人类可读」的核心。

Q:删除为什么不直接删、非要弹确认框?
A:元数据删除后不可恢复,误触成本高。showDialog 是一次性低成本防护——多一步点击,换来删除操作的确定性。行尾 ✕ 保持轻量入口,确认框承担安全兜底。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐