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

实例:文件资料库(FileLib)|收官文章

一、文件清单

文件 职责 行数
database/FileLibDao.ets 数据层:LIKE 搜索、GROUP BY 统计、批量去重、18 个种子 约 270 行
pages/samples/FileLibPage.ets UI 层:搜索框 + 动态筛选条 + 图标行列表 约 160 行
resources/base/profile/main_pages.json 路由注册:pages/samples/FileLibPage 追加一行
pages/Index.ets 首页入口按钮 追加一个按钮

本篇文章完整展示可编译运行的 FileLibPage 代码,最后描述运行效果。

二、FileLibPage 完整代码

import { common } from '@kit.AbilityKit';
import { promptAction } from '@kit.ArkUI';
import { FileLibDao, FileRecord, TypeStat } from '../../database/FileLibDao';

@Entry
@Component
struct FileLibPage {
  @State files: FileRecord[] = [];
  @State typeFilter: string = '全部';
  @State keyword: string = '';
  @State totalCount: number = 0;
  @State totalSize: number = 0;
  @State typeStats: TypeStat[] = [];
  private context: common.UIAbilityContext = getContext(this) as common.UIAbilityContext;
  private readonly icons: Record<string, string> = {
    '文档': '📄', '图片': '🖼️', '视频': '🎬', '音频': '🎵', '压缩包': '🗜️', '其他': '📁',
  };

  aboutToAppear(): void {
    this.refresh();
  }

  async refresh(): Promise<void> {
    try {
      await FileLibDao.initSeedData(this.context);
      this.typeStats = await FileLibDao.typeStats(this.context);
      const t = await FileLibDao.totalStats(this.context);
      this.totalCount = t.count;
      this.totalSize = t.totalSize;
      await this.applyFilter();
    } catch (e) {
      promptAction.showToast({ message: `加载失败: ${e}` });
    }
  }

  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);
    }
  }

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

  onSearch(v: string): void {
    this.keyword = v;
    this.applyFilter();
  }

  deleteFile(f: FileRecord): void {
    promptAction.showDialog({
      title: '删除文件',
      message: `删除「${f.name}」?`,
      buttons: [
        { text: '取消', color: '#808080' },
        { text: '删除', color: '#EF4444' },
      ],
    }).then((res: promptAction.ShowDialogSuccessResponse) => {
      if (res.index === 1) {
        FileLibDao.delete(this.context, f.id).then(async () => {
          await this.refresh();
          promptAction.showToast({ message: '🗑 已删除' });
        });
      }
    });
  }

  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';
  }

  private fmtTime(ts: number): string {
    const d = new Date(ts);
    return `${d.getMonth() + 1}${d.getDate()}`;
  }

  build() {
    Column() {
      // ===== 标题栏 =====
      Row() {
        Column() {
          Text('🗂️ 文件资料库').fontSize(22).fontWeight(FontWeight.Bold)
          Text(`${this.totalCount} 个文件 · 共 ${this.fmtSize(this.totalSize)}`)
            .fontSize(12).fontColor('#999999').margin({ top: 2 })
        }.alignItems(HorizontalAlign.Start).layoutWeight(1)
        Text('↻').fontSize(22).onClick(() => this.refresh())
      }.width('100%').padding({ left: 16, right: 16, top: 12 })

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

      // ===== 类型筛选 + 统计 =====
      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%')

      // ===== 文件列表 =====
      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)
    }
    .width('100%').height('100%').backgroundColor('#F8FAFC')
  }
}

三、注册与运行

  1. main_pages.json 追加 "pages/samples/FileLibPage"
  2. Index.ets 追加:
    Button('🗂️ 16 文件资料库').fontSize(15).width('70%')
      .onClick(() => this.getUIContext().getRouter().pushUrl({ url: 'pages/samples/FileLibPage' }))
    
  3. 构建验证 → BUILD SUCCESSFUL

四、运行效果描述

进入「🗂️ 16 文件资料库」:

第一屏:标题栏「🗂️ 文件资料库 · 18 个文件 · 共 1.7GB」;白色胶囊搜索框「🔍 按名称 / 标签搜索…」;类型筛选条(全部/文档/图片/视频/音频/压缩包/其他,黑底选中「全部」);下方 18 行文件——每行 📄 图标(浅灰底)+ 文件名 + 蓝色类型胶囊 + 大小/日期灰字 + 🏷 标签行 + 灰色 ✕。

交互一(搜索):搜索框输「发布」→ 列表只剩 2 行(用户手册终稿.pdf、发布包 v2.3.0.zip)——名称/标签 LIKE 命中 → 清空 → 恢复 18 行。

交互二(类型筛选):点「视频」→ 3 行 🎬(功能演示录屏 120.0 MB / 发布会宣传片 350.0 MB / 培训课程录像 800.0 MB)→ 点「文档」→ 4 行 📄。

交互三(搜索与筛选互斥):点「压缩包」→ 4 行 🗜️(关键词已清空)→ 搜索框输「备份」→ 命中「旧版备份.zip」→ 列表只剩 1 行。

交互四(删除):点某行 ✕ → 确认框 → 删除 → 标题栏「17 个文件 · 共 …」同步更新。

五、代码质量要点回顾

关注点 本实例做法
模糊搜索 LIKE ‘%kw%’ OR tags
动态筛选条 typeStats 驱动选项
分组统计 GROUP BY + COUNT + SUM
路径去重 path UNIQUE + 先查后插
大小换算 fmtSize 分级单位
图标映射 Record 类型 + ?? 兜底

六、文章小结

实例 16「文件资料库」收官。五篇文章覆盖:元数据建模与 UNIQUE 路径(16-1)→ 图标行文件浏览器 UI(16-2)→ LIKE 搜索与 GROUP BY 统计(16-3)→ 18 个文件种子(16-4)→ 全量代码(16-5)。核心技术是 LIKE 通配符搜索(%包含匹配 + OR 跨字段)、GROUP BY 分组统计(COUNT + SUM 每类一行)、path UNIQUE 约束去重、字节整数存储(可聚合可换算)。这是「文件元数据管理」的完整范式。

下一个实例(17 预算管理)回归财务数据:月度预算 vs 实际支出的对比——支出汇总、预算进度、超支预警,UI 是进度条预算卡。

七、核心代码片段补充讲解

1. 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);
  }
}

这段代码是整页的「数据路由」:keyword 非空走搜索,否则走类型筛选。两个条件在 UI 上也是互斥的——switchTypethis.keyword = '' 先清空搜索词,onSearch 输入时筛选条虽仍高亮但结果被搜索覆盖。为什么互斥?搜索是「全局命中」(名称/标签跨类型),筛选是「类型内浏览」,同时生效会让用户困惑「当前到底在看哪个范围」。互斥后状态机只剩两种:keyword ≠ '' → 搜索keyword = '' → 类型——逻辑简单、无歧义。

2. 动态筛选条渲染

ForEach(['全部'].concat(this.typeStats.map((s: TypeStat) => s.fileType)), (t: string) => {
  Text(`${t}`)
    .backgroundColor(this.typeFilter === t ? '#111827' : '#FFFFFF')
    .fontColor(this.typeFilter === t ? Color.White : '#4B5563')
    .onClick(() => this.switchType(t))
}, (t: string) => t)

筛选条选项不是写死的 6 个类型,而是 typeStats(GROUP BY 统计结果)驱动——数据库里有什么类型,条上就有什么选项。以后种子数据新增类型(比如「代码」),页面零改动自动多一个筛选胶囊。高亮是纯条件三元:typeFilter === t 时黑底白字、否则白底灰字;(t: string) => t 是 ForEach 的 key 生成器,保证选项增删时列表 diff 稳定、不整条重绘。

八、运行效果补充:搜索命中高亮思路

LIKE 查询只返回整行记录、不返回命中位置,所以高亮必须在页面层做:把文件名按关键词切段,用 Span 分段渲染(ArkUI 的 Text 支持子 Span):

// 思路:name.split(kw) 切段,命中段用 Span 标红加粗
// Text() { Span(段1); Span(kw).fontColor('#EF4444').fontWeight(FontWeight.Bold); ... }

运行效果:搜索「发布」后,两行命中文件名的「发布」二字显示为红色加粗,其余字保持黑色——用户一眼看到「为什么命中」。实现注意:kw 为空时直接渲染整段、不做切割,避免空串 split 产生多余空段。

九、代码质量要点补充

关注点 本实例做法 收益
互斥状态机 applyFilter 二选一 + switchType 清词 逻辑简单、无歧义
动态配置 筛选条由 typeStats 驱动 新增类型零改 UI
统一刷新 refresh 重置全部状态 删除/清空后数据一致
图标兜底 ?? '📁' 未知类型不崩、不空白
列表 key ${f.id}-${f.name} 删除/更新时 diff 稳定

十、FAQ

问题 回答
为什么关键词优先于类型筛选? 搜索是跨类型全局命中,与类型筛选语义不同;互斥后状态只有两种,避免「搜到的结果又被类型过滤」的歧义
LIKE 搜索大小写敏感吗? SQLite 的 LIKE 对 ASCII 字母不区分大小写,中文无大小写、不受影响;如需严格区分可用 GLOB 或 binary 比较
动态筛选条为什么不用写死数组? 写死数组在新增类型时需同步改 UI;用 typeStats 驱动后,数据层加种子、UI 自动出现新胶囊
搜索框 onSearch 为什么没 await? onChange 高频触发,applyFilter 内部已 try/catch 且每次覆盖式赋值;不 await 避免输入卡顿,最后一次结果自然胜出
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐