From 198761677c51eede2b52ff8ec5b5cb96b106dfae Mon Sep 17 00:00:00 2001 From: xiangsilian Date: Sat, 15 Aug 2026 00:40:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(app):=20app=20shell=20tabs=20with=20home?= =?UTF-8?q?=20entries=20and=20common=20loading/error=20views=EF=BC=88F2?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/ets/components/f2/ErrorView.ets | 67 +++++ .../main/ets/components/f2/LoadingView.ets | 31 +++ app/entry/src/main/ets/pages/HomePage.ets | 241 +++++++++++++++++- 3 files changed, 334 insertions(+), 5 deletions(-) create mode 100644 app/entry/src/main/ets/components/f2/ErrorView.ets create mode 100644 app/entry/src/main/ets/components/f2/LoadingView.ets diff --git a/app/entry/src/main/ets/components/f2/ErrorView.ets b/app/entry/src/main/ets/components/f2/ErrorView.ets new file mode 100644 index 0000000..baf603b --- /dev/null +++ b/app/entry/src/main/ets/components/f2/ErrorView.ets @@ -0,0 +1,67 @@ +/** + * ErrorView —— 通用失败态组件(F2)。 + * + * NFR-UX-02「失败必有原因 + 下一步指引」的落地组件: + * - message:失败原因(对应 ApiError.message) + * - hint:下一步指引(对应 ApiError.hint,可空则不渲染) + * - onRetry:可选「重试」回调,不传则不渲染按钮 + * 其它 FE 任务可直接 import 本组件复用(目录带 f2 前缀,迁移由协调者做)。 + */ + +const TEXT_PRIMARY: string = '#222222' +const TEXT_SECONDARY: string = '#8A9099' +const WARN: string = '#FF9F0A' +const WARN_BG: string = '#FFF4E0' +const ACCENT: string = '#FF5A79' + +@Component +export struct ErrorView { + @Prop message: string = '请求失败,请检查网络' + @Prop hint: string = '' + @Prop retryText: string = '重试' + onRetry?: () => void + + build() { + Column({ space: 10 }) { + Text('!') + .fontSize(26) + .fontWeight(FontWeight.Bold) + .fontColor(WARN) + .width(56) + .height(56) + .textAlign(TextAlign.Center) + .borderRadius(28) + .backgroundColor(WARN_BG) + Text(this.message) + .fontSize(15) + .fontWeight(FontWeight.Medium) + .fontColor(TEXT_PRIMARY) + .textAlign(TextAlign.Center) + if (this.hint !== '') { + Text(this.hint) + .fontSize(13) + .fontColor(TEXT_SECONDARY) + .textAlign(TextAlign.Center) + } + if (this.onRetry !== undefined) { + Button(this.retryText) + .fontSize(15) + .fontColor(Color.White) + .backgroundColor(ACCENT) + .borderRadius(20) + .height(40) + .padding({ left: 32, right: 32 }) + .margin({ top: 6 }) + .onClick(() => { + if (this.onRetry !== undefined) { + this.onRetry() + } + }) + } + } + .width('100%') + .height('100%') + .justifyContent(FlexAlign.Center) + .padding({ left: 32, right: 32 }) + } +} diff --git a/app/entry/src/main/ets/components/f2/LoadingView.ets b/app/entry/src/main/ets/components/f2/LoadingView.ets new file mode 100644 index 0000000..e27b20e --- /dev/null +++ b/app/entry/src/main/ets/components/f2/LoadingView.ets @@ -0,0 +1,31 @@ +/** + * LoadingView —— 通用加载态组件(F2)。 + * + * 居中 LoadingProgress + 可选文案;text 传空串时只显示转圈。 + * props 一律 @Prop(父传子单向);颜色硬编码(resources 本批禁改,i18n 后置)。 + */ + +const ACCENT: string = '#FF5A79' +const TEXT_SECONDARY: string = '#8A9099' + +@Component +export struct LoadingView { + @Prop text: string = '加载中…' + + build() { + Column({ space: 12 }) { + LoadingProgress() + .width(44) + .height(44) + .color(ACCENT) + if (this.text !== '') { + Text(this.text) + .fontSize(14) + .fontColor(TEXT_SECONDARY) + } + } + .width('100%') + .height('100%') + .justifyContent(FlexAlign.Center) + } +} diff --git a/app/entry/src/main/ets/pages/HomePage.ets b/app/entry/src/main/ets/pages/HomePage.ets index 7c655fa..7e140e0 100644 --- a/app/entry/src/main/ets/pages/HomePage.ets +++ b/app/entry/src/main/ets/pages/HomePage.ets @@ -1,17 +1,248 @@ /** - * HomePage —— 占位桩(FE 批次对应任务填充实现)。 - * 已注册进 main_pages.json,其它页面可按路径跳转。 + * HomePage —— 应用主壳:底部四 Tab(首页/报告/AI/我的)+ 首页内容。 + * + * 协作铁律(FE 批次 README §2/§3): + * - 页面跳转一律 router.pushUrl({ url: 'pages/XxxPage' }),禁止 import 其它页面文件; + * Tab2/3/4 本批用「占位 + 跳页」方案(目标页面是独立路由页,由对应任务实现) + * - 请求只走 ApiClient;今日额度失败静默降级为 '—'(首页不打扰用户,不弹错误态) + * - 响应 wire 类型用具名 interface(字段对齐后端 JSON);泛型调用显式类型参数 */ +import { router } from '@kit.ArkUI' +import { ApiClient } from '../core/ApiClient' + +/** GET /api/v1/quota?quota_key=agent_chat 的响应(字段名对齐后端 JSON)。 */ +interface QuotaWireResp { + quota_key: string + used: number + limit_value: number + bonus_granted: boolean + remaining: number + reset_at: string +} + +const PAGE_BG: string = '#F5F6F8' +const ACCENT: string = '#FF5A79' +const ACCENT_AI: string = '#6C5CE7' +const TEXT_PRIMARY: string = '#222222' +const TEXT_SECONDARY: string = '#8A9099' +const TEXT_DISABLED: string = '#C2C7CE' +const QUOTA_PATH: string = '/api/v1/quota?quota_key=agent_chat' +const WEEKDAYS: string[] = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'] + @Entry @Component struct HomePage { - build() { - Column() { - Text('HomePage(建设中)').fontSize(16).fontColor('#666666') + @State currentTabIndex: number = 0 + @State dateLine: string = '' + @State quotaRemaining: string = '…' + + aboutToAppear(): void { + this.dateLine = this.buildDateLine() + this.loadQuota() + } + + /** 返回键:不拦截(返回 false 走系统默认)。Tab 壳是应用首页,再按返回即退出应用——本批不加复杂逻辑。 */ + onBackPress(): boolean { + return false + } + + /** 问候区日期行,如「2026年8月15日 周六」。 */ + private buildDateLine(): string { + const now = new Date() + return `${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日 ${WEEKDAYS[now.getDay()]}` + } + + /** 拉今日 AI 对话额度;失败静默降级为 '—'(首页额度只是辅助信息,不值得打断用户)。 */ + private async loadQuota(): Promise { + try { + const data = await ApiClient.get(QUOTA_PATH) + this.quotaRemaining = `${data.remaining}` + } catch (_e) { + this.quotaRemaining = '—' + } + } + + /** 底部 Tab 栏项:简单 Text 字形 + 中文标签(本批不引资源)。 */ + @Builder + TabBarItem(icon: string, label: string, index: number) { + Column({ space: 3 }) { + Text(icon) + .fontSize(20) + .fontColor(this.currentTabIndex === index ? ACCENT : TEXT_SECONDARY) + Text(label) + .fontSize(11) + .fontColor(this.currentTabIndex === index ? ACCENT : TEXT_SECONDARY) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) } + + /** 首页功能入口卡:图标块 + 标题/副标题 + 右箭头,整卡点击跳 targetUrl。 */ + @Builder + EntryCard(title: string, subtitle: string, icon: string, color: string, targetUrl: string) { + Row({ space: 12 }) { + Text(icon) + .fontSize(20) + .fontColor(Color.White) + .width(44) + .height(44) + .textAlign(TextAlign.Center) + .borderRadius(12) + .backgroundColor(color) + Column({ space: 4 }) { + Text(title) + .fontSize(16) + .fontWeight(FontWeight.Medium) + .fontColor(TEXT_PRIMARY) + Text(subtitle) + .fontSize(12) + .fontColor(TEXT_SECONDARY) + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + Text('›') + .fontSize(22) + .fontColor(TEXT_DISABLED) + } + .width('100%') + .padding(14) + .borderRadius(16) + .backgroundColor(Color.White) + .alignItems(VerticalAlign.Center) + .onClick(() => { + router.pushUrl({ url: targetUrl }) + }) + } + + /** 今日额度小卡:显示「AI 对话剩余 N 次」,失败静默显示 '—'。 */ + @Builder + QuotaCard() { + Row() { + Column({ space: 4 }) { + Text('今日额度') + .fontSize(12) + .fontColor(TEXT_SECONDARY) + Text(`AI 对话剩余 ${this.quotaRemaining} 次`) + .fontSize(16) + .fontWeight(FontWeight.Medium) + .fontColor(TEXT_PRIMARY) + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + Text('每日刷新') + .fontSize(11) + .fontColor(TEXT_DISABLED) + .padding({ top: 4, bottom: 4, left: 10, right: 10 }) + .borderRadius(10) + .backgroundColor(PAGE_BG) + } + .width('100%') + .padding(14) + .borderRadius(16) + .backgroundColor(Color.White) + .alignItems(VerticalAlign.Center) + } + + /** Tab1 首页:问候区 + 两张功能入口卡 + 今日额度小卡。 */ + @Builder + HomeTab() { + Scroll() { + Column({ space: 12 }) { + Column({ space: 6 }) { + Text('你好,美搭') + .fontSize(26) + .fontWeight(FontWeight.Bold) + .fontColor(TEXT_PRIMARY) + Text(this.dateLine) + .fontSize(13) + .fontColor(TEXT_SECONDARY) + } + .width('100%') + .alignItems(HorizontalAlign.Start) + .margin({ top: 8 }) + + this.EntryCard('生成我的形象报告', '体型 · 体态 · 穿搭建议,一次看全', '▣', ACCENT, 'pages/ReportCreatePage') + this.EntryCard('AI 穿搭顾问', '对话式穿搭建议,随时问', '✦', ACCENT_AI, 'pages/ChatPage') + this.QuotaCard() + } + .width('100%') + .padding(16) + } + .width('100%') + .height('100%') + .align(Alignment.Top) + .scrollBar(BarState.Off) + .edgeEffect(EdgeEffect.Spring) + } + + /** Tab2/3/4 占位页:说明文案 + 居中按钮跳转对应真页面(独立路由页)。 */ + @Builder + PlaceholderTab(icon: string, title: string, desc: string, buttonText: string, targetUrl: string) { + Column({ space: 10 }) { + Text(icon) + .fontSize(44) + .fontColor(TEXT_DISABLED) + Text(title) + .fontSize(18) + .fontWeight(FontWeight.Medium) + .fontColor(TEXT_PRIMARY) + Text(desc) + .fontSize(13) + .fontColor(TEXT_SECONDARY) + .textAlign(TextAlign.Center) + Button(buttonText) + .fontSize(15) + .fontColor(Color.White) + .backgroundColor(ACCENT) + .borderRadius(22) + .height(44) + .padding({ left: 28, right: 28 }) + .margin({ top: 10 }) + .onClick(() => { + router.pushUrl({ url: targetUrl }) + }) + } + .width('100%') + .height('100%') + .justifyContent(FlexAlign.Center) + .padding({ left: 32, right: 32 }) + } + + build() { + Column() { + Tabs({ barPosition: BarPosition.End }) { + TabContent() { + this.HomeTab() + } + .tabBar(this.TabBarItem('⌂', '首页', 0)) + + TabContent() { + this.PlaceholderTab('▤', '报告', '形象报告的查看与管理在这里(建设中)\n新建报告请前往报告创建页', '去生成报告', 'pages/ReportCreatePage') + } + .tabBar(this.TabBarItem('▤', '报告', 1)) + + TabContent() { + this.PlaceholderTab('✦', 'AI 顾问', '与 AI 穿搭顾问的对话在独立页面进行(建设中)', '进入 AI 对话', 'pages/ChatPage') + } + .tabBar(this.TabBarItem('✦', 'AI', 2)) + + TabContent() { + this.PlaceholderTab('☺', '我的', '个人资料与设置(建设中)', '去我的资料', 'pages/ProfilePage') + } + .tabBar(this.TabBarItem('☺', '我的', 3)) + } + .scrollable(true) + .barMode(BarMode.Fixed) + .vertical(false) + .divider({ strokeWidth: 0.5, color: '#E5E7EB' }) + .onChange((index: number) => { + this.currentTabIndex = index + }) + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + } }