Skip to content

首页标签页 — 实现规格 v4

日期: 2026-04-13 状态: 已实现 文件: app/(tabs)/index.tsx替代: v3 规格(living-card 和 completed 重新设计之前)

设计理念

"网站售卖愿景;移动应用培养习惯。"

首页面向回访用户优化,而非首次访客。输入区域在拇指可触范围内,每日习惯钩子(每日一牌)始终可见,读牌师选择器让用户无需离开当前页面即可切换角色。

色彩方案(浅色模式)

Token用途
screenBg#FFFAF3背景渐变起始色
screenGradient['#FFFAF3', '#FFF8EE', '#FFF7E8']全屏背景渐变
textPrimary#4F3E31标题、活跃读牌师名称
textSecondary#7A6548副文本、元信息
textMuted#A28E7A非活跃标签、眉标签
placeholder#9E8B78输入框占位符
cardBg#FFFFFF输入栏、卡片
pillBg#F3EDE4分类胶囊(非活跃)
tabBarBgrgba(255,250,243,0.92)标签栏背景
tabInactive#9E8E7E非活跃标签图标
headerBg#FFFAF3固定顶栏
Brand.gold#D4A843活跃圆环、CTA 渐变、强调色

区域布局(从上至下)

1. 固定顶栏

绝对定位,固定在顶部,带模糊效果和渐变淡出。

  • 背景: BlurView(intensity 60,tint systemChromeMaterialLight)+ LinearGradientrgba(255,250,243,0.9) 到 transparent)。透明度随滚动动画 — 滚动超过 12px 阈值后渐入。
  • 内边距: paddingTop: insets.top(安全区域)
  • 高度: 44px 内容区域
  • 左侧: MysticX logo(logo-and-text.png),100x24,opacity: 0.45
  • 右侧(从左到右):
    • 开发重置按钮(仅开发环境,EXPO_PUBLIC_APP_ENV === 'development'):30x30 圆形,rgba(0,0,0,0.06) 背景,刷新图标 16px #8B7355。将 cardOfDayStatus 重置为 { completed: false } 用于测试。
    • CardOfDayBadge(金色胶囊,完成后隐藏)
    • CreditBadge(余额显示)
  • 动画: FadeIn.duration(300)

背景氛围

所有内容后方有两个背景层:

  1. 屏幕渐变: LinearGradient ['#FFFAF3', '#FFF8EE', '#FFF7E8'] 对角填充
  2. 暖色顶部光晕: LinearGradient 400px 高,固定在顶部,rgba(240,216,166,0.25)rgba(240,216,166,0.08)transparent,位置 [0, 0.35, 0.7]。在屏幕顶部营造温暖的金色色调。

2. HomeGreeting

居中,根据时间变化,个性化问候。

  • 内边距: paddingTop: 32paddingBottom: 32paddingHorizontal: 20
  • 问候文本: Fonts.serif,22px,lineHeight: 30fontWeight: '600'#4F3E31letterSpacing: -0.3
  • 副文本: 14px,lineHeight: 20#7A6548
  • 动画: FadeInDown.duration(500).delay(100)
  • 对齐: 居中

时间段(设备本地时间,通过 new Date().getHours()):

  • morning(0-11):"Good morning" / "What needs clarity today?"
  • afternoon(12-17):"Good afternoon" / "What needs clarity today?"
  • evening(18-21):"Good evening" / "What's on your mind tonight?"
  • night(22-23):"Good evening" / "What's on your mind tonight?"

问候语使用按语言完整本地化的字符串 — 不拼接 "{greeting}, {name}",因为名字位置因语言而异(例如日语:${name}さん、おはようございます)。两套翻译:带名字(已登录)和不带名字(访客)。需支持全部 12 个语言。

3. ReaderSelector — 快速切换读牌师

v3 新增。 水平头像行,用于在首页直接切换活跃的塔罗读牌师。

  • 布局: EyebrowLabel "YOUR READER" + 水平 ScrollView
  • 顶部外边距: 4(距问候语)
  • 间距: 区域:10,头像:16
  • 头像尺寸: 48x48 圆形
  • 活跃圆环: 3px Brand.gold 边框
  • 非活跃圆环: 3px rgba(180,165,145,0.35) 边框
  • 活跃名称: #4F3E31fontWeight: '600'
  • 非活跃名称: #A28E7A
  • 名称字体: 11px,lineHeight: 14,居中对齐,numberOfLines: 1
  • "+" 按钮: 48x48 虚线圆形(borderStyle: 'dashed'#D4CBB8),add 图标
  • "+" 标签: "More"(国际化,12 种语言)
  • 点击操作: 调用 activateTarotReader(reader.id),乐观更新 UI
  • "+" 点击: router.push('/market/readers')
  • 动画: FadeInUp.delay(150).duration(400)
  • 备用头像: #E8DFD3 圆形 + person 图标
  • 数据源: fetchTarotReaders(locale),过滤为 r.owned === true

乐观更新: 点击后立即在本地状态中将选中的读牌师设为 isActive。API 失败时回退到之前的活跃读牌师。

EyebrowLabel(来自 decorative.tsx):11px 大写,letterSpacing: 1.5,颜色 #A28E7A

4. CompactInput — 问题输入 + 语音 + 提交

ChatGPT 风格的多行输入栏,带麦克风按钮和提交箭头。

  • 顶部外边距: 16
  • 边框: borderRadius: 24,1px borderSubtlecardBg 背景
  • 内边距: 18px 水平,17px 顶部,12px 底部
  • 文本: 16px,lineHeight: 22#4F3E31,占位符 #9E8B78
  • 输入高度: minHeight: 44maxHeight: 96
  • 底部行: justifyContent: 'space-between' — 麦克风(左),提交(右)
  • 阴影: iOS:shadowOpacity: 0.06shadowRadius: 8;Android:elevation: 2
  • 最大长度: 1000 字符
  • 动画: FadeInUp.delay(250).duration(500)

麦克风按钮(通过 expo-speech-recognition 实现语音输入):

  • 尺寸: 42x42 圆形
  • 图标: mic-none(空闲)/ mic(聆听中)
  • 颜色: #A28E7A(空闲)/ Brand.gold(聆听中)
  • 背景: transparent(空闲)/ rgba(212,168,67,0.15)(聆听中)
  • 交互: 长按:onPressIn 开始识别,onPressOut 停止
  • 权限: 首次按下通过 getPermissionsAsync() 检查。如未授权,请求权限并提前返回 — 用户再次按下以启动。包裹在 try/catch 中以兼容 Expo Go / 不支持的设备。
  • 语言: 通过 SPEECH_LOCALE_MAP 将应用语言映射到 BCP-47(例如 zh_CNzh-CNenen-US
  • 配置: interimResults: truecontinuous: falseaddsPunctuation: true
  • 结果: 转写文本通过 onChangeQuestion 替换输入文本

提交按钮: 42x42 圆形,#E5B86A 背景,arrow-upward 图标为 #5A4126。触发解读流程:reset() 然后 setContextQuestion(text) 然后 router.push('/reading/select-cards')

占位符轮播: 每 5 秒从 sampleQuestions 中循环。回退为静态 "What guidance do you seek today?"(国际化)。

5. CategoryTabs — 意图筛选胶囊

水平可滚动胶囊。6 个分类。

  • 分类: Love,Career,Healing,Decisions,Study,Wealth
  • 活跃胶囊: LinearGradient ['#F0D8A6', '#F0C67C'],无边框
  • 非活跃: pillBg 背景,1px borderSubtle 边框
  • 图标: MaterialCommunityIcons,15px
  • 标签: 13px,lineHeight: 18fontWeight: '600'
  • 间距: 8
  • 内边距: paddingLeft: 20paddingRight: 28
  • 顶部外边距: 14
  • 动画: FadeInUp.delay(300).duration(400)
  • 点击操作: 获取所选分类的新示例问题

每个分类映射到一个 categoryId API 参数:love-connectioncareer-abundancehealing-growthdecisions-manifestationstudy-growthwealth-prosperity

6. QuickQuestions — 2x2 网格

  • 布局: flexWrap: 'wrap',2 列,间距 10
  • 卡片宽度: (SCREEN_WIDTH - 40 - 10) / 2
  • 卡片高度: 68px
  • 卡片样式: #F8F4EF 背景,1px #E8E1D8 边框,borderRadius: 16
  • 文本: 13px,lineHeight: 18fontWeight: '500'#5C4E40numberOfLines: 2
  • 数量: 4 个问题(从完整数组中截取)
  • 点击操作: 填充输入框并滚动到顶部
  • 顶部外边距: 12
  • 动画: FadeInUp.delay(300).duration(400)(与分类标签一致)

7. CardOfDaySection — 每日习惯 CTA

两种状态。容器:contentPad(20px 水平内边距)。

状态 A:尚未抽牌 — "Living Card"

居中布局,带装饰纹饰、动画卡背和副标题。

  • 容器: codLivingWrap — 居中,paddingTop: 16paddingBottom: 20
  • 纹饰行: ── ✦ ── 细线分隔符加金色星形。线条:32px 宽,hairlineWidthrgba(212,168,67,0.4)。星形:10px,rgba(212,168,67,0.5)
  • 标题: "Card of the Day",Fonts.serif,19px,lineHeight: 26fontWeight: '600'#4F3E31letterSpacing: 0.3
  • 卡片: LivingCard 组件(见下文),包裹在 RitualPressable 中 — 只有卡片可点击
  • 副标题: "Awaken your daily guidance",Fonts.serif 斜体,13px,#A28E7A
  • 积分文本: ✦ +{credits} credits,11px,rgba(212,168,67,0.7)
  • 点击: router.push('/card-of-day')

LivingCard — 3D 环绕动画,创造"活的、会呼吸的"卡背效果:

  • 卡片尺寸: LIVING_CARD_WIDTH = 110,高度 = 110 / 0.6 = ~183px
  • 3D 环绕倾斜: perspective: 800rotateXrotateYORBIT_DURATION: 4800ms 动画但相位偏移 90°,形成环形轨道,如手指沿卡片边缘划过
    • ORBIT_TILT: 4° 最大倾斜角
    • rotateX:序列 0 → +4° → -4° → 0(1/4 + 1/2 + 1/4 周期)
    • rotateY:序列 +4° → -4°(反向,提前开始),形成环形路径
    • 缓动:Easing.inOut(Easing.ease) 实现平滑运动
  • 浮动: translateY ±3pt,6 秒周期(3 秒上升,3 秒下降)
  • 呼吸: scale 1.0 ↔ 1.012,5.2 秒周期(2.6 秒扩张,2.6 秒收缩)
  • 光晕: iOS 原生阴影 — shadowColor: '#D4A843'shadowOffset: {0,0}shadowOpacity: 0.35shadowRadius: 30
  • 组件: 使用 <TarotCard width={110} />(默认显示卡背)

状态 B:已抽牌 — 卡面 + 解读信息

水平行布局,展示实际卡牌图案和解读摘要。

  • 容器: codCompletedWrapflexDirection: 'row'gap: 16paddingVertical: 12
  • 卡片: <TarotCard cardIndex={entry.cardIndex} isReversed={entry.isReversed} showFront width={90} /> — 展示实际抽到的卡牌正面
    • 阴影:shadowColor: '#D4A843'shadowOffset: {0,4}shadowOpacity: 0.2shadowRadius: 12
  • 信息列(右侧,flex: 1gap: 4):
    • 头部行: "CARD OF THE DAY" 眉标签(Brand.gold,11px,letterSpacing: 1.5,大写,fontWeight: '700')+ check-circle 图标(14px,Brand.emerald
    • 卡牌名称: Fonts.serif,20px,lineHeight: 26fontWeight: '600'#4F3E31
    • 关键词: 以 · 连接,13px,#8B7355numberOfLines: 2
    • 情绪天气: Fonts.serif 斜体,12px,#A28E7AnumberOfLines: 3marginTop: 2。条件显示 — 仅当 entry.emotionalWeather 存在时显示。
  • 点击: 整行为 RitualPressablerouter.push('/card-of-day')

数据源: TCardOfDayEntry 包含 cardIndexcardNameisReversedkeywords: string[]emotionalWeather: stringactions: string[]questions: string[]

动画: FadeInUp.delay(400).duration(400)顶部外边距: 18

8. RecentReading — 继续上次解读

条件显示 — 仅当用户至少有 1 次解读时显示。

  • 容器: GlassCardcontentPad(20px 水平内边距)
  • 左侧强调: 3px 垂直条,Brand.goldopacity: 0.5
  • 头部: 读牌师头像(20x20 圆形)+ EyebrowLabel "Continue Reading"
  • 牌阵名称: 15px,lineHeight: 20fontWeight: '600'#4F3E31
  • 元信息: "{读牌师名称} . {时间距今}",13px,#7A6548
  • 问题: 13px,#7A6548numberOfLines: 2
  • 右侧: chevron-right,20px,#B8A99A
  • 点击: router.push('/reading/${reading.id}')(详情页)
  • 顶部外边距: 16
  • 动画: 无(折叠下方)

时间距今格式:{n}m ago{n}h ago{n}d ago — 已国际化支持全部 12 种语言。

数据: fetchReadings(1, 1, locale)readings[] 第一项。类型:TReadingListItem,包含 idquestioncreatedAtspread.namereader.namereader.avatarUrl

9. ForYouCard — 精选推荐

始终显示。硬编码 "Three Card Spread" 推荐(个性化逻辑待定)。

  • 容器: GradientBorderCard(来自 decorative.tsx),contentPad
  • 头部: EyebrowLabel "For You"
  • 标题: Fonts.serif,17px,#4F3E31
  • 描述: 13px,#7A6548
  • 视觉元素: 3 张迷你卡片(18x26,borderRadius: 4,金色调)+ "Past . Present . Future" 标签
  • CTA: "Explore Spreads",金色渐变按钮
  • 点击: reset() 然后 router.push('/reading/spreads')
  • 顶部外边距: 16
  • 动画: 无(折叠下方)

组件树

HomeScreen (default export)
+-- LinearGradient (background: screenGradient)
+-- LinearGradient (topWash: warm gold tint, 400px)
+-- Sticky Top Bar (absolute, z-index 10)
|   +-- BlurView + LinearGradient fade (scroll-driven opacity)
|   +-- Logo (logo-and-text.png)
|   +-- [IS_DEV] DevResetBtn (refresh icon, resets CoD state)
|   +-- CardOfDayBadge
|   +-- CreditBadge
+-- ScrollView
    +-- [loading] -> HomeSkeleton
    +-- [loaded] ->
        +-- HomeGreeting (time-aware, personalized)
        +-- ReaderSelector (avatar row, tap-to-switch)
        +-- CompactInput (text + mic + submit)
        +-- CategoryTabs (6 horizontal pills)
        +-- QuickQuestions (2x2 grid)
        +-- GoldRadialGlow (decorative PNG donut, shifted 200px down)
        +-- CardOfDaySection
        |   +-- [not drawn] LivingCard (3D orbit + breathing + floating)
        |   +-- [drawn] TarotCard (front-facing) + reading info
        +-- RecentReading (conditional, single card)
        +-- ForYouCard (curated recommendation)

动画编排

折叠上方最多 5 组动画。折叠下方区域渲染时无入场动画。

组件动画延迟时长
--Top BarFadeIn0ms300ms
1GreetingFadeInDown100ms500ms
2Reader SelectorFadeInUp150ms400ms
3Compact InputFadeInUp250ms500ms
4Category Tabs + QuestionsFadeInUp300ms400ms
5Card of DayFadeInUp400ms400ms
--Recent Reading----
--For You Card----

总级联时长:~0.8 秒。

数据获取

所有请求通过 useFocusEffect + Promise.all 并行获取。每个请求都有 .catch() 回退,单个失败不会阻塞其他请求。无需后端改动。

函数端点返回值
fetchCredits()GET /api/v1/app/creditsTCreditsInfo
fetchCardOfDayStatus(locale)GET /api/v1/app/card-of-day?locale={locale}TCardOfDayStatus
fetchSampleQuestions(40, locale, categoryId)GET /api/v1/app/questions/sample?count=40&...{ data: string[] }
fetchTarotReaders(locale)GET /api/v1/app/readers?locale={locale}TTarotReader[]
fetchReadings(1, 1, locale)GET /api/v1/app/readings?page=1&limit=1&...{ readings: TReadingListItem[] }
activateTarotReader(id)(点击时)POST /api/v1/app/readers/{id}/activate{ success: boolean }

读牌师过滤: API 返回所有读牌师;客户端过滤为 r.owned === true,仅在选择器中显示已拥有的读牌师。

状态变量

变量类型初始值说明
loadingbooleantrue控制骨架屏与内容切换
questionstring''用户输入文本
creditsTCreditsInfo or nullnull余额 + 等级信息
cardOfDayStatusTCardOfDayStatus or nullnull已抽牌时包含 entry
sampleQuestionsstring[][]按分类获取
activeCategorystring'love'当前选中的分类标签
readersTTarotReader[][]仅已拥有的读牌师
recentReadingTReadingListItem or nullnull最近一次解读
placeholderIndexnumber0轮播占位符
userNamestring or nullderived来自 authClient.useSession()
activeReaderTTarotReader or nullderiveduseMemo — 第一个 isActive,否则第一个已拥有

原生依赖

用途需要原生构建
expo-speech-recognition麦克风按钮的语音转文字
expo-blur固定顶栏半透明效果
expo-image优化图片加载
expo-linear-gradient背景和 CTA 渐变
react-native-reanimated入场动画 + LivingCard 3D 环绕
react-native-svg径向渐变(已安装,可用)

app.json 插件配置expo-speech-recognition):

json
[
  "expo-speech-recognition",
  {
    "microphonePermission": "MysticX needs microphone access for voice input.",
    "speechRecognitionPermission": "MysticX needs speech recognition to transcribe your voice."
  }
]

语音语言映射

将应用语言代码映射到 BCP-47 语音识别语言代码:

App LocaleSpeech Locale
enen-US
zh_CNzh-CN
zh_TWzh-TW
jaja-JP
koko-KR
ptpt-BR
eses-ES
frfr-FR
dede-DE
arar-SA
idid-ID
nlnl-NL

验收标准

  • [x] 问题输入在 iPhone 14/15/17 Pro 上折叠上方可见
  • [x] 读牌师选择器行,点击切换 + 金色活跃圆环
  • [x] 长按麦克风按钮实现语音输入(需要原生构建)
  • [x] 每日一牌区域无需滚动即部分可见
  • [x] 分类标签过滤快速问题(2x2 网格)
  • [x] 点击问题填充输入框并滚动到顶部
  • [x] 每日一牌"抽牌"显示带 3D 环绕动画的 LivingCard
  • [x] 每日一牌"已完成"显示实际卡牌正面,含名称、关键词、情绪天气
  • [x] 每日一牌在两种状态下均导航至 /card-of-day
  • [x] 最近解读卡片导航至 /reading/[id](详情页,非聊天页)
  • [x] 所有字符串已翻译为 12 种语言(完整按语言本地化,不拼接)
  • [x] 问候语使用设备本地时间,名称位置适配各语言
  • [x] 数据获取期间显示骨架屏加载状态
  • [x] 折叠下方区域(最近解读、为你推荐)渲染时无入场动画
  • [x] 首页无扇形卡牌动画(已移至引导/选牌页面)
  • [x] 乐观读牌师切换,API 失败时回退
  • [x] 麦克风权限优雅处理(首次按下 = 请求权限,再次按下 = 开始)
  • [x] 屏幕入场最多 5 组动画,总级联时长 ~0.8 秒
  • [x] 暖色金色顶部光晕渐变,营造氛围深度
  • [x] GoldRadialGlow PNG 圆环从默认位置下移 200px
  • [x] LivingCard 使用 iOS 原生阴影光晕(shadowColor: '#D4A843'
  • [x] 开发重置按钮(仅 EXPO_PUBLIC_APP_ENV=development)在顶栏中

环境变量

变量示例用途
EXPO_PUBLIC_API_URLhttp://localhost:3031后端 API 基础 URL
EXPO_PUBLIC_APP_ENVdevelopment控制仅开发环境 UI(重置按钮、调试信息)。与 Web 应用的 NEXT_PUBLIC_APP_ENV 模式一致。
EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID(来自 Google Cloud)Google 登录 — Web 客户端
EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID(来自 Google Cloud)Google 登录 — iOS 客户端

开发模式IS_DEV = process.env.EXPO_PUBLIC_APP_ENV === 'development'):在顶栏显示每日一牌状态重置按钮。正式发布构建应设为 production

Internal documentation for MysticX team