欢迎大家加入开源鸿蒙跨平台开发者社区,一起共建开源鸿蒙跨平台生态。

在这里插入图片描述

📌 概述

回收站模块用于管理被删除的记录,是数据安全和误删恢复的重要保障。该模块集成了 Cordova 框架与 OpenHarmony 原生能力,实现了“软删除”机制:业务页面不直接物理删除数据,而是将记录移动到回收站表,只有在回收站中执行“彻底删除”时才真正移除。用户可以在回收站中浏览、搜索、按类型筛选被删除的喝茶记录,并支持单条或批量恢复,还可以一键清空回收站。通过这一设计,应用在保持易用体验的同时,大幅降低了误操作带来的数据风险。

🔗 完整流程

第一步:删除动作转向回收站

在记录列表、编辑页面等业务模块,当用户点击“删除”按钮时,并不会立即从主表中删除数据,而是走一条“移动到回收站”的流程:前端通过 db.moveToTrash(record) 将记录插入回收站表,并在主业务表中标记为已删除或直接删除。与此同时,通过 Cordova 调用 OpenHarmony 原生插件,在日志中记录本次删除操作,包括记录 ID、删除时间、来源页面等,方便后续审计和问题排查。用户此时会看到“已移入回收站”的提示,而不是“已彻底删除”,在心理上也更安全。

第二步:回收站列表浏览与筛选

当用户进入“回收站”页面时,前端会从 IndexedDB 的 trash 表中加载所有软删除的记录,并按删除时间倒序排列,最近删除的记录排在最上方。用户可以通过搜索框按茶叶名称、产地、备注等快速过滤,也可以通过下拉框筛选删除来源(如“记录列表删除”“批量删除”“导入覆盖”等)。列表中每一条记录会标出原始类型、删除时间和删除原因,辅助用户判断是否需要恢复。

第三步:恢复或彻底删除

在回收站页面,用户可以对单条记录执行“恢复”操作,也可以勾选多条记录进行批量恢复。恢复时,系统会将记录重新写回业务表(如 records),并从 trash 表中移除对应条目。若用户选择“彻底删除”,则会在前端弹出二次确认提示,确认后才调用 db.deleteFromTrash(id) 和原生层的真实删除逻辑。所有恢复和彻底删除操作同样会被记录到 OpenHarmony 的原生日志中,形成完整的操作审计链路。

🔧 Web 代码实现

回收站页面 HTML 结构

<div id="trash-page" class="page">
    <div class="page-header">
        <h1>回收站</h1>
        <button class="btn btn-danger" onclick="clearTrash()">清空回收站</button>
    </div>

    <div class="trash-toolbar">
        <input type="text" id="trash-search" class="search-box" placeholder="搜索被删除记录...">
        <select id="trash-source-filter" onchange="filterTrashBySource()">
            <option value="">全部来源</option>
            <option value="record-list">记录列表</option>
            <option value="bulk-delete">批量删除</option>
            <option value="import-overwrite">导入覆盖</option>
        </select>
    </div>

    <div class="trash-actions-bar">
        <button class="btn btn-secondary" onclick="selectAllTrash(true)">全选</button>
        <button class="btn btn-secondary" onclick="selectAllTrash(false)">全不选</button>
        <button class="btn btn-primary" onclick="restoreSelected()">恢复选中</button>
        <button class="btn btn-danger" onclick="deleteSelected()">彻底删除选中</button>
    </div>

    <div id="trash-list" class="trash-list">
        <!-- 回收站记录项动态生成 -->
    </div>
</div>

这段 HTML 定义了回收站页面的基本结构:顶部是标题和“清空回收站”按钮,中间是搜索和来源筛选工具栏,下方是批量操作工具条,最底部是动态渲染的回收站记录列表。通过 trash-search 输入框可以按关键字过滤被删除记录,trash-source-filter 下拉框则用于按删除来源分类查看。批量操作按钮配合复选框可以一次性恢复或彻底删除多条记录,从而提高效率。

回收站列表渲染与搜索

let allTrashRecords = [];
let filteredTrashRecords = [];

async function renderTrashPage() {
    try {
        allTrashRecords = await db.getTrashRecords();
        filteredTrashRecords = [...allTrashRecords];
        renderTrashList();
        bindTrashEvents();
    } catch (error) {
        console.error('Failed to load trash:', error);
        showToast('加载回收站失败', 'error');
    }
}

function renderTrashList() {
    const container = document.getElementById('trash-list');
    container.innerHTML = '';

    if (filteredTrashRecords.length === 0) {
        container.innerHTML = '<div class="no-data"><p>回收站为空</p></div>';
        return;
    }

    filteredTrashRecords.forEach(item => {
        const el = document.createElement('div');
        el.className = 'trash-item';
        el.dataset.id = item.id;

        const deletedAt = new Date(item.deletedAt).toLocaleString('zh-CN');

        el.innerHTML = `
            <label class="trash-checkbox">
                <input type="checkbox" class="trash-select" data-id="${item.id}">
                <span></span>
            </label>
            <div class="trash-info">
                <div class="trash-title">${item.data.teaType || '未知茶叶'}</div>
                <div class="trash-meta">
                    <span>删除时间: ${deletedAt}</span>
                    <span>来源: ${item.source || '未知'}</span>
                </div>
                ${item.data.notes ? `<div class="trash-notes">${item.data.notes}</div>` : ''}
            </div>
            <div class="trash-actions">
                <button class="btn-icon" onclick="restoreOne(${item.id})" title="恢复">↩</button>
                <button class="btn-icon" onclick="deleteOne(${item.id})" title="彻底删除">🗑️</button>
            </div>
        `;

        container.appendChild(el);
    });
}

function bindTrashEvents() {
    const searchInput = document.getElementById('trash-search');
    if (searchInput && !searchInput._bound) {
        searchInput._bound = true;
        searchInput.addEventListener('input', e => {
            const keyword = e.target.value.toLowerCase();
            filteredTrashRecords = allTrashRecords.filter(item => {
                const data = item.data || {};
                const text = `${data.teaType || ''} ${data.origin || ''} ${data.notes || ''}`.toLowerCase();
                return text.includes(keyword);
            });
            renderTrashList();
        });
    }
}

这里首先通过 db.getTrashRecords() 读取所有回收站记录,保存在 allTrashRecords 中,再复制一份到 filteredTrashRecords 用于搜索和筛选。renderTrashList() 根据当前过滤结果渲染 DOM,每条记录包括复选框、基本信息和单条操作按钮。bindTrashEvents() 为搜索输入框绑定事件监听,在用户输入关键字时动态过滤 filteredTrashRecords 并重新渲染列表。这样就实现了前端即时搜索,无需额外的数据库查询开销。

恢复与彻底删除逻辑

async function restoreOne(id) {
    const record = allTrashRecords.find(r => r.id === id);
    if (!record) return;

    try {
        await db.restoreFromTrash(id);

        if (window.cordova) {
            cordova.exec(null, null, 'TeaLogger', 'logEvent', [
                'trash_restore_one', { id, source: record.source }
            ]);
        }

        showToast('记录已恢复', 'success');
        renderTrashPage();
    } catch (error) {
        console.error('Failed to restore record:', error);
        showToast('恢复失败', 'error');
    }
}

async function deleteOne(id) {
    if (!confirm('确定要彻底删除这条记录吗?此操作不可恢复。')) {
        return;
    }

    try {
        await db.deleteFromTrash(id);

        if (window.cordova) {
            cordova.exec(null, null, 'TeaLogger', 'logEvent', [
                'trash_delete_one', { id }
            ]);
        }

        showToast('记录已彻底删除', 'success');
        renderTrashPage();
    } catch (error) {
        console.error('Failed to delete record:', error);
        showToast('删除失败', 'error');
    }
}

function getSelectedTrashIds() {
    const checkboxes = document.querySelectorAll('.trash-select:checked');
    return Array.from(checkboxes).map(cb => parseInt(cb.dataset.id, 10));
}

async function restoreSelected() {
    const ids = getSelectedTrashIds();
    if (ids.length === 0) {
        showToast('请先选择要恢复的记录', 'info');
        return;
    }

    for (const id of ids) {
        await db.restoreFromTrash(id);
    }

    if (window.cordova) {
        cordova.exec(null, null, 'TeaLogger', 'logEvent', [
            'trash_restore_batch', { count: ids.length }
        ]);
    }

    showToast(`已恢复 ${ids.length} 条记录`, 'success');
    renderTrashPage();
}

async function deleteSelected() {
    const ids = getSelectedTrashIds();
    if (ids.length === 0) {
        showToast('请先选择要删除的记录', 'info');
        return;
    }

    if (!confirm(`确定要彻底删除选中的 ${ids.length} 条记录吗?此操作不可恢复。`)) {
        return;
    }

    for (const id of ids) {
        await db.deleteFromTrash(id);
    }

    if (window.cordova) {
        cordova.exec(null, null, 'TeaLogger', 'logEvent', [
            'trash_delete_batch', { count: ids.length }
        ]);
    }

    showToast(`已彻底删除 ${ids.length} 条记录`, 'success');
    renderTrashPage();
}

async function clearTrash() {
    if (!confirm('确定要清空整个回收站吗?此操作不可恢复。')) {
        return;
    }

    try {
        await db.clearTrash();

        if (window.cordova) {
            cordova.exec(null, null, 'TeaLogger', 'logEvent', [
                'trash_clear_all', { count: allTrashRecords.length }
            ]);
        }

        showToast('回收站已清空', 'success');
        renderTrashPage();
    } catch (error) {
        console.error('Failed to clear trash:', error);
        showToast('清空失败', 'error');
    }
}

这里分别实现了单条恢复、单条彻底删除、批量恢复、批量彻底删除和清空回收站的逻辑。restoreOne()deleteOne() 针对单条记录进行操作,批量函数则先通过 getSelectedTrashIds() 获取选中的 ID,再在循环中逐条调用数据库 API。每一次重要操作都会通过 cordova.exec 调用 TeaLogger 原生插件记录日志,形成可追踪的操作审计链。针对不可恢复的操作(例如彻底删除、清空回收站),都通过 confirm() 弹窗进行二次确认,降低误操作风险。

🔌 OpenHarmony 原生代码(ArkTS)

回收站数据结构与恢复实现

// entry/src/main/ets/plugins/TrashManager.ets
import { relationalStore } from '@kit.ArkData';

export class TrashManager {
  private store: relationalStore.RdbStore;

  async init(store: relationalStore.RdbStore) {
    this.store = store;
    const sql = `
      CREATE TABLE IF NOT EXISTS trash (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        entity_type TEXT NOT NULL,
        entity_id INTEGER,
        data TEXT NOT NULL,
        source TEXT,
        deleted_at TEXT NOT NULL
      )
    `;
    await this.store.executeSql(sql);
  }

  async moveToTrash(entityType: string, entityId: number, data: object, source: string): Promise<number> {
    const values: relationalStore.ValuesBucket = {
      entity_type: entityType,
      entity_id: entityId,
      data: JSON.stringify(data),
      source: source,
      deleted_at: new Date().toISOString()
    };
    return await this.store.insert('trash', values);
  }

  async restoreRecord(trashId: number): Promise<void> {
    const predicates = new relationalStore.RdbPredicates('trash');
    predicates.equalTo('id', trashId);

    const resultSet = await this.store.query(predicates);
    if (!resultSet.goToFirstRow()) {
      resultSet.close();
      return;
    }

    const entityType = resultSet.getString(resultSet.getColumnIndex('entity_type'));
    const dataJson = resultSet.getString(resultSet.getColumnIndex('data'));
    const data = JSON.parse(dataJson);
    resultSet.close();

    if (entityType === 'record') {
      await this.restoreTeaRecord(data);
    }

    await this.store.delete(predicates);
  }

  private async restoreTeaRecord(data: Record<string, unknown>): Promise<void> {
    const values: relationalStore.ValuesBucket = {
      tea_type: data['teaType'],
      origin: data['origin'],
      price: data['price'],
      rating: data['rating'],
      notes: data['notes'],
      created_at: data['createdAt'] ?? new Date().toISOString()
    };
    await this.store.insert('tea_records', values);
  }
}

这个 ArkTS 原生插件 TrashManager 负责在关系型数据库中维护 trash 表,并提供移动到回收站和从回收站恢复的能力。moveToTrash() 会将原始记录序列化为 JSON 存到 data 字段,并记录实体类型、原始 ID、删除来源和删除时间。restoreRecord() 先查出回收站记录,根据 entity_type 决定调用哪种恢复方法(这里以 record 类型为例),恢复成功后再删除对应的 trash 表项,避免重复恢复。通过这种方式,可以在原生层保证数据的原子性和一致性。

📝 总结

回收站模块是整个喝茶记录应用的“安全网”,它通过 软删除 + 回收站存储 + 原生日志审计 的组合,大幅降低了用户误删数据的风险。Web 层负责提供清晰的列表展示、搜索筛选和批量操作入口,原生 ArkTS 层则负责真正的数据落盘、恢复和彻底删除逻辑。通过 TrashManagerTeaLogger 等插件,Cordova 与 OpenHarmony 之间形成了一条稳定的数据安全通路。你在日后的其他项目中(例如财务应用、笔记应用、观影记录应用)也可以复用这套设计:将所有“删除”统一收口到回收站模块,再配合原生层的类型化恢复逻辑,就能在保持良好体验的前提下,最大限度保护用户数据不被意外丢失。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐