摘要:活动签到数据里最烦人的三件事——同一份文件导了两次、同一个人进出会场签了好几次、姓名或时间字段是脏的。本文从零实现一个面向 鸿蒙 PC(HarmonyOS NEXT / 2in1) 的开源 ArkTS 应用,把这三类问题一次性处理掉:导入 CSV → 自动判重 → 统计出席率 → 一键导出汇总。全程本地处理,不联网、不申请任何权限。

关键词:HarmonyOS、ArkTS、鸿蒙 PC、2in1、CSV 解析、数据去重、DevEco Studio


一、为什么要做这个

组织一场线下活动,签到环节通常由第三方系统(扫码、企业微信、问卷)导出成 CSV。拿到文件之后,组织者真正想知道的只有两件事:

  1. 这份数据干净吗? 能不能直接拿去算绩效 / 报人数?
  2. 到底来了多少人? 不是"签到次数",而是"去了重的人头数"。

手工做这件事的人,都会踩到同样的三个坑:

坑真实表现后果
重复导入同一份文件导两次,或扫码之后又手工补录一次实到人数虚高,同一秒出现多条记录
多次签到一天里进出会场多次,签退也被算成签到出席率算不准,无法区分"来了"和"来了好几趟"
脏数据姓名缺失、时间写成"待定"、活动 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 Studio5.0.0 及以上
HarmonyOS SDKAPI 12(5.0.0)
目标设备2in1(鸿蒙 PC)/ tablet / phone
第三方依赖零

module.json5 里声明:

"deviceTypes": ["2in1", "tablet", "phone"]

2in1 就是鸿蒙 PC 的设备类型。声明之后,在鸿蒙 PC 上安装即为桌面窗口应用,不需要任何额外适配代码——但窗口尺寸变化必须自己处理(见第六章第 6 条)。


四、架构:把统计逻辑从 UI 里彻底剥离

这是整个项目最想强调的一点。很多 demo 会把统计逻辑直接写在页面的 build() 里,导致三个问题:无法测试、无法复用、改一处崩一片。

我做了一个严格的分层:

pages/Index
只负责布局与事件绑定

viewmodel/
AppData 状态 + AppActions 动作

utils/Statistics
纯函数统计引擎

utils/CsvParser
纯函数解析器

model/Types
纯数据定义

核心是一个纯函数:

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 里的 iconAppScope/resources/base/media/
entry/src/main/module.json5 里的 icon / startWindowIconentry/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
  1. 用 DevEco Studio 打开工程根目录(含 oh-package.json5 的那一层),等待 Sync 完成
  2. 连接鸿蒙 PC 或启动模拟器,点 Run ‘entry’
  3. 首次运行先点「加载演示数据」,立刻能看到完整统计效果
  4. 想验证算法: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 同构移植,不同步的话验证就失真了)。


十一、总结

这个小项目技术上不复杂,但有三个点我觉得值得分享:

  1. 把业务逻辑写成纯函数。这是让"鸿蒙应用可测试"的关键一步——不依赖设备、不依赖 UI,性能极差的环境里也能快速迭代。
  2. 判重要分类,不要一刀切。"完全重复"和"多次签到"是两种完全不同的业务事实,一个可以自动清理,一个必须人工确认。混在一起会让工具失去可信度。
  3. ArkTS 的严格模式是好事,但坑很密集。第六章那 10 条基本覆盖了从零搭建工程到编译通过的全部障碍,希望能帮你省下几个小时。

如果这篇文章帮你少踩了一个坑,欢迎点赞收藏。有更好的判重口径设计,也欢迎在评论区交流。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐