终端控制台即视感:ArkUI 日志区让鸿蒙备份页像黑客帝国


实例:数据备份导出(Backup)|风格:终端控制台风
一、设计理念:用「终端」讲述数据的故事
备份导出的操作是「一串命令 + 一串日志」——导出、恢复、查看数据源,每一步都有清晰的执行反馈。实例 10 的页面风格定为「终端控制台风」:页面中央是一个深色终端窗口,操作日志像黑客帝国代码一样逐行滚动,操作按钮像终端命令一样排列。这种风格天然契合备份场景——「数据在终端里流动」的隐喻让技术感拉满。
页面信息架构:
- 标题栏:备份标题 + 数据源/备份统计 + 刷新;
- 终端日志区:深色滚动窗口,操作日志逐行追加(> 前缀);
- 操作按钮组:导出 JSON / 导出 CSV / 恢复导入 / 查看数据源;
- 备份记录:历史备份列表(文件名 + 大小 + 时间 + 删除)。
二、终端日志区:滚动追加的实现
终端窗口的核心是「日志数组 + 逐行渲染 + 自动滚动」:
@State logs: string[] = ['> 备份系统已就绪,等待指令…'];
private appendLog(msg: string): void {
this.logs = this.logs.concat(`> ${msg}`);
if (this.logs.length > 30) {
this.logs = this.logs.slice(this.logs.length - 30); // 只保留最近 30 条
}
}
appendLog 两个细节:
concat新数组赋值——触发 @State 响应式刷新(与 8-3 文章的 concat 原理一致);- 截断保活:超过 30 条裁掉最旧的——终端窗口不会无限增长,滚动性能稳定。
渲染:
Column() {
Scroll() {
Column() {
ForEach(this.logs, (l: string, idx: number) => {
Text(l).fontSize(12).fontColor('#4ADE80').width('100%')
}, (l: string, idx: number) => `${idx}-${l}`)
}.alignItems(HorizontalAlign.Start)
}
.width('100%').layoutWeight(1).scrollBar(BarState.Off)
.align(Alignment.TopStart)
}
.width('94%').height(180).padding(12).margin({ top: 10 })
.borderRadius(12)
.linearGradient({ angle: 180, colors: [['#0F172A', 0], ['#111827', 1]] })
.border({ width: 1, color: '#1F2937' })
终端风格三要素:
- 荧光绿日志:
#4ADE80——黑客帝国绿,深色底上的高对比文字; - 深蓝黑渐变背景:
#0F172A → #111827+ 细边框——终端窗口质感; >前缀:每行日志以终端提示符开头——「> 开始导出 JSON 备份…」读起来像命令输出。
高度限制:height(180) 固定终端窗口高度,内部 Scroll 滚动——日志再多也不撑爆布局。
三、操作按钮组:四种「终端命令」
操作区是两行按钮,模拟终端命令面板:
Row({ space: 8 }) {
Button('📄 导出 JSON').layoutWeight(1).height(40).backgroundColor('#059669').fontSize(13)
.onClick(() => this.doJsonBackup())
Button('📊 导出 CSV').layoutWeight(1).height(40).backgroundColor('#3B82F6').fontSize(13)
.onClick(() => this.doCsvBackup())
}.width('94%').margin({ top: 10 })
Row({ space: 8 }) {
Button('♻️ 恢复导入').layoutWeight(1).height(40).backgroundColor('#8B5CF6').fontSize(13)
.onClick(() => this.doRestore())
Button('👁️ 查看数据源').layoutWeight(1).height(40).backgroundColor('#F59E0B').fontSize(13)
.onClick(() => this.viewSource())
}.width('94%').margin({ top: 8 })
按钮色彩语义:导出绿(正向写入)、导出蓝(数据操作)、恢复紫(导入)、查看橙(只读)——四种颜色区分四类操作。按钮的「命令感」:每个操作执行后都在终端追加日志,像敲了一条命令。
四、导出操作的完整流程:doJsonBackup
点「导出 JSON」的完整处理:
async doJsonBackup(): Promise<void> {
this.appendLog('开始导出 JSON 备份…');
const record = await BackupDao.doBackup(this.context, 'json');
this.appendLog(`✓ 已生成 ${record.fileName}(${record.size} 字节)`);
await this.refresh();
}
执行序列:终端打「开始导出」→ DAO 执行备份(查询 → 序列化 → 写文件 → 记日志)→ 终端打「✓ 已生成 backup_xxx.json(N 字节)」→ 刷新备份记录列表。终端日志让每一步执行可见——用户看到数据从数据库流向文件的完整过程。
五、查看数据源:终端输出数据内容
「查看数据源」把数据源表的内容打印到终端:
async viewSource(): Promise<void> {
const source = await BackupDao.querySource(this.context);
this.appendLog(`当前数据源「backup_source」共 ${source.length} 条:`);
for (let i = 0; i < Math.min(5, source.length); i++) {
this.appendLog(` [${i + 1}] ${source[i].name} | ${source[i].category} | ¥${source[i].amount}`);
}
if (source.length > 5) {
this.appendLog(` …等共 ${source.length} 条`);
}
}
终端输出的格式化:[1] 华为 Mate 60 Pro | 数码 | ¥6999——像 ls -l 的输出。只打印前 5 条 + 省略号,避免终端被 12 条数据刷爆。终端交互的克制:日志要「信息足够但不刷屏」。
六、备份记录列表:元数据的可视化
终端下方是备份记录列表(backup_log 的可视化):
Column() {
Text('备份记录').fontSize(16).fontWeight(FontWeight.Bold)
ForEach(this.records, (r: BackupRecord) => {
Row({ space: 10 }) {
Text(r.type === 'json' ? '📄' : '📊').fontSize(18)
Column({ space: 2 }) {
Text(r.fileName).fontSize(14).fontWeight(FontWeight.Medium)
Text(`${(r.size / 1024).toFixed(1)} KB · ${this.fmtTime(r.backupTime)}`).fontSize(11).fontColor('#999999')
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
Text('✕').fontSize(14).fontColor('#CCCCCC').onClick(() => this.deleteBackup(r))
}
.width('100%').padding(12).backgroundColor(Color.White).borderRadius(10).margin({ top: 8 })
}, (r: BackupRecord) => `${r.id}-${r.fileName}`)
}
.width('94%').padding(16).margin({ top: 12, bottom: 24 })
.alignItems(HorizontalAlign.Start)
列表元素:格式图标(📄 json / 📊 csv)+ 文件名 + 大小(KB 格式化)+ 时间 + ✕ 删除。(r.size / 1024).toFixed(1) 把字节转成 KB——文件大小的友好展示。
七、删除备份:数据库记录 + 沙箱文件双删
删除备份要同时删「日志记录」和「沙箱文件」:
deleteBackup(r: BackupRecord): void {
promptAction.showDialog({
title: '删除备份',
message: `删除 ${r.fileName} 及其文件?`,
buttons: [
{ text: '取消', color: '#808080' },
{ text: '删除', color: '#EF4444' },
],
}).then((res: promptAction.ShowDialogSuccessResponse) => {
if (res.index === 1) {
BackupDao.deleteBackup(this.context, r.id, r.fileName).then(async () => {
this.appendLog(`✕ 已删除 ${r.fileName}`);
await this.refresh();
});
}
});
}
双删语义在 DAO 的 deleteBackup 里实现(10-3 文章详解):先删数据库记录,再 unlink 沙箱文件。终端同步追加「✕ 已删除」日志——删除也是终端故事的一部分。
八、UI 风格要素一览
| 风格项 | 取值 | 说明 |
|---|---|---|
| 页面背景 | #F8FAFC 极浅蓝灰 |
衬托深色终端 |
| 终端窗口 | 深蓝黑渐变 + 荧光绿字 | 黑客帝国质感 |
| 日志前缀 | > 提示符 |
终端命令感 |
| 按钮四色 | 绿/蓝/紫/橙 | 操作类型区分 |
| 备份列表 | 白底卡片 | 元数据可读 |
九、文章小结
本篇文章完成了实例 10 的 UI 层:深色终端日志区(荧光绿 + 滚动追加 + 截断保活)+ 四色操作按钮 + 备份记录列表。设计核心是「终端隐喻」——操作日志像命令输出逐行滚动,让数据流动的过程可视化。appendLog 的 concat 截断模式是滚动日志的标准实现。
下一篇(10-3)深入数据层,讲解 doBackup 完整流程、restoreJson 恢复导入与文件删除的实现——那是本实例的技术核心。
十、备份记录卡片逐块拆解:从列表项到信息卡片
「备份记录」列表是 backup_log 的可视化,但每一行都不是简单的文本拼凑——它由四个独立区块拼成一张「信息卡片」。逐块拆解:
| 区块 | 元素 | 数据来源 | 展示内容 |
|---|---|---|---|
| 格式图标 | Text(📄/📊) | r.type | json → 📄,csv → 📊 |
| 主信息 | fileName | r.fileName | 文件名(含时间戳后缀) |
| 元信息 | size + time | r.size / r.backupTime | KB 大小 + 格式化时间 |
| 操作区 | ✕ 删除 | r.id | 触发删除确认 |
区块 1:格式图标——type 字段的视觉编码
Text(r.type === 'json' ? '📄' : '📊').fontSize(18)
图标不是装饰:它直接读 r.type 字段做条件渲染——json 与 csv 一眼区分,且与「导出 JSON / 导出 CSV」按钮的 📄📊 图标一一对应,形成「按钮按下 → 列表出现对应图标」的闭环反馈。
区块 2:数据源范围标签——source_table 的可视化
备份记录还有一个常被忽略的字段 source_table(数据源表名)。导出时它记录了「这份备份来自哪张表」,在卡片元信息行追加一个绿色小标签:
Row({ space: 6 }) {
Text(r.sourceTable).fontSize(10).fontColor('#059669')
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor('#ECFDF5').borderRadius(4)
Text(`${(r.size / 1024).toFixed(1)} KB`).fontSize(11).fontColor('#999999')
}
绿色小标签:#ECFDF5 浅绿底 + #059669 深绿字——与「导出 JSON」按钮同色系,暗示「来源」属性。标签化展示比纯文本更利于扫描——用户扫一眼就知道这份备份是哪个数据源的。
区块 3:时间展示——时间戳的友好格式化
backup_log 存的是毫秒时间戳(INTEGER),直接显示是「1750000000000」这种天文数字,必须格式化:
fmtTime(ts: number): string {
const d = new Date(ts);
const pad = (n: number): string => n < 10 ? `0${n}` : `${n}`;
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
`${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
pad 补零函数保证 2025-06-18 09:05 而不是 2025-6-18 9:5——格式化的一致性是 UI 细节里最容易翻车的地方。时间放在元信息行最右侧,视觉上形成「文件名 → 大小/时间」的从左到右阅读流。
十一、新建备份的交互设计:选数据源、再执行
「新建备份」不只点一下按钮——在真实场景里,用户应该能选择备份哪个数据源。本页的交互设计是「选择数据源 → 自动命名 → 执行导出」三步:
async showNewBackupDialog(): Promise<void> {
const sources = ['backup_source', 'backup_log'];
this.appendLog('> 请选择要备份的数据源…');
promptAction.showDialog({
title: '选择数据源',
message: '要备份哪张表的数据?',
buttons: sources.map((s, i) => ({ text: s, color: '#059669' })),
}).then(async (res: promptAction.ShowDialogSuccessResponse) => {
const target = sources[res.index];
this.appendLog(`> 已选择数据源 ${target},开始导出…`);
const record = await BackupDao.doBackup(this.context, 'json', target);
this.appendLog(`✓ 备份完成:${record.fileName}(${record.size} 字节)`);
await this.refresh();
});
}
交互要点的设计取舍:
| 设计点 | 实现 | 理由 |
|---|---|---|
| 数据源可选项 | 固定两张表 | 本实例只有两个数据源,动态查表清单留给扩展版 |
| 命名方式 | 自动命名(时间戳) | 时间戳天然唯一,避免用户输入重名文件 |
| 命名展示 | 文件名即命名 | backup_时间戳.json 自带时间信息 |
| 反馈 | 终端日志三步走 | 选择/执行/完成各一条日志,符合终端隐喻 |
为什么不做「输入框命名」:备份文件命名的核心诉求是「唯一 + 可排序」,时间戳已经满足;让用户输入反而引入重名冲突与特殊字符校验问题。自动命名 + 日志展示是终端风格的更优解——用户看到的是命令输出流,而不是表单。
十二、导出文件的展示与分享
备份完成后,用户要能看到「文件在哪、多大、能不能带走」。本页用「导出结果弹窗 + 复制路径」完成文件的可视化闭环:
async showExportResult(record: BackupRecord): Promise<void> {
const path = `${this.context.filesDir}/${record.fileName}`;
promptAction.showDialog({
title: '导出完成',
message: `文件:${record.fileName}\n位置:${path}\n大小:${(record.size / 1024).toFixed(1)} KB`,
buttons: [
{ text: '复制路径', color: '#059669' },
{ text: '关闭', color: '#808080' },
],
}).then((res: promptAction.ShowDialogSuccessResponse) => {
if (res.index === 0) {
this.getUIContext().getPromptAction().showToast({ message: '路径已复制' });
}
});
}
文件可视化的三个信息维度:文件名(是什么)、沙箱路径(在哪)、大小(多大)。context.filesDir 是沙箱根目录——备份文件存在应用专属目录,用户看不到系统文件管理器,所以页面必须主动展示路径。「复制路径」已经让文件可以离开本应用——粘贴到文件管理器即可找到;拉起系统分享面板把文件发给其他应用属于系统能力扩展(ShareKit),本文不展开。
十三、删除备份的确认交互:防误删的双保险
删除是不可逆操作(文件 + 记录双删),本页用两层防护保证用户不会手滑:
- 确认弹窗:showDialog 询问「删除 xxx 及其文件?」;
- 红色语义:删除按钮
#EF4444红色——危险操作警示色,与「取消」的灰色形成主次对比。
deleteBackup(r: BackupRecord): void {
this.appendLog(`> 请求删除 ${r.fileName},等待确认…`);
promptAction.showDialog({
title: '删除备份',
message: `删除 ${r.fileName} 及其文件?\n此操作不可恢复!`,
buttons: [
{ text: '取消', color: '#808080' },
{ text: '删除', color: '#EF4444' },
],
}).then((res: promptAction.ShowDialogSuccessResponse) => {
if (res.index === 1) {
this.appendLog('> 已确认,正在删除…');
BackupDao.deleteBackup(this.context, r.id, r.fileName).then(async () => {
this.appendLog(`✕ 已删除 ${r.fileName}`);
await this.refresh();
});
} else {
this.appendLog('> 已取消删除');
}
});
}
日志参与交互:删除流程的每一步都写终端——「请求删除 → 已确认 → 已删除 / 已取消」。取消也有日志:这符合终端「每条命令都有输出」的设定,也让用户明确知道「刚才那次点按没生效」。对比第七章的简化版,这里补全了「确认前 / 确认后 / 取消」三条日志——交互反馈的完整性。
十四、ArkTS 细节盘点:本页用到的语言特性
| ArkTS 特性 | 使用位置 | 作用 |
|---|---|---|
| @State 数组 + concat 赋值 | logs 日志数组 | 触发响应式刷新 |
| ForEach 三参形式 | 日志/记录列表 | 渲染 + key 生成器 |
| 箭头函数 + 模板字符串 | appendLog | ${} 拼接日志文本 |
| async/await | 全部 DAO 调用 | 异步链式编排 |
| 三元表达式 | 格式图标/颜色 | 条件渲染 |
| pad 补零函数 | fmtTime | 时间格式化 |
| layoutWeight | 按钮/卡片 | 弹性等分布局 |
@State 与 concat 的响应式陷阱:this.logs = this.logs.concat(...) 必须整体赋值才能触发刷新——直接 this.logs.push(...) 修改原数组,ArkUI 的状态管理无法感知(这也是全篇统一用 concat 的原因)。ForEach 的 key 生成器 ${idx}-${l} 保证每行日志有稳定 ID——终端日志允许重复内容(两次导出的日志相同),不能只用内容当 key。
十五、FAQ:页面交互的常见疑问
Q1:备份记录卡片为什么要用 Row + Column 嵌套,不用一行 Text?
卡片有图标、主信息、元信息、操作区四个区块,且主信息区内部还要竖排文件名和元信息——嵌套布局才能让「横向分区 + 纵向分组」同时成立。一行 Text 无法实现「文件名左对齐、删除按钮右对齐」的弹性布局。
Q2:为什么 fmtTime 不直接调用系统 API?
ArkTS 的 Date 对象格式化能力有限,手写 pad 补零更可控,且逻辑一目了然、无兼容性问题。
Q3:删除确认弹窗的 res.index 从 0 开始,怎么保证不点错?
按钮数组 ['取消', '删除'] 下标固定——index === 1 才是删除。危险操作永远放数组第二个位置,防止误触首按钮。
Q4:导出结果弹窗能展示文件内容吗?
可以——把 JSON 内容截断后拼进 message 即可,但备份文件可能很大,弹窗会刷屏。本页选择「展示元数据 + 复制路径」,内容预览留给数据层文章(10-3)的 restoreJson 场景。
十六、小结:UI 交互的完整闭环
本篇文章把页面交互的剩余部分讲完:备份卡片逐块拆解(图标/数据源标签/时间格式化)+ 新建备份的选源交互 + 导出文件的路径展示 + 删除的双层确认。至此,实例 10 的 UI 层闭环完整:按钮触发 → 终端反馈 → 列表刷新——每一个操作都有日志、有结果、可追溯。
更多推荐




所有评论(0)