☰
HarmonyOS 应用实战:校园树洞投稿(二)投稿——心情选择与匿名表单的状态编排
2026/10/2 12:53:13 网站建设 项目流程

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

项目编号:42-treehole-post 技术栈:HarmonyOS ArkTS · ArkUI 声明式 UI 本篇页面:投稿Func1Tab.ets(94 行) 系列导航:(一)首页 ·(二)投稿· (三)话题 · (四)我的 源码开源: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/safeBottom@StorageProp全局只读,Ability 写入,单向订阅即可
content@State受控输入,每次onChange都触发重绘与计数刷新
activeTopic@State单选索引,驱动胶囊反色
moodIdx@State单选索引,驱动字号与透明度
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 }) }

四个可迁移的技术点:

  1. 双通道表达选中态——fontSize(34 vs 26)放大 +opacity(1.0 vs 0.4)提亮。emoji 本身有颜色,加边框或换底色都会显脏,改字号和透明度是 emoji 选择器最干净的方案。
  2. layoutWeight(1)五等分—— 五个子Column各占 1/5 行宽,点击热区自动均分且铺满整行;新增一个心情不用改布局。
  3. key 写成(m, idx) => m + idx—— 因为moods是string[],纯 emoji 作为 key 在重复时会冲突,拼上索引保证唯一。注意这是静态不重排场景下的权宜写法:一旦数组会增删或排序,基于 index 的 key 会导致节点错误复用,届时换成 id。
  4. 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 }) }
  1. 沉浸式输入:backgroundColor(Color.Transparent)+borderRadius(0)把TextArea的默认输入框外观抹平,让它看起来就是卡片的一部分;height(130)给足长文空间。
  2. 占位文案即产品承诺:「这里很安全,说出你的心里话...」——树洞用户的第一顾虑是隐私,文案直接回应它。
  3. 🔒 完全匿名常驻:绿色C.ok+ 锁图标,与输入区同屏。注意它是常驻文字而非开关——树洞的匿名是无条件默认,不给用户"忘了开匿名"的机会。
  4. 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是不变量,后续任何逻辑都不用再防御。

六、TopicCard:Flex 换行的单选胶囊

@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
话题页「最需要安慰的话题」= 该话题下 😢/😔 占比按topic+mood二维聚合
我的页情绪曲线:15 次投稿的心情按时间连线按mine+ts排序
运营侧连续多日 😢 触发关怀推送服务端聚合,匿名处理

关键在于:投稿时必须把心情落进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聚合出来的实时值。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询