鸿蒙 PC 开源实战:用 ArkTS 写一个「活动签到查重 + 参与统计」应用(附 10 个编译避坑点)
摘要:活动签到数据里最烦人的三件事——同一份文件导了两次、同一个人进出会场签了好几次、姓名或时间字段是脏的。本文从零实现一个面向 鸿蒙 PC(HarmonyOS NEXT / 2in1) 的开源 ArkTS 应用,把这三类问题一次性处理掉:导入 CSV → 自动判重 → 统计出席率 → 一键导出汇总。全程本地处理,不联网、不申请任何权限。
关键词:HarmonyOS、ArkTS、鸿蒙 PC、2in1、CSV 解析、数据去重、DevEco Studio
一、为什么要做这个
组织一场线下活动,签到环节通常由第三方系统(扫码、企业微信、问卷)导出成 CSV。拿到文件之后,组织者真正想知道的只有两件事:
- 这份数据干净吗? 能不能直接拿去算绩效 / 报人数?
- 到底来了多少人? 不是"签到次数",而是"去了重的人头数"。
手工做这件事的人,都会踩到同样的三个坑:
| 坑 | 真实表现 | 后果 |
|---|---|---|
| 重复导入 | 同一份文件导两次,或扫码之后又手工补录一次 | 实到人数虚高,同一秒出现多条记录 |
| 多次签到 | 一天里进出会场多次,签退也被算成签到 | 出席率算不准,无法区分"来了"和"来了好几趟" |
| 脏数据 | 姓名缺失、时间写成"待定"、活动 ID 为空 | 整个统计静默出错,还找不到是哪一行的问题 |
市面上的签到系统大多只管"收数据",不管"洗数据"。所以我干脆自己写一个:导入即出结论,并且告诉你每一条可疑记录在哪一行。
二、成品长什么样
五个页签,PC 宽屏与窄屏自适应(断点 840vp):
┌──────────────────────────────────────────────────────────┐
│ 活动签到与参与统计 [加载演示数据] [导入签到结果] │
├──────────────────────────────────────────────────────────┤
│ 数据源:当前 379 条原始记录 · 3 场活动 · 118 位参与者 │
├──────────────────────────────────────────────────────────┤
│ 总览 │ 签到明细 │ 重复检查 │ 参与情况 │ 导出汇总 │
├──────────────────────────────────────────────────────────┤
│ ┌─ 指标卡 ─┬─ 指标卡 ─┬─ 指标卡 ─┬─ 指标卡 ─┐ │
│ ├─ 数据质量检查 ────────┬─ 活动出席情况 ────┤ │
│ ├─ 签到时间分布 ─────────────────────────────┤ │
└──────────────────────────────────────────────────────────┘
用内置的 379 行演示数据跑一遍,结果是这样:
数据行(不含表头): 379
完全重复: 34
多次签到: 16
异常: 9
ACT-2026-001 名册 120 人 / 出席 111 人 / 出席率 92.5%
ACT-2026-002 名册 120 人 / 出席 90 人 / 出席率 75.0%
ACT-2026-003 名册 120 人 / 出席 119 人 / 出席率 99.2%
这份演示数据是我故意"投毒"生成的——10% 完全重复 + 6% 多次签到 + 3 场各 3 条异常,用来验证统计口径对不对。
三、技术选型
| 项目 | 版本 |
|---|---|
| DevEco Studio | 5.0.0 及以上 |
| HarmonyOS SDK | API 12(5.0.0) |
| 目标设备 | 2in1(鸿蒙 PC)/ tablet / phone |
| 第三方依赖 | 零 |
module.json5 里声明:
"deviceTypes": ["2in1", "tablet", "phone"]
2in1 就是鸿蒙 PC 的设备类型。声明之后,在鸿蒙 PC 上安装即为桌面窗口应用,不需要任何额外适配代码——但窗口尺寸变化必须自己处理(见第六章第 6 条)。
四、架构:把统计逻辑从 UI 里彻底剥离
这是整个项目最想强调的一点。很多 demo 会把统计逻辑直接写在页面的 build() 里,导致三个问题:无法测试、无法复用、改一处崩一片。
我做了一个严格的分层:
核心是一个纯函数:
analyze(records: CheckinRecord[], rosterMap?: RosterMap): AnalysisResult
它不 import 任何 @ohos.* 系统能力,不碰 UI,只做数据变换。带来一个直接好处:
同一套算法可以用 JavaScript 再实现一遍,在 Node 里跑单元测试。
这就是tools/verify.mjs存在的原因——30 条断言的算法自检,不需要设备、不需要模拟器,node tools/verify.mjs一秒出结果。
$ node tools/verify.mjs
... 30/30 passed
对鸿蒙开发来说,这一点价值极高:你可以在不打开 DevEco Studio 的情况下验证业务逻辑。
五、核心实现
5.1 三档判重规则
判重不是"有重复就删",而是要区分性质。我把它分成三档:
| 类型 | 判定条件 | 建议处置 |
|---|---|---|
完全重复 Exact | 同一人 + 同一活动 + 同一时间戳 | 直接清理 |
多次签到 Repeat | 同一人 + 同一活动 + 不同时间 | 人工确认是否算有效出席 |
数据异常 Invalid | 活动 ID / 参与者 ID 为空,或时间无法解析 | 回原表修正 |
关键设计:每个「人 + 活动」组合只保留时间最早的一条作为有效出席,后续记录标记但不重复计数。
// ---- 第二趟:组内按时间排序,标记 Exact / Repeat 与 Primary ----
personGroups.forEach((bucket: AnalyzedRecord[]) => {
// 时间升序;时间相同按原始行号,保证结果稳定可复现
bucket.sort((a: AnalyzedRecord, b: AnalyzedRecord) => {
if (a.record.timestamp !== b.record.timestamp) {
return a.record.timestamp - b.record.timestamp;
}
return a.record.lineNo - b.record.lineNo;
});
const seenTimestamps: Set<number> = new Set<number>();
for (let i: number = 0; i < bucket.length; i++) {
const item: AnalyzedRecord = bucket[i];
if (i === 0) {
item.isPrimary = true;
seenTimestamps.add(item.record.timestamp);
continue;
}
if (seenTimestamps.has(item.record.timestamp)) {
item.kind = DupKind.Exact; // 同人同活动同时间
exactDupCount++;
} else {
item.kind = DupKind.Repeat; // 同人同活动不同时间
repeatCount++;
seenTimestamps.add(item.record.timestamp);
}
}
});
注意 seenTimestamps 用的是 Set 而不是"和上一条比较时间"——因为排序后同一秒可能有多条,必须是"这个时间戳是否出现过",而不是"是否等于前一条"。
5.2 实到人数为什么要 Set
// 实到人数:只数 Primary(每人一次),这是去重后的真实出席人数
const primarySet: Set<string> = new Set<string>();
for (const item of bucket) {
rawCount++;
if (item.kind === DupKind.Exact || item.kind === DupKind.Repeat) {
dupCount++;
}
if (item.isPrimary) {
primarySet.add(item.record.participantId);
}
}
出席人数 = 去重后的唯一参与者数,而不是签到条数。 张三签了 3 次,实到只 +1。这是整个应用最容易被误解、也最必须写清楚的口径。
5.3 数据质量分 0~100
组织者最需要的其实是一个"能不能直接用"的判断:
/**
* 数据质量分 0~100。
* 扣分权重:异常记录 -10/条,完全重复 -3/条,多次签到 -1/条。
* 用「每 N 条记录扣 1 分」的方式换算,避免数据量大时分数直接归零。
*/
function calcQualityScore(total: number, invalid: number, exact: number, repeat: number): number {
if (total === 0) {
return 0;
}
const penaltyRate: number = (invalid * 10 + exact * 3 + repeat * 1) / total;
const score: number = 100 - penaltyRate * 100;
return Math.max(0, Math.min(100, Math.round(score)));
}
这里有个细节值得说:扣分用"占比"换算,而不是直接减绝对分。否则 10000 行的文件里有 20 条重复就归零了,分数完全失去区分度。
5.4 CSV 列名别名映射
真实世界的签到系统导出的列名五花八门:活动ID / 活动编号 / 场次ID,参与者ID / 工号 / 学号 / 手机号……
我做了两轮匹配(精确优先、包含兜底)来解决这个问题:
function buildColumnAliases(): Map<string, string[]> {
const map: Map<string, string[]> = new Map<string, string[]>();
map.set('activityId', ['活动id', '活动编号', 'activityid', '活动', '场次id', '会议id']);
map.set('participantId', ['参与者id', '工号', '学号', '用户id', 'userid', '人员编号', 'memberid', '手机号', 'phone', '身份证号']);
map.set('participantName', ['姓名', '参与者姓名', '用户姓名', '员工姓名', 'name', 'username', '昵称']);
map.set('group', ['部门', '班级', '分组', '组织', '单位', 'dept', 'department', 'group', 'team', '所属部门']);
map.set('checkinTime', ['签到时间', '打卡时间', '时间', 'checkintime', 'time', 'createdat', '创建时间', '签到日期']);
map.set('channel', ['签到方式', '渠道', '来源', '方式', 'channel', 'source', 'method', '签到渠道']);
return map;
}
顺便处理了另外四类"文件格式坑":
- 分隔符自动嗅探:逗号 / 分号 / Tab / 竖线(Excel 在部分区域设置下导出的是分号)
- RFC 4180 引号转义:
""表示一个引号字符 - 字段内换行:不用
split('\n'),而是手写状态机逐字符扫描 - UTF-8 BOM:导出的 CSV 统一加 BOM,Excel 双击不乱码
还有一处时间格式兼容,覆盖了实际见过的各种写法:
2026-10-14 09:30:00
2026/10/14 9:30
2026年10月14日 09:30
10 位 / 13 位时间戳
六、10 个 ArkTS 编译避坑点(本文最有价值的部分)
这一章是实际把工程跑起来踩出来的。这些错误网上资料零散,且大多不报"行号 + 明确原因",很容易卡住半天。
1. hvigor-config.json5 必须存在,且 modelVersion 要和 oh-package.json5 一致
现象:Sync 直接失败,提示
ModelVersion for hvigor is not specified, different from the value of ModelVersion for ohpm, 5.0.0
原因:DevEco Studio 新建工程时会自动生成 hvigor/hvigor-config.json5,但如果你是手工/脚本搭的工程,很容易漏掉这个文件。
解决:补建 hvigor/hvigor-config.json5,modelVersion 与根 oh-package.json5 保持一致。
2. 图标资源的层级:应用级 vs 模块级不能混
现象:Sync 报资源找不到。
规则:
| 引用方 | 资源放哪 |
|---|---|
AppScope/app.json5 里的 icon | AppScope/resources/base/media/ |
entry/src/main/module.json5 里的 icon / startWindowIcon | entry/src/main/resources/base/media/ |
且不能重名:如果 layered_image.json 和 layered_image.png 同名放在一起,会冲突。删掉多余的那个。
3. app.json5 不允许出现 4 个旧字段
现象:assembleHap 报 app.json5:11:32。
原因:新版 schema 不再允许 app.json5 里出现 minAPIVersion / targetAPIVersion / apiReleaseType / debug。SDK 版本统一由 build-profile.json5 的 compatibleSdkVersion / targetSdkVersion 管理。
解决:app.json5 只留 bundleName / vendor / versionCode / versionName / icon / label。
4. 尾随闭包写法要求组件「有且仅有一个」@BuilderParam
现象:
In the trailing lambda case, 'SectionCard' must have one and only one
property decorated with '@BuilderParam'
原因:ArkTS 用 SectionCard() { ... } 这种尾随闭包语法时,编译器只能把闭包绑到哪个 @BuilderParam 上产生歧义,所以要求有且仅有一个。
解决:组件里删掉多余的 @BuilderParam;需要多个插槽时改用显式传参,不要用尾随闭包。
5. @ObjectLink 只能绑 @Observed 类实例
现象:编译通过但运行时崩溃。
原因:@ObjectLink 要求数据源是被 @Observed 装饰的类实例。AnalysisResult 是普通 interface,不行。
解决:拆成基础类型 @Prop 传参,或者把数据源改成 @Observed class。
6. @State 只观察第一层
现象:数据算完了,界面不刷新。
原因:@State store: AppStore 只观察 store 自身的变化,store.data.result = xxx 这种深层赋值不触发刷新。
解决:直接观察需要变的那一层:
@State data: AppData = appStore.data; // 直接观察 AppData 的第一层属性
并且 AppActions 必须持有代理后的同一个实例(new AppActions(this.data)),否则改的不是被观察的那个对象。
7. arkts-no-untyped-obj-literals:对象字面量必须有明确类型
这是报错最多的一条,一次编译就报了 10 处。ArkTS 严格模式不允许"无类型对象字面量":
// ✗ 编译失败
export const Logger = { info(msg: string) { ... } };
const config = { a: 1, b: 2 };
// ✓ 用类承载
export class Logger {
static info(msg: string): void { ... }
}
// ✓ 或者显式标注类型
const config: MyConfig = { a: 1, b: 2 };
连带注意:interface 里的嵌套属性也不能写匿名 {...} 类型,必须用具名类型。
8. Record<string, T> 字面量裸 key 不合法
// ✗ 编译失败:Record 字面量的 key 没有引号
const COLUMN_ALIASES: Record<string, string[]> = {
activityId: ['活动id'], // activityId 被当成标识符
};
// ✓ 方案 A:加引号
const M: Record<string, string[]> = { 'activityId': ['活动id'] };
// ✓ 方案 B(本项目采用):改用 Map,复杂场景更灵活
const M: Map<string, string[]> = new Map<string, string[]>();
M.set('activityId', ['活动id']);
9. bindSheet 的 title 字段有类型歧义
// ✗
.bindSheet($$this.showSheet, this.MySheet(), {
title: { title: '导入签到结果' }
})
// ✓
.bindSheet($$this.showSheet, this.MySheet(), {
title: '导入签到结果'
})
10. 用命令行跑 hvigor,比看 IDE 报错快得多
DevEco Studio 的报错面板经常只显示第一条,且信息被截断。直接命令行跑完整构建,一次拿到全部错误清单:
export DEVECO_SDK_HOME="D:\DevEco Studio\sdk"
"D:\DevEco Studio\tools\node\node.exe" \
"D:\DevEco Studio\tools\hvigor\bin\hvigorw.js" assembleHap
一个提示:如果你在纯命令行环境看到 spawn java ENOENT,那是签名工具需要 Java 而你的 shell 缺 PATH,DevEco Studio 内置的 JBR 不受影响,不用管它。
七、还有两个工程习惯值得保留
7.1 导出不申请任何权限
导出 CSV 时,很多实现会去申请「文件读写」权限,用户一拒绝功能就废了。
这个应用不申请任何动态权限:优先写入应用沙箱的 Download 目录(用户可在「文件管理」中直接找到),失败时自动落到沙箱 files/exports——路径在界面上可一键复制。
结论:零权限申请的方案,在"体验"上反而比要权限更好。
7.2 图标用脚本生成,零图形库依赖
node tools/gen-icons.mjs
tools/gen-icons.mjs 是一个手写 PNG 编码器(自己实现 zlib + CRC32 + PNG chunk),不依赖 sharp / canvas 这类原生模块,在纯 Node 环境直接跑。
好处:换配色只需改脚本参数,也不会有"clone 下来装不上依赖"的问题。
八、怎么跑起来
# GitHub
git clone https://github.com/Hunshow/harmony-checkin.git
# 或国内镜像(AtomGit / 开放原子)
git clone https://atomgit.com/gcw_husamPlM/harmony-checkin.git
cd harmony-checkin
- 用 DevEco Studio 打开工程根目录(含
oh-package.json5的那一层),等待 Sync 完成 - 连接鸿蒙 PC 或启动模拟器,点 Run ‘entry’
- 首次运行先点「加载演示数据」,立刻能看到完整统计效果
- 想验证算法:
node tools/verify.mjs(30 条断言,不需要设备)
Fork 后建议改包名:
AppScope/app.json5里的bundleName默认是com.example.activitycheckin,改成你自己的域名倒写,避免冲突。注意改完要重新签名(DevEco 的自动签名证书是按原包名申请的,包名变了要重新生成)。
九、可以怎么改(二次开发指引)
| 想做什么 | 改哪里 |
|---|---|
| 接入新签到系统的列名 | CsvParser.ets 的 buildColumnAliases() |
| 调整判重规则 | Statistics.ets 的 analyze() 第二趟循环 |
| 改质量分权重 | Statistics.ets 的 calcQualityScore() |
| 加新的导出报表 | Statistics.ets 加 buildXxxCsvRows + Index.ets 加卡片 |
| 换主题配色 | common/Tokens.ets(UI 层禁止硬编码色值) |
| 接真实数据源(HTTP / 数据库) | 在 AppActions.importFromText 前加拉取函数,下游逻辑全部复用 |
十、项目信息
- GitHub:https://github.com/Hunshow/harmony-checkin
- AtomGit:https://atomgit.com/gcw_husamPlM/harmony-checkin
- 许可证:Apache License 2.0
- 规模:49 个受版本控制的文件,16 个
.ets源文件 - CI:GitHub Actions 自动跑
node tools/verify.mjs,每次提交验证算法
欢迎提 Issue / PR。提交前请确保 node tools/verify.mjs 全部通过;如果改了统计逻辑,记得同步更新这个自检文件(它是 ArkTS 算法的 JS 同构移植,不同步的话验证就失真了)。
十一、总结
这个小项目技术上不复杂,但有三个点我觉得值得分享:
- 把业务逻辑写成纯函数。这是让"鸿蒙应用可测试"的关键一步——不依赖设备、不依赖 UI,性能极差的环境里也能快速迭代。
- 判重要分类,不要一刀切。"完全重复"和"多次签到"是两种完全不同的业务事实,一个可以自动清理,一个必须人工确认。混在一起会让工具失去可信度。
- ArkTS 的严格模式是好事,但坑很密集。第六章那 10 条基本覆盖了从零搭建工程到编译通过的全部障碍,希望能帮你省下几个小时。
如果这篇文章帮你少踩了一个坑,欢迎点赞收藏。有更好的判重口径设计,也欢迎在评论区交流。
更多推荐



所有评论(0)