首页标签页 — 实现规格 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 | 分类胶囊(非活跃) |
| tabBarBg | rgba(255,250,243,0.92) | 标签栏背景 |
| tabInactive | #9E8E7E | 非活跃标签图标 |
| headerBg | #FFFAF3 | 固定顶栏 |
| Brand.gold | #D4A843 | 活跃圆环、CTA 渐变、强调色 |
区域布局(从上至下)
1. 固定顶栏
绝对定位,固定在顶部,带模糊效果和渐变淡出。
- 背景:
BlurView(intensity 60,tintsystemChromeMaterialLight)+LinearGradient(rgba(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)
背景氛围
所有内容后方有两个背景层:
- 屏幕渐变:
LinearGradient['#FFFAF3', '#FFF8EE', '#FFF7E8']对角填充 - 暖色顶部光晕:
LinearGradient400px 高,固定在顶部,rgba(240,216,166,0.25)→rgba(240,216,166,0.08)→transparent,位置[0, 0.35, 0.7]。在屏幕顶部营造温暖的金色色调。
2. HomeGreeting
居中,根据时间变化,个性化问候。
- 内边距:
paddingTop: 32,paddingBottom: 32,paddingHorizontal: 20 - 问候文本:
Fonts.serif,22px,lineHeight: 30,fontWeight: '600',#4F3E31,letterSpacing: -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)边框 - 活跃名称:
#4F3E31,fontWeight: '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,1pxborderSubtle,cardBg背景 - 内边距: 18px 水平,17px 顶部,12px 底部
- 文本: 16px,
lineHeight: 22,#4F3E31,占位符#9E8B78 - 输入高度:
minHeight: 44,maxHeight: 96 - 底部行:
justifyContent: 'space-between'— 麦克风(左),提交(右) - 阴影: iOS:
shadowOpacity: 0.06,shadowRadius: 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_CN到zh-CN,en到en-US) - 配置:
interimResults: true,continuous: false,addsPunctuation: 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背景,1pxborderSubtle边框 - 图标:
MaterialCommunityIcons,15px - 标签: 13px,
lineHeight: 18,fontWeight: '600' - 间距: 8
- 内边距:
paddingLeft: 20,paddingRight: 28 - 顶部外边距: 14
- 动画:
FadeInUp.delay(300).duration(400) - 点击操作: 获取所选分类的新示例问题
每个分类映射到一个 categoryId API 参数:love-connection、career-abundance、healing-growth、decisions-manifestation、study-growth、wealth-prosperity。
6. QuickQuestions — 2x2 网格
- 布局:
flexWrap: 'wrap',2 列,间距 10 - 卡片宽度:
(SCREEN_WIDTH - 40 - 10) / 2 - 卡片高度: 68px
- 卡片样式:
#F8F4EF背景,1px#E8E1D8边框,borderRadius: 16 - 文本: 13px,
lineHeight: 18,fontWeight: '500',#5C4E40,numberOfLines: 2 - 数量: 4 个问题(从完整数组中截取)
- 点击操作: 填充输入框并滚动到顶部
- 顶部外边距: 12
- 动画:
FadeInUp.delay(300).duration(400)(与分类标签一致)
7. CardOfDaySection — 每日习惯 CTA
两种状态。容器:contentPad(20px 水平内边距)。
状态 A:尚未抽牌 — "Living Card"
居中布局,带装饰纹饰、动画卡背和副标题。
- 容器:
codLivingWrap— 居中,paddingTop: 16,paddingBottom: 20 - 纹饰行:
── ✦ ──细线分隔符加金色星形。线条:32px 宽,hairlineWidth,rgba(212,168,67,0.4)。星形:10px,rgba(212,168,67,0.5)。 - 标题: "Card of the Day",
Fonts.serif,19px,lineHeight: 26,fontWeight: '600',#4F3E31,letterSpacing: 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: 800,rotateX和rotateY以ORBIT_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 秒下降) - 呼吸:
scale1.0 ↔ 1.012,5.2 秒周期(2.6 秒扩张,2.6 秒收缩) - 光晕: iOS 原生阴影 —
shadowColor: '#D4A843',shadowOffset: {0,0},shadowOpacity: 0.35,shadowRadius: 30 - 组件: 使用
<TarotCard width={110} />(默认显示卡背)
状态 B:已抽牌 — 卡面 + 解读信息
水平行布局,展示实际卡牌图案和解读摘要。
- 容器:
codCompletedWrap—flexDirection: 'row',gap: 16,paddingVertical: 12 - 卡片:
<TarotCard cardIndex={entry.cardIndex} isReversed={entry.isReversed} showFront width={90} />— 展示实际抽到的卡牌正面- 阴影:
shadowColor: '#D4A843',shadowOffset: {0,4},shadowOpacity: 0.2,shadowRadius: 12
- 阴影:
- 信息列(右侧,
flex: 1,gap: 4):- 头部行: "CARD OF THE DAY" 眉标签(
Brand.gold,11px,letterSpacing: 1.5,大写,fontWeight: '700')+check-circle图标(14px,Brand.emerald) - 卡牌名称:
Fonts.serif,20px,lineHeight: 26,fontWeight: '600',#4F3E31 - 关键词: 以
·连接,13px,#8B7355,numberOfLines: 2 - 情绪天气:
Fonts.serif斜体,12px,#A28E7A,numberOfLines: 3,marginTop: 2。条件显示 — 仅当entry.emotionalWeather存在时显示。
- 头部行: "CARD OF THE DAY" 眉标签(
- 点击: 整行为
RitualPressable→router.push('/card-of-day')
数据源: TCardOfDayEntry 包含 cardIndex、cardName、isReversed、keywords: string[]、emotionalWeather: string、actions: string[]、questions: string[]。
动画: FadeInUp.delay(400).duration(400),顶部外边距: 18
8. RecentReading — 继续上次解读
条件显示 — 仅当用户至少有 1 次解读时显示。
- 容器:
GlassCard,contentPad(20px 水平内边距) - 左侧强调: 3px 垂直条,
Brand.gold,opacity: 0.5 - 头部: 读牌师头像(20x20 圆形)+
EyebrowLabel"Continue Reading" - 牌阵名称: 15px,
lineHeight: 20,fontWeight: '600',#4F3E31 - 元信息: "{读牌师名称} . {时间距今}",13px,
#7A6548 - 问题: 13px,
#7A6548,numberOfLines: 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,包含 id、question、createdAt、spread.name、reader.name、reader.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 Bar | FadeIn | 0ms | 300ms |
| 1 | Greeting | FadeInDown | 100ms | 500ms |
| 2 | Reader Selector | FadeInUp | 150ms | 400ms |
| 3 | Compact Input | FadeInUp | 250ms | 500ms |
| 4 | Category Tabs + Questions | FadeInUp | 300ms | 400ms |
| 5 | Card of Day | FadeInUp | 400ms | 400ms |
| -- | Recent Reading | 无 | -- | -- |
| -- | For You Card | 无 | -- | -- |
总级联时长:~0.8 秒。
数据获取
所有请求通过 useFocusEffect + Promise.all 并行获取。每个请求都有 .catch() 回退,单个失败不会阻塞其他请求。无需后端改动。
| 函数 | 端点 | 返回值 |
|---|---|---|
fetchCredits() | GET /api/v1/app/credits | TCreditsInfo |
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,仅在选择器中显示已拥有的读牌师。
状态变量
| 变量 | 类型 | 初始值 | 说明 |
|---|---|---|---|
loading | boolean | true | 控制骨架屏与内容切换 |
question | string | '' | 用户输入文本 |
credits | TCreditsInfo or null | null | 余额 + 等级信息 |
cardOfDayStatus | TCardOfDayStatus or null | null | 已抽牌时包含 entry |
sampleQuestions | string[] | [] | 按分类获取 |
activeCategory | string | 'love' | 当前选中的分类标签 |
readers | TTarotReader[] | [] | 仅已拥有的读牌师 |
recentReading | TReadingListItem or null | null | 最近一次解读 |
placeholderIndex | number | 0 | 轮播占位符 |
userName | string or null | derived | 来自 authClient.useSession() |
activeReader | TTarotReader or null | derived | useMemo — 第一个 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):
[
"expo-speech-recognition",
{
"microphonePermission": "MysticX needs microphone access for voice input.",
"speechRecognitionPermission": "MysticX needs speech recognition to transcribe your voice."
}
]语音语言映射
将应用语言代码映射到 BCP-47 语音识别语言代码:
| App Locale | Speech Locale |
|---|---|
en | en-US |
zh_CN | zh-CN |
zh_TW | zh-TW |
ja | ja-JP |
ko | ko-KR |
pt | pt-BR |
es | es-ES |
fr | fr-FR |
de | de-DE |
ar | ar-SA |
id | id-ID |
nl | nl-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_URL | http://localhost:3031 | 后端 API 基础 URL |
EXPO_PUBLIC_APP_ENV | development | 控制仅开发环境 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。