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 }) }四个可迁移的技术点:
- 双通道表达选中态——
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是不变量,后续任何逻辑都不用再防御。
六、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()+ 风险预检 + 防重复提交 |
| 提交结果 | Toast | publish()+bump()+ 跳回首页 |
| 心情可用性 | 仅存索引 | 落库Hole.mood,下游可做情绪曲线 |
| 草稿 | 无 | 退出时写Preferences,回来自动恢复 |
| 键盘遮挡 | 无适配 | expandSafeArea+ 提交按钮置底 |
十一、本篇小结
投稿页的核心不是三个卡片,而是三个@State到一条Hole的收敛:content受控输入并硬截断、activeTopic与moodIdx用索引驱动单选、提交时一次性组装publish(content, topic, mood)。补齐校验、防重与跨页同步后,Toast 就换成了真实的数据流闭环。
下一篇 (三)话题 讲目录端:Grid两列网格的列数算术,以及 6 个话题的count如何从硬编码变成按topic聚合出来的实时值。