鸿蒙实战:报告与雷达图 ReportDao
前言(必读)

图:鸿蒙实战:报告与雷达图 ReportDao(第 86 篇) 运行效果截图(HarmonyOS NEXT)
report 表是 AI 分析结果的主要载体------它将端侧推理产出的人格类型、雷达数据、情绪标签和 AI 摘要持久化到本地。本文详解 ReportDao 的 8 个方法以及 JSON 字段的序列化/反序列化策略,并附带 RelationDao 伴侣关系的实现。

图:ReportDao + RelationDao 数据结构------JSON 字段序列化与雷达分数存储策略
#mermaid-svg-NJ5IBBFAjaYOjQYB{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-NJ5IBBFAjaYOjQYB .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-NJ5IBBFAjaYOjQYB .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-NJ5IBBFAjaYOjQYB .error-icon{fill:#552222;}#mermaid-svg-NJ5IBBFAjaYOjQYB .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-NJ5IBBFAjaYOjQYB .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-NJ5IBBFAjaYOjQYB .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-NJ5IBBFAjaYOjQYB .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-NJ5IBBFAjaYOjQYB .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-NJ5IBBFAjaYOjQYB .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-NJ5IBBFAjaYOjQYB .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-NJ5IBBFAjaYOjQYB .marker{fill:#333333;stroke:#333333;}#mermaid-svg-NJ5IBBFAjaYOjQYB .marker.cross{stroke:#333333;}#mermaid-svg-NJ5IBBFAjaYOjQYB svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-NJ5IBBFAjaYOjQYB p{margin:0;}#mermaid-svg-NJ5IBBFAjaYOjQYB .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-NJ5IBBFAjaYOjQYB .cluster-label text{fill:#333;}#mermaid-svg-NJ5IBBFAjaYOjQYB .cluster-label span{color:#333;}#mermaid-svg-NJ5IBBFAjaYOjQYB .cluster-label span p{background-color:transparent;}#mermaid-svg-NJ5IBBFAjaYOjQYB .label text,#mermaid-svg-NJ5IBBFAjaYOjQYB span{fill:#333;color:#333;}#mermaid-svg-NJ5IBBFAjaYOjQYB .node rect,#mermaid-svg-NJ5IBBFAjaYOjQYB .node circle,#mermaid-svg-NJ5IBBFAjaYOjQYB .node ellipse,#mermaid-svg-NJ5IBBFAjaYOjQYB .node polygon,#mermaid-svg-NJ5IBBFAjaYOjQYB .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-NJ5IBBFAjaYOjQYB .rough-node .label text,#mermaid-svg-NJ5IBBFAjaYOjQYB .node .label text,#mermaid-svg-NJ5IBBFAjaYOjQYB .image-shape .label,#mermaid-svg-NJ5IBBFAjaYOjQYB .icon-shape .label{text-anchor:middle;}#mermaid-svg-NJ5IBBFAjaYOjQYB .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-NJ5IBBFAjaYOjQYB .rough-node .label,#mermaid-svg-NJ5IBBFAjaYOjQYB .node .label,#mermaid-svg-NJ5IBBFAjaYOjQYB .image-shape .label,#mermaid-svg-NJ5IBBFAjaYOjQYB .icon-shape .label{text-align:center;}#mermaid-svg-NJ5IBBFAjaYOjQYB .node.clickable{cursor:pointer;}#mermaid-svg-NJ5IBBFAjaYOjQYB .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-NJ5IBBFAjaYOjQYB .arrowheadPath{fill:#333333;}#mermaid-svg-NJ5IBBFAjaYOjQYB .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-NJ5IBBFAjaYOjQYB .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-NJ5IBBFAjaYOjQYB .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-NJ5IBBFAjaYOjQYB .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-NJ5IBBFAjaYOjQYB .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-NJ5IBBFAjaYOjQYB .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-NJ5IBBFAjaYOjQYB .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-NJ5IBBFAjaYOjQYB .cluster text{fill:#333;}#mermaid-svg-NJ5IBBFAjaYOjQYB .cluster span{color:#333;}#mermaid-svg-NJ5IBBFAjaYOjQYB div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-NJ5IBBFAjaYOjQYB .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-NJ5IBBFAjaYOjQYB rect.text{fill:none;stroke-width:0;}#mermaid-svg-NJ5IBBFAjaYOjQYB .icon-shape,#mermaid-svg-NJ5IBBFAjaYOjQYB .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-NJ5IBBFAjaYOjQYB .icon-shape p,#mermaid-svg-NJ5IBBFAjaYOjQYB .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-NJ5IBBFAjaYOjQYB .icon-shape .label rect,#mermaid-svg-NJ5IBBFAjaYOjQYB .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-NJ5IBBFAjaYOjQYB .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-NJ5IBBFAjaYOjQYB .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-NJ5IBBFAjaYOjQYB :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 1:1
reports 表
personality_type TEXT
emotion_tags TEXT
JSON 数组
ai_summary TEXT
radar_data TEXT
JSON 对象
created_at INTEGER
radar_scores 表
emotion_stability REAL
self_awareness REAL
social_tendency REAL
creativity REAL
responsibility REAL
empathy REAL
一、ReportEntity 模型
typescript
export interface ReportEntity {
id?: number
handwriting_id: number // FK → handwriting.id(1:1 关系)
archive_id?: number // 冗余 FK,方便按档案查询
personality_type?: string // 人格类型文字
summary?: string // AI 摘要
radar_json?: string // JSON: 6 维雷达分数
tags_json?: string // JSON: 情绪标签数组
created_at: number
}
JSON 字段说明
| 字段 | 存储格式 | 示例 | 序列化工具 |
|---|---|---|---|
radar_json |
RadarScores 对象的 JSON 字符串 |
{"energy":62,"thinking":68,...} |
JSON.stringify / JSON.parse |
tags_json |
string[] 数组的 JSON 字符串 |
["💛 平静偏暖","能量 62"] |
JSON.stringify / JSON.parse |
二、ReportDao 方法全景
| 方法 | 用途 | 调用方 |
|---|---|---|
create(item) |
写入分析报告 | AnalyzingPage |
findById(id) |
按报告 ID 加载 | ReportDetailPage |
findByHandwriting(handwritingId) |
按笔迹 ID 查报告 | ReportDetailPage |
listByArchive(archiveId) |
档案下全部报告 | TrendPage / ArchivePage |
listRecent(limit) |
全局最近 N 条 | HomePage(最近分析) |
findLatestByArchive(archiveId) |
档案最新 1 条 | ArchivePage 卡片 |
parseRadar(report) |
反序列化雷达 JSON | 各页面工具方法 |
parseTags(report) |
反序列化标签 JSON | 各页面工具方法 |
countAll() |
全局计数 | MePage |
三、核心查询实现
3.1 findByHandwriting
typescript
// 由 handwriting_id 查找对应的报告
static async findByHandwriting(handwritingId: number): Promise<ReportEntity | null> {
const predicates = new relationalStore.RdbPredicates(TABLE)
predicates.equalTo('handwriting_id', handwritingId)
predicates.orderByDesc('created_at')
const rs = await store.query(predicates, COLS)
let result: ReportEntity | null = null
if (rs.goToFirstRow()) {
result = ReportDao.parseRow(rs)
}
rs.close()
return result
}
3.2 findLatestByArchive(每档案最新一条)
typescript
// ArchivePage 每个档案卡片显示最新人格类型
static async findLatestByArchive(archiveId: number): Promise<ReportEntity | null> {
const rs = await store.querySql(
`SELECT * FROM ${TABLE} WHERE archive_id = ?
ORDER BY created_at DESC LIMIT 1`,
[archiveId]
)
let result: ReportEntity | null = null
if (rs.goToFirstRow()) {
result = ReportDao.parseRow(rs)
}
rs.close()
return result
}
为什么用 querySql 而非 predicates? 因为需要
LIMIT 1,RdbPredicates的limit()在某些版本中不如原生 SQL 直观。
3.3 listByArchive(档案时间线)
typescript
// TrendPage 使用 --- 展示某个档案下的情绪变化趋势
static async listByArchive(archiveId: number): Promise<ReportEntity[]> {
const predicates = new relationalStore.RdbPredicates(TABLE)
predicates.equalTo('archive_id', archiveId)
predicates.orderByDesc('created_at')
const rs = await store.query(predicates, COLS)
const result: ReportEntity[] = []
while (rs.goToNextRow()) {
result.push(ReportDao.parseRow(rs))
}
rs.close()
return result
}
四、JSON 字段序列化
4.1 写入
typescript
// AnalyzingPage 中写入时
await ReportDao.create({
handwriting_id: hwId,
archive_id: archiveId,
personality_type: '温和理性型',
summary: 'TA 是一个细腻、克制的人...',
radar_json: JSON.stringify({ // 序列化为 JSON 字符串
energy: 62, thinking: 68,
action: 55, emotion: 71,
social: 60, detail: 75
}),
tags_json: JSON.stringify([ // 序列化为 JSON 字符串
'💛 平静偏暖', '能量 62', '细腻克制'
]),
created_at: Date.now()
})
4.2 读取
typescript
// 反序列化雷达分数
static parseRadar(report: ReportEntity): RadarScores {
if (!report.radar_json) {
return { energy: 0, thinking: 0, action: 0, emotion: 0,
social: 0, detail: 0 }
}
try {
return JSON.parse(report.radar_json) as RadarScores
} catch (e) {
return { energy: 0, thinking: 0, action: 0, emotion: 0,
social: 0, detail: 0 }
}
}
// 反序列化标签
static parseTags(report: ReportEntity): string[] {
if (!report.tags_json) return []
try {
const t = JSON.parse(report.tags_json) as string[]
return Array.isArray(t) ? t : []
} catch (e) {
return []
}
}
反序列化的安全策略:
| 异常情况 | 处理方式 |
|---|---|
radar_json 为空字符串 |
返回全 0 的 RadarScores |
JSON.parse 抛出异常 |
catch 后返回默认值 |
parseTags 结果不是数组 |
Array.isArray 校验 |
五、RelationDao 伴侣关系
5.1 模型
typescript
export interface RelationEntity {
id?: number
user_id: number // 当前用户
partner_archive_id: number // 伴侣的 archive_id
bound_method?: string // 'tap' | 'qrcode' | 'phone'
match_score?: number // 合盘匹配度
created_at: number
}
5.2 方法
| 方法 | 用途 | 调用方 |
|---|---|---|
create(item) |
创建关系绑定 | BindRelationPage |
findByUser(userId) |
查询当前用户的关系 | 几乎所有页面 |
listByUser(userId) |
列出所有关系 | --- |
updateMatchScore(id, score) |
更新匹配度 | DuetReportPage |
delete(id) |
解绑 | MePage |
5.3 findByUser 实现
typescript
// 查询当前用户最新的关系绑定
static async findByUser(userId: number): Promise<RelationEntity | null> {
const predicates = new relationalStore.RdbPredicates(TABLE)
predicates.equalTo('user_id', userId)
predicates.orderByDesc('created_at')
const rs = await store.query(predicates, COLS)
let result: RelationEntity | null = null
if (rs.goToFirstRow()) {
result = RelationDao.parseRow(rs)
}
rs.close()
return result
}
这个方法是全项目中调用频次最高的 DAO 查询------HomePage、ArchivePage、MePage、CoupleHomePage、DuetReportPage 都在用。
六、数据完整性
6.1 1:1 约束
report 与 handwriting 是 1:1 关系(一条笔迹只能有 1 份报告),但 SQLite 中通过 handwriting_id 外键实现,没有创建 UNIQUE 约束:
typescript
// 如果某条笔迹已存在报告,不要重复创建
const existing = await ReportDao.findByHandwriting(handwritingId)
if (existing) {
hilog.warn(0x0000, TAG, '该笔迹已存在报告,跳过')
return existing.id!
}
6.2 级联清理
ArchiveDao.delete 中会清理 report 和 handwriting 的 archive_id 引用,但不会删除 report 行本身:
删除档案 → report.archive_id = 0(数据保留)
删除笔迹 → 目前没有级联删除 report
七、完整数据写入流程
AnalyzingPage 完成推理
↓
HandwritingDao.create({
archive_id, source, ocr_text,
feature_json, energy_score
})
↓
ArchiveDao.incrementRecord(
archiveId, now
)
↓
ReportDao.create({
handwriting_id, archive_id,
personality_type, summary,
radar_json, tags_json
})
↓
AppStorage.setOrCreate(
'analysis_result', result
)
↓
router.pushUrl('pages/ReportDetailPage')
↓
ReportDetailPage 从 AppStorage 读取 result
八、注意事项与常见问题
8.1 开发注意事项
在正式开发前,建议按以下步骤完成环境准备与前置检查:
- 版本确认:检查 DevEco Studio 与 SDK 版本,确保满足目标 API Level 要求
- 权限声明 :在
module.json5的requestPermissions字段中提前声明所有需要的系统权限 - 设备能力检查:调用前验证设备是否支持目标能力(相机、NFC、传感器等)
- 异步封装 :所有耗时操作(数据库、文件 I/O、网络请求)统一使用
async/await处理 - 资源释放 :在组件
aboutToDisappear()生命周期钩子中及时释放系统资源,防止内存泄漏
8.2 常见错误与解决方案
常见问题快速排查表:
| 问题类型 | 排查方向 | 参考方法 |
|---|---|---|
| 应用崩溃 | 查看 hilog 错误日志 | hilog.error(TAG, "...", e.message) |
| 状态丢失 | 检查 AppStorage 键名拼写 | 统一使用常量管理键名 |
| 动画不流畅 | 避免在 animateTo 回调中执行 I/O | 动画与数据操作分离 |
总结
- ✅ report 表:8 字段,handwriting_id 为 FK,1:1 关系
- ✅ 8 个 DAO 方法:覆盖创建/查询/列表/统计全部场景
- ✅ JSON 序列化:radar_json / tags_json 使用 JSON.stringify/parse
- ✅ 安全反序列化:空值/异常/类型校验三重保护
- ✅ RelationDao:5 个方法管理伴侣绑定关系
- ✅ 完整写入流程:handwriting → archive count → report → AppStorage
下一篇将讲解DemoSeeder 模拟数据初始化------数据库模块的最后一篇。
📌 收藏提示:JSON 字段是 SQLite 灵活性的体现------在关系型数据库中灵活存储非结构化数据,适合雷达图和标签这类长度可变的数据。
如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!
相关资源:
- relationalStore RDB API
- 原始 SQL 查询
- hilog 日志系统
- HarmonyOS NEXT 开发指南
- 第30篇 原始SQL查询与聚合统计
- 第45篇 雷达图正多边形网格
- 第46篇 雷达图数据多边形
- 第32篇 DAO 设计模式
七、ReportDao 核心方法
7.1 JSON 字段的序列化策略
报告中的情绪标签和雷达数据使用 JSON 字符串存储:
typescript
// 写入:对象 → JSON 字符串
const valuesBucket = {
personality_type: report.personalityType,
emotion_tags: JSON.stringify(report.emotionTags), // string[] → JSON
radar_data: JSON.stringify(report.radarData), // RadarData → JSON
ai_summary: report.aiSummary,
archive_id: report.archiveId,
created_at: Date.now()
};
// 读取:JSON 字符串 → 对象
const emotionTags = JSON.parse(
resultSet.getString(resultSet.getColumnIndex('emotion_tags'))
) as string[];
7.2 查询最近 N 条报告
typescript
async queryRecent(limit: number = 5): Promise<ReportInfo[]> {
const store = await DatabaseService.getInstance().getStore();
const sql = `
SELECT r.*, a.name AS archive_name
FROM reports r
JOIN archives a ON r.archive_id = a.id
ORDER BY r.created_at DESC
LIMIT ?
`;
const resultSet = await store.querySql(sql, [limit]);
const reports: ReportInfo[] = [];
while (resultSet.goToNextRow()) {
reports.push(this.fromResultSet(resultSet));
}
resultSet.close();
return reports;
}
7.3 RelationDao 伴侣关系存储
typescript
class RelationDao {
async save(userId: number, partnerCode: string): Promise<void> {
const store = await DatabaseService.getInstance().getStore();
const pred = new relationalStore.RdbPredicates('relations');
pred.equalTo('user_id', userId);
await store.delete(pred); // 先删除旧的(一个用户只能有一个伴侣)
await store.insert('relations', {
user_id: userId,
partner_code: partnerCode,
created_at: Date.now()
});
}
async findByUser(userId: number): Promise<RelationInfo | null> {
const store = await DatabaseService.getInstance().getStore();
const pred = new relationalStore.RdbPredicates('relations');
pred.equalTo('user_id', userId);
const result = await store.query(pred);
if (!result.goToFirstRow()) { result.close(); return null; }
const info = this.fromResultSet(result);
result.close();
return info;
}
}
如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!