feat(app): app shell tabs with home entries and common loading/error views(F2)

This commit is contained in:
xsl
2026-08-15 00:40:53 +08:00
parent 9503c6e259
commit 198761677c
3 changed files with 334 additions and 5 deletions
@@ -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 })
}
}
@@ -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)
}
}
+236 -5
View File
@@ -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<void> {
try {
const data = await ApiClient.get<QuotaWireResp>(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)
}
}