ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

HarmonyOS 应用实战:校园树洞投稿(二)投稿——心情选择与匿名表单的状态编排

HarmonyOS 应用实战:校园树洞投稿(二)投稿——心情选择与匿名表单的状态编排 HarmonyOS 应用实战校园树洞投稿二投稿——心情选择与匿名表单的状态编排项目编号42-treehole-post 技术栈HarmonyOS ArkTS · ArkUI 声明式 UI 本篇页面投稿Func1Tab.ets94 行 系列导航一首页 ·二投稿· 三话题 · 四我的 源码开源https://gitee.com/codenestFlow/HarmonyOSHub一、本篇聚焦投稿页是整套数据流的生产端见 一首页 第十节的四页数据流它把用户的输入组装成一条Hole交给首页展示、话题页聚合、我的页统计。本篇解决三个工程问题三个State怎么协同、单选交互用什么视觉表达、从Toast占位到真实提交要补哪些环节校验、防重、跨页同步。二、三个State内容、话题、心情StorageProp(safeTop) safeTop: number 0; StorageProp(safeBottom) safeBottom: number 0; State content: string ; State activeTopic: number 0; State moodIdx: number 2; private topics: string[] [深夜emo, 学习焦虑, 人际关系, 青春疼痛, 治愈瞬间]; private moods: string[] [, , , , ];状态划分有讲究状态装饰器为什么safeTop/safeBottomStorageProp全局只读Ability 写入单向订阅即可contentState受控输入每次onChange都触发重绘与计数刷新activeTopicState单选索引驱动胶囊反色moodIdxState单选索引驱动字号与透明度topics/moods无装饰器静态词表不参与响应式声明为private常量语义moodIdx默认值是2 中性不是 0。默认值即产品态度不预设用户难过也不强制选择——用户一个字不改也能直接投稿心情只是可选的情感标注。两个词表是跨页共享的稳定标识topics与首页hotTopics、话题页前 5 个话题完全同名同序见 三话题。所以投稿时存的是话题名而不是索引落库后首页的# 标签、话题页的count聚合才能对得上。三、页面骨架五区纵向流 提交分支build() { Column() { this.Header() Scroll() { Column({ space: 16 }) { this.MoodCard() this.EditCard() this.TopicCard() Button(投入树洞).fontSize(15).fontColor(#FFFFFF).backgroundColor(C.primary) .width(100%).height(48).borderRadius(D.rMd) .onClick(() { promptAction.showToast({ message: this.content.length 0 ? 投稿成功 : 请输入内容 }); }) } .width(100%) .padding({ left: D.pad, right: D.pad, top: 16, bottom: D.pad this.safeBottom 20 }) } .layoutWeight(1).scrollBar(BarState.Off).align(Alignment.Top) } .width(100%).height(100%).backgroundColor(C.bg) }骨架与首页同构Header在Scroll外 layoutWeight(1)差别在内容组织心情 → 编辑 → 话题 → 按钮情绪先行。这个顺序本身就是设计先问你今天心情如何用户进入倾诉状态后再写正文最后才归类和提交。三张卡片共用同一套容器样式白底 /rLg圆角 20 /stroke描边 / padding 16视觉上形成三步式引导节奏比显式分步器更轻。提交按钮的分支校验只有一行this.content.length 0 ? 投稿成功 : 请输入内容它能拦住空内容但拦不住全空格、超长、重复点击——这三件事在第七节补齐。四、MoodCard用字号和透明度代替边框Builder MoodCard() { Column({ space: 12 }) { Text(今天心情如何).fontSize(14).fontWeight(FontWeight.Bold).fontColor(C.text).width(100%) Row() { ForEach(this.moods, (m: string, idx: number) { Column() { Text(m).fontSize(this.moodIdx idx ? 34 : 26) .opacity(this.moodIdx idx ? 1.0 : 0.4) }.layoutWeight(1).onClick(() { this.moodIdx idx; }) }, (m: string, idx: number) m idx) }.width(100%) } .width(100%).padding(16).backgroundColor(C.card).borderRadius(D.rLg).border({ width: 1, color: C.stroke }) }四个可迁移的技术点双通道表达选中态——fontSize(34 vs 26)放大 opacity(1.0 vs 0.4)提亮。emoji 本身有颜色加边框或换底色都会显脏改字号和透明度是 emoji 选择器最干净的方案。layoutWeight(1)五等分—— 五个子Column各占 1/5 行宽点击热区自动均分且铺满整行新增一个心情不用改布局。key 写成(m, idx) m idx—— 因为moods是string[]纯 emoji 作为 key 在重复时会冲突拼上索引保证唯一。注意这是静态不重排场景下的权宜写法一旦数组会增删或排序基于 index 的 key 会导致节点错误复用届时换成 id。onClick挂在外层Column—— 点击热区是整个 1/5 格而不是 emoji 字形本身。演进固定行高 文字标签。字号从 26 跳到 34 会让行高轻微抖动补一个固定高度即可同时给 emoji 加文字标签兼顾可读性与无障碍interface Mood { emoji: string; label: string; } private moods: Mood[] [ { emoji: , label: 很难过 }, { emoji: , label: 低落 }, { emoji: , label: 一般 }, { emoji: , label: 不错 }, { emoji: , label: 超棒 } ]; Builder MoodItem(m: Mood, idx: number) { Column({ space: 4 }) { Text(m.emoji) .fontSize(this.moodIdx idx ? 34 : 26) .opacity(this.moodIdx idx ? 1.0 : 0.4) if (this.moodIdx idx) { Text(m.label).fontSize(11).fontColor(C.primary) } } .height(56) // 固定行高切换时零抖动 .layoutWeight(1) .justifyContent(FlexAlign.Center) .onClick(() { this.moodIdx idx; }) }height(56)锁死单元格高度放大动画不再带动布局选中时才渲染文字标签if条件渲染未选中保持纯 emoji 的清爽。五、EditCard透明输入框与常驻的安全承诺Builder EditCard() { Column({ space: 10 }) { TextArea({ placeholder: 这里很安全说出你的心里话... }) .backgroundColor(Color.Transparent).borderRadius(0).height(130) .onChange((v: string) { this.content v; }) Row() { Text( 完全匿名).fontSize(12).fontColor(C.ok) Blank() Text(this.content.length /1000).fontSize(12).fontColor(C.textDim) }.width(100%) } .width(100%).padding(16).backgroundColor(C.card).borderRadius(D.rLg).border({ width: 1, color: C.stroke }) }沉浸式输入backgroundColor(Color.Transparent)borderRadius(0)把TextArea的默认输入框外观抹平让它看起来就是卡片的一部分height(130)给足长文空间。占位文案即产品承诺「这里很安全说出你的心里话...」——树洞用户的第一顾虑是隐私文案直接回应它。 完全匿名常驻绿色C.ok 锁图标与输入区同屏。注意它是常驻文字而非开关——树洞的匿名是无条件默认不给用户忘了开匿名的机会。Blank()两端对齐左安全承诺、右字数计数一行塞进两个不同语义的信息。演进把上限从显示变成约束。现在/1000只是显示超长照样能输入。改成在onChange里截断 计数分级变色private static readonly MAX_LEN: number 1000; .onChange((v: string) { if (v.length MAX_LEN) { this.content v.substring(0, MAX_LEN); // 硬截断保证数据侧永不越界 promptAction.showToast({ message: 最多 MAX_LEN 个字 }); return; } this.content v; }) // 计数颜色900 以下灰900~1000 橙到顶红 private countColor(): string { const n: number this.content.length; if (n MAX_LEN) { return C.danger; } if (n MAX_LEN * 0.9) { return C.warn; } return C.textDim; }截断放在onChange而不是提交时校验好处是状态永远合法content.length 1000是不变量后续任何逻辑都不用再防御。六、TopicCardFlex 换行的单选胶囊Builder TopicCard() { Column({ space: 10 }) { Text(选择话题).fontSize(14).fontWeight(FontWeight.Bold).fontColor(C.text).width(100%) Flex({ wrap: FlexWrap.Wrap }) { ForEach(this.topics, (t: string, idx: number) { Text(# t).fontSize(13) .fontColor(this.activeTopic idx ? #FFFFFF : C.textSub) .padding({ left: 14, right: 14, top: 7, bottom: 7 }) .backgroundColor(this.activeTopic idx ? C.primary : C.cardSoft).borderRadius(16) .margin({ right: 10, bottom: 10 }) .onClick(() { this.activeTopic idx; }) }, (t: string) t) }.width(100%) } .width(100%).padding(16).backgroundColor(C.card).borderRadius(D.rLg).border({ width: 1, color: C.stroke }) }为什么用Flex而不是Row五个胶囊总宽超过屏宽Row会溢出或挤压Flex({ wrap: FlexWrap.Wrap })自动折行实测 3 2 两行话题数量变化时不用改布局。margin({ right: 10, bottom: 10 })而非space折行后行间距由bottom提供这是 Flex 换行场景的标准做法。选中态是紫底白字反色与首页横滑胶囊的浅紫底紫字形成层级差——这里是可切换的表单控件那里是可点的浏览入口。key 用(t: string) t话题名天然唯一比索引更稳顺序变了也能正确复用节点。演进把单选胶囊抽象成受控组件。首页的横滑过滤、话题页的分类切换都会用到同一套交互// components/ChipGroup.ets Component export struct ChipGroup { items: string[] []; Link active: number; // 双向绑定选中索引 onPick: (name: string) void () {}; build() { Flex({ wrap: FlexWrap.Wrap }) { ForEach(this.items, (t: string, idx: number) { Text(# t).fontSize(13) .fontColor(this.active idx ? #FFFFFF : C.textSub) .padding({ left: 14, right: 14, top: 7, bottom: 7 }) .backgroundColor(this.active idx ? C.primary : C.cardSoft) .borderRadius(16) .margin({ right: 10, bottom: 10 }) .onClick(() { this.active idx; this.onPick(t); }) }, (t: string) t) }.width(100%) } }Link让选中索引回写父组件的State父页面只写一行ChipGroup({ items: this.topics, active: $activeTopic, onPick: (t: string) this.topic t })七、从 Toast 到真实提交校验、防重、跨页同步Demo 的按钮只弹 Toast。要让它真正产生一条树洞补齐四件事// Func1Tab.ets —— 完整提交流程 State submitting: boolean false; private canSubmit(): boolean { return this.content.trim().length 0 !this.submitting; } private submit(): void { if (!this.canSubmit()) { promptAction.showToast({ message: 请输入内容 }); return; } const risk: string riskOf(this.content); // 匿名保护联系方式预检 if (risk ! ) { promptAction.showToast({ message: risk }); return; } this.submitting true; const topic: string this.topics[this.activeTopic]; const mood: string this.moods[this.moodIdx]; HoleStore.publish(this.content.trim(), topic, mood); // 写入仓库首篇第十节 HoleStore.bump(); // 版本号 1首页/我的页重绘 this.content ; // 清空表单避免重复提交 this.moodIdx 2; this.activeTopic 0; this.submitting false; promptAction.showToast({ message: 已投入树洞 }); AppStorage.setOrCreate(tabIndex, 0); // 跳回首页看结果 }配套的按钮与跳转改造Button(this.submitting ? 投入中... : 投入树洞) .fontSize(15).fontColor(#FFFFFF) .backgroundColor(this.submitting ? C.textDim : C.primary) .width(100%).height(48).borderRadius(D.rMd) .enabled(!this.submitting) // 防重复点击 .onClick(() { this.submit(); }) // Index.ets —— 让 Tab 索引可被外部驱动 StorageLink(tabIndex) current: number 0;四个环节的必要性环节不做会怎样做法前置校验空内容/全空格生成脏数据content.trim().length 0匿名保护用户写出手机号匿名承诺失效本地正则预检 服务端审核防重复提交网络慢时连点生成多条submitting标志 enabled(false)跨页同步投稿后首页看不到新内容HoleStore.bump()AppStorage版本号其中tabIndex用StorageLink而非State是为了让投稿页能反向驱动Tabs切换这也是首页篇提到的全局只读用StorageProp、需要双向写入用StorageLink的具体落点。匿名不是什么都不做而是要做额外的事。正则预检示例生产环境应下沉到服务端// common/Safety.ets const PHONE_RE: RegExp /1[3-9]\d{9}/; const QQ_RE: RegExp /[Qq]{2}[^0-9]{0,4}[1-9][0-9]{5,10}/; const WX_RE: RegExp /[微薇][信][^0-9a-zA-Z]{0,4}[a-zA-Z][-_a-zA-Z0-9]{5,19}/; export function riskOf(text: string): string { if (PHONE_RE.test(text)) { return 内容包含手机号会破坏匿名; } if (QQ_RE.test(text)) { return 内容包含 QQ 号请删除后投稿; } if (WX_RE.test(text)) { return 内容包含微信号请删除后投稿; } return ; }客户端预检的价值是即时反馈避免用户写完一千字才被服务端拒绝但它只是第一道防线真实审核必须在服务端且检测结果不应上传原文。八、心情字段的下游价值moodIdx只有 0~4 五个取值却是最有价值的结构化数据下游用法依赖首页卡片话题标签旁加心情角标 Hole.mood话题页「最需要安慰的话题」 该话题下 / 占比按topicmood二维聚合我的页情绪曲线15 次投稿的心情按时间连线按minets排序运营侧连续多日 触发关怀推送服务端聚合匿名处理关键在于投稿时必须把心情落进Hole.mood否则事后无法补。这也是为什么HoleStore.publish()的签名是(content, topic, mood)三元组——生产端一次采集四个页面共享。九、实机验证1. 首屏心情卡 为 34 号全实其余 26 号半透明 编辑卡透明TextArea 绿色标识 0/1000 话题卡# 深夜emo 紫底选中。2. 点 moodIdx由 2 变 4 放大到 34 号且透明度变为 1.0原选中项 缩小变淡双通道选中态即时重绘。3. 空内容点提交content.length 0为 false走校验分支弹出「请输入内容」脏数据被拦下。4. 完整视图心情卡、编辑卡、话题卡、提交按钮四区在单屏内完整呈现底部安全区留白足够。验收清单项验证方式结果心情双通道选中点最右 字号 26→34、透明度 0.4→1.0五等分热区点击格子空白处命中对应 emoji字数计数联动输入任意字符0/1000实时更新空内容拦截直接点提交Toast「请输入内容」Flex 折行5 个话题3 2 两行间距均匀十、可演进方向方向现状改法字数约束仅显示onChange硬截断 分级变色表单校验空判断trim() 风险预检 防重复提交提交结果Toastpublish()bump() 跳回首页心情可用性仅存索引落库Hole.mood下游可做情绪曲线草稿无退出时写Preferences回来自动恢复键盘遮挡无适配expandSafeArea 提交按钮置底十一、本篇小结投稿页的核心不是三个卡片而是三个State到一条Hole的收敛content受控输入并硬截断、activeTopic与moodIdx用索引驱动单选、提交时一次性组装publish(content, topic, mood)。补齐校验、防重与跨页同步后Toast 就换成了真实的数据流闭环。下一篇 三话题 讲目录端Grid两列网格的列数算术以及 6 个话题的count如何从硬编码变成按topic聚合出来的实时值。
返回列表