文件资料库全量代码与效果:ArkTS 在 HarmonyOS 的个人网盘


实例:文件资料库(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')
}
}
三、注册与运行
main_pages.json追加"pages/samples/FileLibPage";Index.ets追加:Button('🗂️ 16 文件资料库').fontSize(15).width('70%') .onClick(() => this.getUIContext().getRouter().pushUrl({ url: 'pages/samples/FileLibPage' }))- 构建验证 →
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 上也是互斥的——switchType 里 this.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 避免输入卡顿,最后一次结果自然胜出 |
更多推荐


所有评论(0)