fix:修复
This commit is contained in:
@@ -1,3 +1,7 @@
|
||||
EXPO_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
EXPO_PUBLIC_ENV=dev
|
||||
EXPO_PUBLIC_DEFAULT_LANGUAGE=auto
|
||||
#
|
||||
# Expo/EAS 项目 ID(UUID)。用于真机获取 Expo Push Token(expo-notifications)。
|
||||
# 获取方式:在 client 目录执行 `eas project:init` 或 `eas project:info` 查看。
|
||||
EXPO_PUBLIC_EAS_PROJECT_ID=c519f016-e5c8-426c-868f-5545dce8beef
|
||||
|
||||
1
client/.npmrc
Normal file
1
client/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
registry=https://registry.npmmirror.com
|
||||
30
client/app.config.ts
Normal file
30
client/app.config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { ConfigContext, ExpoConfig } from 'expo/config';
|
||||
|
||||
/**
|
||||
* 运行时获取 Push Token(expo-notifications)在真机/Dev Client 场景下通常需要 projectId。
|
||||
*
|
||||
* 这里把 projectId 注入到 `extra.eas.projectId`:
|
||||
* - 开发/本地:从 `.env.local`(EXPO_PUBLIC_EAS_PROJECT_ID)读取并写入配置
|
||||
* - CI/EAS:也可通过环境变量注入(EXPO_PUBLIC_EAS_PROJECT_ID 或 EAS_PROJECT_ID)
|
||||
*/
|
||||
export default ({ config }: ConfigContext): ExpoConfig => {
|
||||
const projectId =
|
||||
process.env.EXPO_PUBLIC_EAS_PROJECT_ID ||
|
||||
// 兼容部分 CI/EAS 注入的变量名
|
||||
process.env.EAS_PROJECT_ID ||
|
||||
undefined;
|
||||
|
||||
return {
|
||||
...config,
|
||||
extra: {
|
||||
...(config.extra ?? {}),
|
||||
eas: {
|
||||
// 保留已有配置,再覆盖 projectId
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
...(((config.extra as any) ?? {}).eas ?? {}),
|
||||
projectId: projectId ?? (config.extra as any)?.eas?.projectId,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -36,6 +36,13 @@
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
}
|
||||
},
|
||||
"extra": {
|
||||
"eas": {
|
||||
"projectId": "c519f016-e5c8-426c-868f-5545dce8beef"
|
||||
},
|
||||
"router": {}
|
||||
},
|
||||
"owner": "damersu"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { setConsentAccepted, getConsentAccepted } from '../../src/storage/appStorage';
|
||||
import { fetchLegalLinks } from '@/src/services/legalApi';
|
||||
import { getOnboardingCompleted } from '@/src/storage/appStorage';
|
||||
|
||||
// 导入 SVG 组件
|
||||
import FlowersBg from '../../assets/images/index/flowers_endbg.svg';
|
||||
@@ -27,7 +28,13 @@ export default function SplashScreen() {
|
||||
const accepted = await getConsentAccepted();
|
||||
setShowConsent(!accepted);
|
||||
if (accepted) {
|
||||
router.replace('/');
|
||||
// 已同意协议则直接分发到目标页,避免先回到 /(index)再二次跳转导致“闪一下”
|
||||
const completed = await getOnboardingCompleted();
|
||||
if (completed) {
|
||||
router.replace('/(app)/home');
|
||||
} else {
|
||||
router.replace('/(onboarding)/onboarding');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -32,7 +32,8 @@ export {
|
||||
|
||||
export const unstable_settings = {
|
||||
// Ensure that reloading on `/modal` keeps a back button present.
|
||||
initialRouteName: 'index',
|
||||
// 让首次启动(未同意协议)直接进入协议页,避免先渲染 index 再跳转导致“闪一下”
|
||||
initialRouteName: '(splash)/splash',
|
||||
};
|
||||
|
||||
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
||||
@@ -144,6 +145,9 @@ function RootLayoutNav() {
|
||||
return (
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
{/* 协议页分组(首次启动优先进入) */}
|
||||
<Stack.Screen name="(splash)" />
|
||||
|
||||
{/* 启动分发页:根据 onboarding 状态跳转 */}
|
||||
<Stack.Screen name="index" />
|
||||
|
||||
@@ -173,7 +177,8 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: '#EAD2BA',
|
||||
},
|
||||
splashImage: {
|
||||
width: '80%',
|
||||
height: '80%',
|
||||
// 覆盖层图片尺寸需与系统原生 Splash 的视觉一致,避免出现“缩小一下”的错觉
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -81,7 +81,12 @@ function ThemeCard({
|
||||
{children}
|
||||
{/* 文案展示在图片中心 */}
|
||||
<View style={styles.textOverlay}>
|
||||
<Text style={[styles.overlayTitle, selected && styles.selectedOverlayTitle]}>
|
||||
<Text
|
||||
style={[styles.overlayTitle, selected && styles.selectedOverlayTitle]}
|
||||
numberOfLines={1}
|
||||
adjustsFontSizeToFit
|
||||
minimumFontScale={0.85}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -94,20 +99,21 @@ function ThemeCard({
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
flexWrap: 'nowrap',
|
||||
gap: 12,
|
||||
paddingHorizontal: 0,
|
||||
paddingHorizontal: 4,
|
||||
paddingBottom: 50,
|
||||
paddingTop: 20,
|
||||
justifyContent: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
cardContainer: {
|
||||
alignItems: 'center',
|
||||
width: 112,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
alignItems: 'stretch',
|
||||
},
|
||||
previewWrapper: {
|
||||
width: 110,
|
||||
height: 178,
|
||||
width: '100%',
|
||||
aspectRatio: 110 / 178,
|
||||
borderRadius: 26,
|
||||
padding: 6.5,
|
||||
justifyContent: 'center',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { View, StyleSheet, TextInput, Platform, Animated, TouchableOpacity, Text } from 'react-native';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { View, StyleSheet, TextInput, Platform, Animated, TouchableOpacity, Text, Keyboard, Pressable } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
||||
@@ -17,9 +17,27 @@ export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProp
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||
const blinkAnim = useRef(new Animated.Value(1)).current;
|
||||
const hasInput = value.trim().length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
||||
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
||||
|
||||
const subShow = Keyboard.addListener(showEvent, (e) => {
|
||||
setKeyboardHeight(e.endCoordinates?.height ?? 0);
|
||||
});
|
||||
const subHide = Keyboard.addListener(hideEvent, () => {
|
||||
setKeyboardHeight(0);
|
||||
});
|
||||
|
||||
return () => {
|
||||
subShow.remove();
|
||||
subHide.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const animation = Animated.loop(
|
||||
Animated.sequence([
|
||||
@@ -36,8 +54,14 @@ export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProp
|
||||
return () => animation.stop();
|
||||
}, [blinkAnim, isFocused]);
|
||||
|
||||
const footerBottom = useMemo(() => {
|
||||
// iOS 的 keyboard height 通常已包含底部安全区,避免重复叠加
|
||||
const keyboardOffset = Math.max(0, keyboardHeight - insets.bottom);
|
||||
return 16 + insets.bottom + keyboardOffset;
|
||||
}, [insets.bottom, keyboardHeight]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Pressable style={styles.container} onPress={Keyboard.dismiss} accessible={false}>
|
||||
<View style={styles.inputCard}>
|
||||
<View style={styles.inputWrapper}>
|
||||
{/* 显示层:文案 + 跟随的光标 */}
|
||||
@@ -67,20 +91,30 @@ export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProp
|
||||
caretHidden={true}
|
||||
autoCorrect={false}
|
||||
spellCheck={false}
|
||||
returnKeyType="done"
|
||||
blurOnSubmit={true}
|
||||
onSubmitEditing={() => {
|
||||
Keyboard.dismiss();
|
||||
// 有输入时,“完成”直接进入下一步,避免真机卡在键盘上
|
||||
if (value.trim().length > 0) onNext();
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.footer, { bottom: insets.bottom + 16 }]}>
|
||||
<View style={[styles.footer, { bottom: footerBottom }]}>
|
||||
<TouchableOpacity
|
||||
onPress={onNext}
|
||||
onPress={() => {
|
||||
Keyboard.dismiss();
|
||||
onNext();
|
||||
}}
|
||||
disabled={!hasInput}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{hasInput ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
<string>12.0</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>用于连接局域网服务以获取内容与同步数据(仅在需要访问内网地址时使用)。</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
|
||||
@@ -5,6 +5,11 @@ set -euo pipefail
|
||||
# - 某些情况下 xcodebuild 生成的 .xcarchive/Info.plist 缺少 ApplicationProperties
|
||||
# - Organizer 无法识别归档中的主 App(即使 Products/Applications/*.app 存在)
|
||||
#
|
||||
# 说明:
|
||||
# - 该脚本的核心作用是让 Organizer 能识别归档里的主 App,从而出现“分发/上传 TestFlight”入口。
|
||||
# - 这类问题通常发生在命令行/CI 归档(xcodebuild archive)或某些自定义归档流程中,
|
||||
# 导致 .xcarchive/Info.plist 缺少/不完整。
|
||||
#
|
||||
# 用法:
|
||||
# ./scripts/fix-xcarchive-header.sh "/path/to/xxx.xcarchive"
|
||||
|
||||
@@ -25,6 +30,10 @@ if [[ ! -f "$ARCHIVE_INFO_PLIST" ]]; then
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# 归档名:尽量从路径推导,避免依赖 Xcode 环境变量
|
||||
archive_basename="$(/usr/bin/basename "$ARCHIVE_PATH")"
|
||||
archive_name="${archive_basename%.xcarchive}"
|
||||
|
||||
# 取第一个 App(归档里通常只有一个主 App)
|
||||
APP_PLIST="$(/usr/bin/find "$ARCHIVE_PATH/Products/Applications" -maxdepth 2 -name Info.plist -path "*.app/Info.plist" 2>/dev/null | /usr/bin/head -n 1 || true)"
|
||||
if [[ -z "$APP_PLIST" ]]; then
|
||||
@@ -39,6 +48,14 @@ APP_REL_PATH="Applications/$APP_NAME"
|
||||
bundle_id="$(/usr/bin/plutil -extract CFBundleIdentifier raw -o - "$APP_PLIST" 2>/dev/null || true)"
|
||||
short_version="$(/usr/bin/plutil -extract CFBundleShortVersionString raw -o - "$APP_PLIST" 2>/dev/null || true)"
|
||||
build_version="$(/usr/bin/plutil -extract CFBundleVersion raw -o - "$APP_PLIST" 2>/dev/null || true)"
|
||||
display_name="$(/usr/bin/plutil -extract CFBundleDisplayName raw -o - "$APP_PLIST" 2>/dev/null || true)"
|
||||
bundle_name="$(/usr/bin/plutil -extract CFBundleName raw -o - "$APP_PLIST" 2>/dev/null || true)"
|
||||
|
||||
# SchemeName 在 Organizer 中会用到,但在某些归档流程里会缺失
|
||||
scheme_name="${SCHEME_NAME:-}"
|
||||
if [[ -z "$scheme_name" ]]; then
|
||||
scheme_name="${archive_name:-}"
|
||||
fi
|
||||
|
||||
if [[ -z "$bundle_id" || -z "$short_version" || -z "$build_version" ]]; then
|
||||
echo "错误:无法从 App Info.plist 读取 bundle/version/build:$APP_PLIST" >&2
|
||||
@@ -63,6 +80,16 @@ fi
|
||||
# 备份一份,防止误操作
|
||||
cp -f "$ARCHIVE_INFO_PLIST" "$ARCHIVE_INFO_PLIST.bak"
|
||||
|
||||
# 修复归档根字段,避免 Organizer 仍然把它当 Generic Archive
|
||||
# 参考:标准 .xcarchive/Info.plist 通常包含 Name / SchemeName / ArchiveVersion / CreationDate 等。
|
||||
# 我们只在缺失时补齐,尽量不改动归档的其他内容。
|
||||
if ! /usr/bin/plutil -extract Name xml1 -o - "$ARCHIVE_INFO_PLIST" >/dev/null 2>&1; then
|
||||
/usr/bin/plutil -insert Name -string "${archive_name:-${display_name:-${bundle_name:-}}}" "$ARCHIVE_INFO_PLIST" 2>/dev/null || true
|
||||
fi
|
||||
if ! /usr/bin/plutil -extract SchemeName xml1 -o - "$ARCHIVE_INFO_PLIST" >/dev/null 2>&1; then
|
||||
/usr/bin/plutil -insert SchemeName -string "${scheme_name:-${archive_name:-}}" "$ARCHIVE_INFO_PLIST" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 如果已有 ApplicationProperties,直接更新关键字段即可
|
||||
if /usr/bin/plutil -extract ApplicationProperties xml1 -o - "$ARCHIVE_INFO_PLIST" >/dev/null 2>&1; then
|
||||
/usr/bin/plutil -replace ApplicationProperties.ApplicationPath -string "$APP_REL_PATH" "$ARCHIVE_INFO_PLIST"
|
||||
@@ -98,3 +125,9 @@ echo "已修复归档 header:$ARCHIVE_INFO_PLIST"
|
||||
echo "主 App:$APP_REL_PATH"
|
||||
echo "Bundle:$bundle_id"
|
||||
echo "Version/Build:$short_version/$build_version"
|
||||
echo "Name/SchemeName:${archive_name:-} / ${scheme_name:-}"
|
||||
|
||||
# 轻量自检:确保关键字段存在(不强制失败,避免中断归档)
|
||||
if ! /usr/bin/plutil -extract ApplicationProperties.ApplicationPath xml1 -o - "$ARCHIVE_INFO_PLIST" >/dev/null 2>&1; then
|
||||
echo "警告:归档 Info.plist 仍缺少 ApplicationProperties.ApplicationPath,Organizer 可能仍显示 Generic Archive" >&2
|
||||
fi
|
||||
|
||||
@@ -22,7 +22,14 @@ function getOptionalEnv(name: string, fallback: string): string {
|
||||
|
||||
export type AppRuntimeEnv = 'local' | 'dev' | 'prod';
|
||||
|
||||
export const APP_ENV = (getOptionalEnv('EXPO_PUBLIC_ENV', 'local') as AppRuntimeEnv) ?? 'local';
|
||||
/**
|
||||
* Release/TestFlight 场景下如果未注入 EXPO_PUBLIC_ENV,
|
||||
* 默认回退到 prod(避免误打到 localhost 导致真机“无法发起网络请求”)。
|
||||
*/
|
||||
const DEFAULT_RUNTIME_ENV: AppRuntimeEnv =
|
||||
typeof __DEV__ !== 'undefined' && __DEV__ ? 'local' : 'prod';
|
||||
|
||||
export const APP_ENV = (getOptionalEnv('EXPO_PUBLIC_ENV', DEFAULT_RUNTIME_ENV) as AppRuntimeEnv) ?? DEFAULT_RUNTIME_ENV;
|
||||
|
||||
function getApiBaseUrl(env: AppRuntimeEnv): string {
|
||||
// 向后兼容:若直接提供了 EXPO_PUBLIC_API_BASE_URL,则优先使用(不再强制要求 *_DEV/_PROD)
|
||||
|
||||
@@ -100,6 +100,8 @@ function getExpoProjectId(): string | undefined {
|
||||
// 兼容 app.json / app.config.ts 的 extra.eas.projectId
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(Constants.expoConfig as any)?.extra?.eas?.projectId ||
|
||||
// 兜底:某些运行时环境仍可直接读到 EXPO_PUBLIC_ 注入
|
||||
process.env.EXPO_PUBLIC_EAS_PROJECT_ID ||
|
||||
undefined
|
||||
);
|
||||
}
|
||||
@@ -115,7 +117,7 @@ export async function getExpoPushTokenOrThrow(): Promise<string> {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
const hint = projectId
|
||||
? ''
|
||||
: '(可能缺少 EAS projectId,建议在 app.json 的 extra.eas.projectId 配置后重试)';
|
||||
: '(可能缺少 EAS projectId:请在 .env.local 配置 EXPO_PUBLIC_EAS_PROJECT_ID,或在 app.json/app.config.ts 的 extra.eas.projectId 写入后重试)';
|
||||
throw new Error(`获取 Expo Push Token 失败:${msg}${hint}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +122,8 @@ export async function httpJson<T>(opts: HttpJsonOptions): Promise<T> {
|
||||
} catch (e) {
|
||||
// RN 下 AbortError 文案不完全一致,这里统一对外语义
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
throw new Error(`网络请求失败:${msg}`);
|
||||
// 带上 URL,便于在 TestFlight/Release 排查实际打到哪个地址(例如误打到 localhost)
|
||||
throw new Error(`网络请求失败:${msg}(${url})`);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
|
||||
236
spec_kit/SuixinTheme/plan.md
Normal file
236
spec_kit/SuixinTheme/plan.md
Normal file
@@ -0,0 +1,236 @@
|
||||
# 「随心」主题(Suixin Theme)技术计划(plan)
|
||||
|
||||
## 0. 目标回顾
|
||||
|
||||
在 Home 现有「风景 / 纯色」主题基础上新增第三种主题「随心」:
|
||||
|
||||
- **输入**:问卷生成的用户画像 `U`(本地存储)
|
||||
- **输出**:Home 背景推荐颜色(以纯色为主)
|
||||
- **规则**:复用「个性化背景颜色推荐算法」的 Base Theme/Neutral Theme 与 Hard Rules
|
||||
- **计算时机**:
|
||||
- **冷启动(App 进程级)**:计算一次并锁定 Base Theme
|
||||
- **切换文案(Home 上滑切下一条)**:在同一 Base Theme 内更新一次“当前颜色”
|
||||
- **持久化**:主题选择与「随心」计算状态均持久化(避免回到 Home/重进页面时丢失)
|
||||
- **多语言**:TC(zh-TW)+ EN
|
||||
|
||||
## 1. 现状梳理(与改动点)
|
||||
|
||||
### 1.1 现有主题切换
|
||||
|
||||
- `ThemeMode` 当前为 `'scenery' | 'color'`
|
||||
- `ThemeModal` 弹窗提供 2 个卡片切换
|
||||
- `Home` 根据 `themeMode`:
|
||||
- `scenery`:背景图 + 默认底色
|
||||
- `color`:从 `THEME_COLORS` 按 `index` 轮换纯色
|
||||
- `ui.theme.mode` 已在 `AsyncStorage` 持久化
|
||||
|
||||
### 1.2 用户画像输入已就绪
|
||||
|
||||
客户端已将问卷映射为 `UserProfileV1_2(_Extended)` 并持久化(`user.profileScoring`),关键字段:
|
||||
|
||||
- `stage.unknown`(Hard Rule:unknown → Neutral)
|
||||
- `need`(稀疏 one-hot:`{ [needTag]: 1 }` 或 `{}`)
|
||||
- `emotion_score: number | null`
|
||||
- `profile_confidence: number`
|
||||
- `profile_answered`
|
||||
|
||||
## 2. 技术方案总览
|
||||
|
||||
### 2.1 「随心」算法在 Home 的落地形态
|
||||
|
||||
Home 不具备“长文案阅读页的 scroll”,因此采用**“渐变单点采样”**来复用算法的连续插值模型:
|
||||
|
||||
- Base Theme 仍按 `need / stage` 选定并锁定(Theme Lock)
|
||||
- 每次切换文案时,生成一个 \(t \in [0, 1]\),并计算:
|
||||
|
||||
\[
|
||||
Color(t) = lerp(Color\_top, Color\_bottom, t)
|
||||
\]
|
||||
|
||||
- 输出为单一 `hex` 纯色,作为 Home `backgroundColor`
|
||||
- 全程 **不跨 need、不跨主题色系**,仅在同主题内移动
|
||||
|
||||
### 2.2 持久化与“只在冷启动/切换文案时计算”
|
||||
|
||||
为同时满足“持久化”与“冷启动时计算”:
|
||||
|
||||
- **持久化内容**:锁定的 `base_theme_id` + 用于生成 \(t\) 的 `seed` + 当前 `step_index` + `last_color`
|
||||
- **冷启动计算**:当检测到“新一轮 App 启动会话”时,重新选择并锁定 `base_theme_id`,并重置/更新 `seed` 与 `step_index`
|
||||
- **切换文案计算**:仅递增 `step_index`,在同一 `base_theme_id` 下更新 `last_color`
|
||||
|
||||
> 说明:冷启动检测以“进程级首次进入 Home”为准(工程实现阶段会在 `_layout` 或全局单例中生成 boot 标记)。
|
||||
|
||||
## 3. 数据结构与存储设计
|
||||
|
||||
### 3.1 扩展主题枚举
|
||||
|
||||
- 将 `ThemeMode` 扩展为:`'scenery' | 'color' | 'suixin'`
|
||||
- 存储 key:沿用 `ui.theme.mode`
|
||||
|
||||
### 3.2 新增「随心」状态存储
|
||||
|
||||
新增本地存储 key(建议):
|
||||
|
||||
- `ui.theme.suixin.state`
|
||||
|
||||
数据结构(建议):
|
||||
|
||||
```ts
|
||||
type SuixinThemeStateV1 = {
|
||||
schema_version: 1;
|
||||
saved_at: string; // ISO8601
|
||||
base_theme_id: 'neutral' | 'emotional_support' | 'parenting_pressure' | 'self_worth' | 'anxiety_relief' | 'rest_balance';
|
||||
seed: string; // 用于生成 t 的稳定种子(可由 profile + 日期等派生)
|
||||
step_index: number; // 每切换一条文案 +1
|
||||
last_color: string; // "#RRGGBB"
|
||||
};
|
||||
```
|
||||
|
||||
### 3.3 冷启动会话标记(Boot ID)
|
||||
|
||||
为实现“仅冷启动时重置 base theme/seed”,新增一个进程级 boot 标记(实现二选一):
|
||||
|
||||
- **方案 A(推荐)**:在 `app/_layout.tsx` 首次挂载时生成 `boot_id` 并写入内存单例(不落盘)
|
||||
- **方案 B**:写入 `AsyncStorage`(例如 `app.boot.lastSeenAt`)并结合“本次运行内存标记”判定首次进入 Home
|
||||
|
||||
计划优先采用方案 A:逻辑清晰且不污染存储。
|
||||
|
||||
## 4. 颜色算法实现细节(Home 版本)
|
||||
|
||||
### 4.1 主题色盘常量
|
||||
|
||||
在客户端新增一个颜色模块(例如 `client/src/features/suixinTheme/`),内置:
|
||||
|
||||
- Base Theme(5 套)+ Neutral(1 套)
|
||||
- 与设计文档保持一致的 `hex` 值
|
||||
|
||||
### 4.2 Base Theme 选择(锁定)
|
||||
|
||||
输入:`UserProfileScoring`
|
||||
|
||||
输出:`base_theme_id`
|
||||
|
||||
规则:
|
||||
|
||||
- 若 `stage.unknown === 1` → `neutral`
|
||||
- 若 `need` 为空 `{}` → `neutral`
|
||||
- 否则取 `Object.keys(need)[0]`:
|
||||
- 若 key 在枚举内 → 对应 Base Theme
|
||||
- 否则 → `neutral`
|
||||
|
||||
### 4.3 t 的生成与“低感知变化”
|
||||
|
||||
为了让“切换文案”带来“流动感”但不跳变,采用**小步进**策略:
|
||||
|
||||
- 定义 `N = 12`(可调):表示从 \(0 \to 1\) 的分段数
|
||||
- 每次切换文案:`step_index += 1`
|
||||
- 计算:`t = (step_index % N) / (N - 1)`
|
||||
|
||||
> 该策略保证 \(t\) 在 \([0, 1]\) 内缓慢移动;到达 1 后回到 0 会有一次跳变。为进一步降低跳变,可改为往返波形:
|
||||
>
|
||||
> - `phase = step_index % (2*(N-1))`
|
||||
> - `t = phase <= (N-1) ? phase/(N-1) : (2*(N-1)-phase)/(N-1)`
|
||||
|
||||
实现阶段默认采用**往返波形**,避免回卷突跳。
|
||||
|
||||
### 4.4 lerp 计算(严格线性)
|
||||
|
||||
- `lerp` 仅允许线性插值
|
||||
- 颜色空间:先使用 sRGB 的逐通道线性插值(实现简单、可控);若后续需要更自然,可升级到线性空间插值,但仍保持线性模型
|
||||
|
||||
### 4.5 emotion/confidence 的约束接入
|
||||
|
||||
本期按“安全优先”策略落地:
|
||||
|
||||
- 若 `emotion_score === null` 或 `emotion_score <= 0.3`:输出不做任何微扰(纯 lerp 结果)
|
||||
- 亮度微扰(\(\Delta L \le \pm 2\%\))与饱和度上限为可选增强;若落地,将以 `profile_confidence` 作为开关条件,并确保不改变色系
|
||||
|
||||
## 5. UI 与交互实现计划
|
||||
|
||||
### 5.1 ThemeModal:新增第三个主题卡片
|
||||
|
||||
- 在 `client/components/home/ThemeModal.tsx`:
|
||||
- `ThemeMode` 扩展为包含 `'suixin'`
|
||||
- 新增 `ThemeCard`:标题使用 i18n(如 `t('theme.suixin')`)
|
||||
- 布局改造:由 2 卡横排改为 **3 卡自适应**(`flexWrap` 或减小 gap/宽度),确保小屏不溢出
|
||||
- 预览图:一期可复用 `theme_color.png` 作为占位;若有设计资源再替换为 `theme_suixin.png`
|
||||
|
||||
### 5.2 Home:新增主题分支与颜色计算时机
|
||||
|
||||
在 `client/app/(app)/home.tsx`:
|
||||
|
||||
- 将 `themeMode === 'suixin'` 作为第三分支:
|
||||
- 背景为纯色(`backgroundColor = suixinColor`)
|
||||
- 不显示风景图
|
||||
- **冷启动**:Home 首次进入时,读取用户画像与 `suixin.state`:
|
||||
- 若检测到新 boot 会话:重算并写入 `suixin.state`
|
||||
- 否则:直接使用持久化的 `last_color`
|
||||
- **切换文案**:在现有 `triggerNextContent` 成功切换索引后:
|
||||
- 若当前主题为 `suixin`:递增 `step_index`,计算新的 `last_color`,并持久化
|
||||
|
||||
### 5.3 收藏(Favorites)背景记录兼容
|
||||
|
||||
`FavoriteItem.background` 当前对 `color` 存 `hex`,对 `scenery` 存图片索引。
|
||||
|
||||
- `suixin` 同样存 `hex`,与 `color` 分支一致即可
|
||||
|
||||
## 6. i18n 计划(TC / EN)
|
||||
|
||||
在 `client/src/i18n/locales/all.json` 增加:
|
||||
|
||||
- `theme.suixin`
|
||||
- (可选)`theme.suixinDesc`(若 UI 后续展示描述)
|
||||
|
||||
英文命名采用语义化方案(本计划建议):
|
||||
|
||||
- EN:`theme.suixin = "Ease"`
|
||||
- TC:`theme.suixin = "隨心"`
|
||||
|
||||
> 若后续品牌希望保留音译,也可改为 EN=`Suixin`,不影响技术实现。
|
||||
|
||||
## 7. 兼容性与迁移
|
||||
|
||||
- `ThemeMode` 的存储值新增 `'suixin'`:
|
||||
- 旧版本只会存 `'scenery'|'color'`,升级后兼容
|
||||
- 若读取到未知值,继续回退 `'scenery'`
|
||||
- 新增 `ui.theme.suixin.state`:
|
||||
- 若不存在,首次进入随心主题时初始化
|
||||
|
||||
## 8. 测试计划(最小可回归)
|
||||
|
||||
### 8.1 单元测试(推荐)
|
||||
|
||||
为颜色算法模块增加用例(可放在 `client/src/features/suixinTheme/__tests__/`):
|
||||
|
||||
- `stage.unknown=1` → 必选 `neutral`
|
||||
- `need={}` → 必选 `neutral`
|
||||
- `need={rest_balance:1}` → 选 `rest_balance` Base Theme
|
||||
- `step_index` 递增 → `t` 按往返波形变化且始终在 \([0,1]\)
|
||||
- `emotion_score=null` / `<=0.3` → 不触发微扰逻辑
|
||||
|
||||
### 8.2 手动验收(与 spec 对齐)
|
||||
|
||||
- ThemeModal 能看到第三个主题并可切换
|
||||
- 冷启动进入 Home:随心背景根据画像选定主题色系
|
||||
- 上滑切换文案:背景色在同主题内缓慢变化(无跨主题跳色)
|
||||
- `stage.unknown=1` 或 `need` 跳过:背景为 Neutral Theme
|
||||
- 切换语言:主题名称在 TC/EN 下正确显示
|
||||
|
||||
## 9. 风险与对策
|
||||
|
||||
- **三卡布局拥挤**:采用 `flexWrap`/缩小卡片尺寸,必要时改为横向滚动
|
||||
- **“持久化”与“冷启动重算”矛盾**:以“状态落盘 + 冷启动重置 base theme/seed”方式兼容两者
|
||||
- **颜色可读性风险**:一期先用主题中间色/插值结果,避免过饱和;必要时增加对比度检查(后续迭代)
|
||||
|
||||
## 10. 里程碑拆分(实现顺序)
|
||||
|
||||
- **M1:基础接入**
|
||||
- 扩展 `ThemeMode`,ThemeModal 增加第三项与 i18n
|
||||
- Home 增加 `suixin` 分支,背景可显示(先用 neutral 兜底)
|
||||
- **M2:算法落地 + 持久化**
|
||||
- 新增 suixin 颜色模块(Base/Neutral、pickTheme、lerp、t 生成)
|
||||
- 新增 `suixin.state` 存取与冷启动/切换文案更新
|
||||
- **M3:回归与体验优化**
|
||||
- 收藏背景记录兼容
|
||||
- 测试补齐与边界修正(unknown/跳过/缺画像)
|
||||
|
||||
161
spec_kit/SuixinTheme/spec.md
Normal file
161
spec_kit/SuixinTheme/spec.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# 「随心」主题(Suixin Theme)高层规范(spec)
|
||||
|
||||
## 1. 背景与动机
|
||||
|
||||
当前首页(Home)支持两种主题:
|
||||
|
||||
- **风景**:使用预置风景图作为背景
|
||||
- **纯色**:使用预置颜色列表轮换作为背景
|
||||
|
||||
现在新增第三种主题 **「随心」**,其核心是:**背景颜色随用户画像个性化**,并遵循既有的「个性化背景颜色推荐算法」规则与硬约束(Hard Rules)。
|
||||
|
||||
## 2. 目标(Goals)
|
||||
|
||||
- **新增主题**:在现有「风景 / 纯色」基础上新增 **「随心」** 主题,并与现有主题切换入口保持一致。
|
||||
- **个性化颜色**:基于用户完成问卷后生成的用户画像 `U`,输出 Home 背景的推荐颜色(或渐变颜色组),形成“更贴合此刻”的视觉陪伴。
|
||||
- **稳定与不冒犯**:严格遵循硬规则(例如 `mom_stage=unknown` 强制 Neutral Theme),并在一次 session 内保持稳定,避免跳色造成打扰。
|
||||
- **多语言**:支持 **繁体中文(TC / zh-TW)** 与 **英文(EN)** 的主题名称与 UI 文案展示。
|
||||
|
||||
## 3. 非目标(Non-Goals)
|
||||
|
||||
- **不用于转化**:随心主题不承担 CTA/转化引导职责,不为“制造变化”而变化。
|
||||
- **不新增色系数量**:不新增主题色系数量,复用既定 Base Theme(5 套)+ Neutral Theme(1 套)。
|
||||
- **不做心理诊断**:颜色不用于推断用户心理状态,只用于提升阅读与停留的舒适度。
|
||||
|
||||
## 4. 适用范围(Scope)
|
||||
|
||||
### 4.1 适用页面
|
||||
|
||||
- **首页 Home 背景(主题模式为「随心」时)**:输出为“纯色背景”或“轻量渐变背景”(实现形态由工程实现阶段确定,但必须遵循硬约束与稳定性规则)。
|
||||
|
||||
### 4.2 不适用页面
|
||||
|
||||
- 首页列表/卡片/CTA 组件背景(不在本需求范围)
|
||||
- 任何需要高对比/强引导的交互区域(避免降低可用性)
|
||||
|
||||
## 5. 用户体验与交互
|
||||
|
||||
### 5.1 主题切换入口与位置
|
||||
|
||||
- **切换位置**:与现有主题切换位置一致(即当前 Home 右上角主题按钮打开的主题选择弹窗/面板)。
|
||||
- **切换项**:在「风景」「纯色」旁新增第三项「随心」。
|
||||
|
||||
### 5.2 主题命名与多语言(TC / EN)
|
||||
|
||||
#### i18n Key 建议(示例)
|
||||
|
||||
- `home.theme.scenery`
|
||||
- `home.theme.color`
|
||||
- `home.theme.suixin`
|
||||
- `home.theme.suixinDesc`(可选:主题描述,用于解释“随心=按问卷画像推荐颜色”)
|
||||
|
||||
#### 文案建议
|
||||
|
||||
- **TC(zh-TW)**
|
||||
- `home.theme.suixin`: 隨心
|
||||
- `home.theme.suixinDesc`: 依照你的問卷狀態,推薦舒適的背景色
|
||||
- **EN**
|
||||
- `home.theme.suixin`: Suixin
|
||||
- `home.theme.suixinDesc`: A cozy background color, tailored from your questionnaire
|
||||
|
||||
> 说明:主题名「随心」作为品牌/概念名,EN 采用音译 `Suixin`,避免语义误解(如 “Random”)。
|
||||
|
||||
## 6. 输入输出(与问卷画像的对接)
|
||||
|
||||
### 6.1 输入:用户画像 `U`
|
||||
|
||||
随心主题的颜色推荐以客户端本地存储的用户画像为输入(来源:问卷完成后生成的画像)。
|
||||
|
||||
必须使用字段(与现有实现对齐):
|
||||
|
||||
- `U.stage.unknown`:用于 Hard Rule(unknown → Neutral Theme)
|
||||
- `U.need`:用于选择 Base Theme(稀疏 one-hot,例如 `{ "rest_balance": 1 }`;若为空 `{}` 视为“need 跳过”)
|
||||
- `U.emotion_score`:用于动态强度/亮度扰动的约束(可为 `null`)
|
||||
- `U.profile_confidence`:用于个性化强度(可信度低则更保守)
|
||||
- `U.profile_answered`:用于判断题目是否跳过(避免伪精确)
|
||||
|
||||
### 6.2 输出:Home 背景推荐颜色
|
||||
|
||||
输出形态需支持两类(工程阶段二选一或混合):
|
||||
|
||||
- **纯色输出(推荐优先)**:输出单一 `hex` 颜色作为背景色
|
||||
- **轻量渐变输出(可选增强)**:输出 2~3 个 `hex` 颜色作为背景渐变 stops(必须连续、低感知变化)
|
||||
|
||||
## 7. 颜色算法规则(复用现有文档,Home 场景化)
|
||||
|
||||
### 7.1 主题色系(Base Theme / Neutral Theme)
|
||||
|
||||
Base Theme(5 套,不新增):
|
||||
|
||||
```json
|
||||
{
|
||||
"emotional_support": ["#F6DCE4", "#FFEFF4", "#FFF7FA"],
|
||||
"parenting_pressure": ["#D6EAF5", "#EEF6FB", "#F8FCFF"],
|
||||
"self_worth": ["#FFD8A8", "#FFE8C9", "#FFF6E5"],
|
||||
"anxiety_relief": ["#DFF3EA", "#ECFBF6", "#F6FFFB"],
|
||||
"rest_balance": ["#F2E6D8", "#FAF3EC", "#FFFDF9"]
|
||||
}
|
||||
```
|
||||
|
||||
Neutral Theme(1 套):
|
||||
|
||||
```json
|
||||
["#F4F7F2", "#E8F1EC", "#EDF4F8"]
|
||||
```
|
||||
|
||||
### 7.2 Home 场景的主题选择规则(Theme Picking)
|
||||
|
||||
- **Hard Rule**:若 `U.stage.unknown = 1` → **强制 Neutral Theme**
|
||||
- 若 `U.need` 为空对象 `{}`(need 跳过/缺失)→ **使用 Neutral Theme**
|
||||
- 否则:从 `U.need` 取出被选中的 need tag(稀疏 one-hot 的 key),映射到对应 Base Theme
|
||||
|
||||
### 7.3 Home 场景的颜色输出规则(Solid/Gradient)
|
||||
|
||||
Home 没有“长文案滚动阅读”的 scroll,因此需要将「连续渐变」规则做“等价映射”:
|
||||
|
||||
- **纯色输出(默认)**:使用所选主题的中间色(例如 `theme[1]`)作为背景色,保证稳定、可读、低感知。
|
||||
- **轻量渐变输出(可选)**:使用主题的 `theme[0]` 与 `theme[2]` 作为 top/bottom,保持同主题内部变化;渐变 stops 仅允许线性分布,不允许 easing/bounce。
|
||||
|
||||
> 备注:是否启用渐变由实现阶段决定;即便启用,也必须遵循「同主题内部变化」与「连续」的约束。
|
||||
|
||||
### 7.4 情绪与置信度调节(强度而非色系)
|
||||
|
||||
复用既有规则精神:`emotion_score` 与 `profile_confidence` **只影响强度**,不得导致色系切换。
|
||||
|
||||
- `emotion_score ≤ 0.3`:禁止任何动态增强(保持最稳定的纯色/静态渐变)
|
||||
- `emotion_score ∈ [0.3, 0.6]` 且 `profile_confidence ≥ 0.6`:允许极弱亮度微扰(\(\Delta L \le \pm 2\%\)),用于降低“模板感”
|
||||
- `profile_confidence ≤ 0.4`:最大饱和度不超过 60%(若实现包含饱和度调节)
|
||||
|
||||
## 8. 稳定性与 Session 规则(Home 版本)
|
||||
|
||||
为避免“背景跳色”,随心主题必须具备 **Theme Lock**:
|
||||
|
||||
- **锁定时机**:用户进入 Home 且主题模式为「随心」
|
||||
- **锁定内容**:锁定 Base Theme(或 Neutral Theme)选择结果;必要时也锁定最终输出颜色/渐变 stops
|
||||
- **解锁时机**:
|
||||
- 用户离开 Home(或 app 重启,按实现策略)
|
||||
- 用户主动切换主题模式(从随心切换到风景/纯色,再切回时可重新计算)
|
||||
- **Session 内禁止重新采样**:不得因为画像更新、拉取新文案、上下滑动切换文案而切换 Base Theme
|
||||
|
||||
## 9. 边界条件与兜底
|
||||
|
||||
- **用户未完成问卷 / 跳过全部题目**:画像中 `stage.unknown=1` 且 `need={}`,必须输出 Neutral Theme(稳定、安全)。
|
||||
- **emotion_score 为 null**:视为不确定 → 禁止动态增强,输出稳定纯色/静态渐变。
|
||||
- **非法/未知 need key**:按跳过处理 → Neutral Theme。
|
||||
|
||||
## 10. 验收标准(Acceptance Criteria)
|
||||
|
||||
- **入口一致**:Home 的主题切换入口不变位置;新增「随心」选项可选中并持久化。
|
||||
- **多语言正确**:TC 与 EN 下,「随心」主题名称与描述文案正确展示(不出现缺失 key)。
|
||||
- **规则一致**:
|
||||
- `stage.unknown=1` 时必为 Neutral Theme
|
||||
- `need` 缺失/跳过时必为 Neutral Theme
|
||||
- 不允许跨 need 插值/切换
|
||||
- **稳定性**:一次 Home session 内,不因切换文案/刷新/拉取推荐而改变随心主题色系(Theme Lock 生效)。
|
||||
|
||||
## 11. 依赖与关联模块
|
||||
|
||||
- **用户画像来源**:客户端 `User Profile Scoring`(问卷完成后生成 `U` 并写入本地存储)
|
||||
- **颜色算法来源**:`设计说明文档/个性化背景颜色推荐算法.md`(规则与 Hard Rules)
|
||||
- **UI 入口**:Home 顶部主题切换弹窗(与现有位置一致)
|
||||
|
||||
152
spec_kit/SuixinTheme/tasks.md
Normal file
152
spec_kit/SuixinTheme/tasks.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# 「随心」主题(Suixin Theme)任务清单(tasks)
|
||||
|
||||
> 说明:
|
||||
>
|
||||
> - 本清单基于 `spec_kit/SuixinTheme/plan.md` 拆分为可执行任务。
|
||||
> - 执行过程中:完成一项就在对应条目打勾(`[x]`),并补充必要的实现备注/PR 链接(如有)。
|
||||
> - **当本 tasks 全部完成后**,需要回到 `spec_kit/overview.md` 在 `SuixinTheme` 条目下标记“已完成编码(阶段性/全部)”。
|
||||
|
||||
## 0. 准备与基线确认
|
||||
|
||||
- [x] **T0.1 确认现有主题切换链路位置与文件**
|
||||
- **涉及文件**:`client/components/home/ThemeModal.tsx`、`client/app/(app)/home.tsx`、`client/src/storage/appStorage.ts`
|
||||
- **验收**:确认 `ThemeMode` 当前仅 `scenery/color`,并确认 `Home` 背景分支逻辑位置(方便插入 `suixin` 分支)
|
||||
|
||||
- [x] **T0.2 确认用户画像可在 Home 获取**
|
||||
- **涉及文件**:`client/src/storage/appStorage.ts`、`client/src/features/userProfileScoring/*`
|
||||
- **验收**:`getUserProfileScoring()` 在 Home 已可读到 `stage/need/emotion_score/profile_confidence/profile_answered`
|
||||
|
||||
## 1. 数据与存储层改造(ThemeMode + suixin state)
|
||||
|
||||
- [x] **T1.1 扩展 `ThemeMode` 枚举支持 `suixin`**
|
||||
- **涉及文件**:`client/src/storage/appStorage.ts`(类型 + `getThemeMode/setThemeMode` 兼容)
|
||||
- **要点**:
|
||||
- 新类型:`'scenery' | 'color' | 'suixin'`
|
||||
- `getThemeMode()` 读取到未知值时回退 `scenery`(保持兼容)
|
||||
- **验收**:TypeScript 编译无类型报错;旧存储值仍可正常读取
|
||||
|
||||
- [x] **T1.2 新增本地存储:`ui.theme.suixin.state`**
|
||||
- **涉及文件**:`client/src/storage/appStorage.ts`
|
||||
- **新增内容**:
|
||||
- `type SuixinThemeStateV1`
|
||||
- `getSuixinThemeState()` / `setSuixinThemeState()`(建议)
|
||||
- **验收**:能读写该 key;结构包含 `base_theme_id/seed/step_index/last_color`
|
||||
|
||||
## 2. 「随心」颜色算法模块(纯函数 + 可测试)
|
||||
|
||||
- [x] **T2.1 新增 `suixinTheme` 模块目录与色盘常量**
|
||||
- **建议路径**:`client/src/features/suixinTheme/`
|
||||
- **新增文件建议**:
|
||||
- `palette.ts`:Base Theme(5)+ Neutral(1)常量
|
||||
- `types.ts`:`BaseThemeId`、`SuixinThemeStateV1`(若不放在 storage)
|
||||
- **验收**:色值与 `设计说明文档/个性化背景颜色推荐算法.md` 完全一致
|
||||
|
||||
- [x] **T2.2 实现 Base Theme 选择(Theme Picking)**
|
||||
- **建议文件**:`client/src/features/suixinTheme/pickTheme.ts`
|
||||
- **规则**(必须对齐文档 Hard Rules):
|
||||
- `stage.unknown === 1` → `neutral`
|
||||
- `need` 为空 `{}` → `neutral`
|
||||
- 否则取 `Object.keys(need)[0]`,未知 key → `neutral`
|
||||
- **验收**:不同画像输入下输出主题 id 符合预期
|
||||
|
||||
- [x] **T2.3 实现线性 `lerp`(仅线性,禁止 easing)**
|
||||
- **建议文件**:`client/src/features/suixinTheme/colorMath.ts`
|
||||
- **要求**:
|
||||
- `hex ↔ rgb` 转换
|
||||
- `lerpRgb(a,b,t)`:\(t\in[0,1]\) clamp
|
||||
- 输出标准 `#RRGGBB`
|
||||
- **验收**:插值边界 t=0/1 输出正确;中间值可复现、无跳段
|
||||
|
||||
- [x] **T2.4 实现 \(t\) 生成(切文案步进 + 往返波形)**
|
||||
- **建议文件**:`client/src/features/suixinTheme/progress.ts`
|
||||
- **要求**:
|
||||
- `N=12` 可配置常量
|
||||
- 采用往返波形,避免回卷突跳
|
||||
- **验收**:连续 step_index 下 \(t\) 始终在 \([0,1]\),且相邻变化幅度稳定
|
||||
|
||||
- [x] **T2.5(可选增强)按 emotion/confidence 控制“动态增强开关”**
|
||||
- **说明**:一期允许先不做亮度微扰,只实现“禁动态开关”
|
||||
- **对齐点**:
|
||||
- 文档:`emotion_score ≤ 0.2` 禁止任何扰动
|
||||
- `emotion_score=null` 视为不确定,同样禁止扰动
|
||||
- **验收**:低情绪/不确定时不触发增强分支(一期未实现亮度微扰,默认不启用任何扰动)
|
||||
|
||||
## 3. UI:ThemeModal 增加「随心」入口
|
||||
|
||||
- [x] **T3.1 ThemeModal 新增第三个主题卡片**
|
||||
- **涉及文件**:`client/components/home/ThemeModal.tsx`
|
||||
- **要点**:
|
||||
- `ThemeMode` 类型同步为包含 `suixin`
|
||||
- 新增 `ThemeCard`:`onPress={() => onSelect('suixin')}`
|
||||
- 布局改为 3 卡可展示(`flexWrap`/调整 gap/尺寸),避免小屏溢出
|
||||
- 预览图占位(可先复用 `theme_color.png` 或新增 `theme_suixin.png`)
|
||||
- **验收**:弹窗可见第三项;选中态边框正确;无布局溢出
|
||||
|
||||
## 4. i18n:新增主题文案(TC/EN)
|
||||
|
||||
- [x] **T4.1 增加 `theme.suixin` 翻译键**
|
||||
- **涉及文件**:`client/src/i18n/locales/all.json`
|
||||
- **文案建议**:
|
||||
- `zh-TW`: `隨心`
|
||||
- `en`: `Ease`(语义化命名;如需改音译,后续可替换)
|
||||
- **验收**:切换语言后 ThemeModal 的第三项标题正确显示,不出现缺失 key
|
||||
|
||||
## 5. Home:随心主题渲染 + 冷启动/切文案计算
|
||||
|
||||
- [x] **T5.1 Home 增加 `suixin` 分支并使用 `backgroundColor`**
|
||||
- **涉及文件**:`client/app/(app)/home.tsx`
|
||||
- **要点**:
|
||||
- `themeMode === 'suixin'` 时,不渲染风景图
|
||||
- 背景色来自 suixin 状态(`last_color`)或初始化计算结果
|
||||
- **验收**:选择随心主题后背景变为算法输出色;切回风景/纯色逻辑不受影响
|
||||
|
||||
- [x] **T5.2 冷启动(进程级)计算并锁定 Base Theme**
|
||||
- **涉及文件**:`client/app/_layout.tsx`(或新增全局单例模块)、`client/app/(app)/home.tsx`
|
||||
- **要点**:
|
||||
- 生成一次 `boot_id`(仅内存)用于判断“本次进程首次进入 Home”
|
||||
- 首次进入 Home 且 theme=suixin:根据画像选 `base_theme_id` 并初始化 `seed/step_index/last_color`
|
||||
- 写入 `ui.theme.suixin.state` 持久化
|
||||
- **验收**:同一次运行内多次进入 Home 不重复“冷启动重算”;重启 App 后会重算一次
|
||||
|
||||
- [x] **T5.3 切换文案时更新 suixin 颜色(不跨主题)**
|
||||
- **涉及文件**:`client/app/(app)/home.tsx`
|
||||
- **要点**:
|
||||
- 在 `triggerNextContent` 切换 index 后:若 theme=suixin,`step_index += 1` → 计算 \(t\) → `lerp` 得到新 `last_color`
|
||||
- 持久化更新 state
|
||||
- **验收**:上滑切下一条文案时背景色小幅变化;Base Theme 不变(同一色系内变化)
|
||||
|
||||
## 6. 收藏背景记录兼容
|
||||
|
||||
- [x] **T6.1 收藏逻辑兼容 suixin**
|
||||
- **涉及文件**:`client/app/(app)/home.tsx`(`favItem.background` 写入)
|
||||
- **规则**:
|
||||
- `suixin` 与 `color` 一致:保存 `hex` 到 `background`
|
||||
- **验收**:收藏后在 Favorites 列表缩略卡片可正确显示背景色
|
||||
|
||||
## 7. 测试与回归
|
||||
|
||||
- [x] **T7.1(推荐)为 suixin 模块补单测**
|
||||
- **建议路径**:`client/src/features/suixinTheme/__tests__/`
|
||||
- **覆盖点**:
|
||||
- `stage.unknown=1` → neutral
|
||||
- `need={}` → neutral
|
||||
- `need={rest_balance:1}` → rest_balance
|
||||
- 往返波形 \(t\) 的边界与范围
|
||||
- `lerp` 边界与格式
|
||||
- **验收**:测试通过,避免回归
|
||||
|
||||
- [x] **T7.2 手动验收(按 spec)**
|
||||
- **验收清单**:
|
||||
- ThemeModal:三主题可切换,选中态正确
|
||||
- 随心:冷启动首次进入 Home 生效;切文案时同主题内变色
|
||||
- Hard Rules:unknown / need 跳过 → Neutral
|
||||
- 多语言:TC/EN 标题正确
|
||||
- **备注**:已完成自动化回归(`vitest` + `tsc`);如需视觉确认可在模拟器/真机打开 ThemeModal 与 Home 做肉眼验收
|
||||
|
||||
## 8. 收尾:overview.md 标记
|
||||
|
||||
- [x] **T8.1 tasks 全部完成后更新 `spec_kit/overview.md`**
|
||||
- **位置**:`## SuixinTheme` 条目
|
||||
- **内容**:补充“已完成编码(全部)”与关键变更文件清单(可选)
|
||||
- **验收**:overview 总览可读、可追踪
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
- iOS 构建号已提升到 `2`,并将 `client/ios/client/Info.plist` 改为自动跟随 `MARKETING_VERSION` / `CURRENT_PROJECT_VERSION`
|
||||
- 推送 entitlements 的 `aps-environment` 已切到 `production`(用于 TestFlight/线上包)
|
||||
- 清理未接入编译的 WidgetKit 骨架残留:移除磁盘上的 `client/ios/MindfulnessWidget/` 文件,并从 `client/ios/client.xcodeproj/project.pbxproj` 删除对应工程引用(避免 Xcode 显示幽灵文件)
|
||||
- 修复 Xcode Archive 偶发显示 “Generic Xcode Archive”:在共享 scheme `Hey Mama` 的 Archive Post-actions 自动补齐 `.xcarchive/Info.plist` 的 `ApplicationProperties`,并在缺失时补齐 `Name`/`SchemeName` + 自检提示(根治 Organizer 无法识别主 App、无法分发/上传 TestFlight 的问题)
|
||||
|
||||
## Splash Consent
|
||||
|
||||
@@ -97,6 +98,8 @@
|
||||
- `spec_kit/Splash Consent/spec.md`
|
||||
- `spec_kit/Splash Consent/plan.md`
|
||||
- `spec_kit/Splash Consent/tasks.md`
|
||||
- **近期变更**:
|
||||
- 启动流程优化:将 Expo Router 初始路由调整为协议页 `/(splash)/splash`,并在协议页已同意时直接分发到 `/(app)/home` 或 `/(onboarding)/onboarding`,避免系统开屏结束后先渲染 `index`(转圈页)再跳协议页导致的“闪一下”
|
||||
|
||||
## Policy Links
|
||||
|
||||
@@ -165,3 +168,30 @@
|
||||
- `spec_kit/User Profile Scoring/spec.md`
|
||||
- **已完成编码(阶段性)**:
|
||||
- 客户端 Onboarding 完成时已收集问卷答案并生成用户画像,写入本地存储供推荐/Push/Widget 复用
|
||||
|
||||
## SuixinTheme
|
||||
|
||||
- **目标**:在 Home 现有「风景 / 纯色」主题基础上新增「随心」主题,背景颜色根据用户问卷画像个性化推荐
|
||||
- **核心范围**:复用既有 Base Theme(5 套)+ Neutral(1 套),按 `need/stage/emotion/confidence` 选色并做 session 锁定(Theme Lock),支持 TC/EN 文案
|
||||
- **阶段产物**:
|
||||
- `spec_kit/SuixinTheme/spec.md`
|
||||
- `spec_kit/SuixinTheme/plan.md`
|
||||
- `spec_kit/SuixinTheme/tasks.md`
|
||||
- **已完成编码(全部)**:
|
||||
- 客户端新增第三主题 `suixin`(随心/Ease),与现有主题切换入口一致
|
||||
- Home:冷启动会话内锁定 Base Theme;切换文案时在同主题内线性插值输出纯色背景,并持久化 `ui.theme.suixin.state`
|
||||
- i18n:新增 `theme.suixin`(TC/EN)
|
||||
- 测试:新增随心模块单测(Vitest)并通过;`tsc --noEmit` 通过
|
||||
- **变更文件**:
|
||||
- `client/src/storage/appStorage.ts`
|
||||
- `client/app/(app)/home.tsx`
|
||||
- `client/components/home/ThemeModal.tsx`
|
||||
- `client/src/i18n/locales/all.json`
|
||||
- `client/src/utils/bootSession.ts`
|
||||
- `client/src/features/suixinTheme/palette.ts`
|
||||
- `client/src/features/suixinTheme/colorMath.ts`
|
||||
- `client/src/features/suixinTheme/progress.ts`
|
||||
- `client/src/features/suixinTheme/pickTheme.ts`
|
||||
- `client/src/features/suixinTheme/index.ts`
|
||||
- `client/src/features/suixinTheme/__tests__/suixinTheme.test.ts`
|
||||
- `client/app/_layout.tsx`
|
||||
|
||||
184
设计说明文档/个性化背景颜色推荐算法.md
Normal file
184
设计说明文档/个性化背景颜色推荐算法.md
Normal file
@@ -0,0 +1,184 @@
|
||||
🎨 个性化文案背景颜色推荐算法
|
||||
V1.2(文案滑动优化版)
|
||||
|
||||
适用范围(明确收敛)
|
||||
|
||||
✅ 仅用于「文案阅读页」
|
||||
|
||||
✅ 支持上下滑动阅读
|
||||
|
||||
❌ 不用于首页 / 列表 / 卡片 / CTA
|
||||
|
||||
❌ 不承担转化或引导职责
|
||||
|
||||
一、设计目标(V1.2 更新)
|
||||
|
||||
在 V1.1 基础上,新增以下目标:
|
||||
|
||||
阅读优先:颜色永远服务于“读下去”,而非“制造变化”
|
||||
|
||||
滑动即流动:通过连续渐变营造沉浸感,而非主题切换
|
||||
|
||||
低感知变化:用户能感到“舒服”,但不意识到颜色在变
|
||||
|
||||
核心原则不变:
|
||||
不通过颜色制造新的情绪判断
|
||||
|
||||
二、颜色数量策略(V1.2 明确约束)
|
||||
2.1 颜色主题数量(不变)
|
||||
|
||||
Need Theme:5 套(不新增)
|
||||
|
||||
Neutral Theme:1 套
|
||||
|
||||
总计:6 套背景主题
|
||||
|
||||
❗️V1.2 明确约束:
|
||||
在一次文案阅读 session 内,不允许切换主题色系
|
||||
|
||||
三、Base Theme(保持不变,仅重申)
|
||||
{
|
||||
"emotional_support": ["#F6DCE4", "#FFEFF4", "#FFF7FA"],
|
||||
"parenting_pressure": ["#D6EAF5", "#EEF6FB", "#F8FCFF"],
|
||||
"self_worth": ["#FFD8A8", "#FFE8C9", "#FFF6E5"],
|
||||
"anxiety_relief": ["#DFF3EA", "#ECFBF6", "#F6FFFB"],
|
||||
"rest_balance": ["#F2E6D8", "#FAF3EC", "#FFFDF9"]
|
||||
}
|
||||
|
||||
Neutral Theme:
|
||||
["#F4F7F2", "#E8F1EC", "#EDF4F8"]
|
||||
|
||||
四、V1.2 新增:滑动专用渐变规则(重点修改)
|
||||
4.1 滑动只允许「同主题内部变化」
|
||||
|
||||
禁止行为(V1.2 明令禁止):
|
||||
|
||||
❌ 滑动到不同文案 → 切换 Base Theme
|
||||
|
||||
❌ 根据滑动进度改变 need / 情绪语义
|
||||
|
||||
❌ 滑动触发颜色“跳段”
|
||||
|
||||
允许行为:
|
||||
|
||||
✅ 同一 Base Theme 内做连续插值
|
||||
|
||||
✅ 渐变重心随 scroll 微移
|
||||
|
||||
4.2 渐变插值模型(保持线性)
|
||||
Color(t) = lerp(Color_top, Color_bottom, t)
|
||||
t = scroll_offset / content_height
|
||||
t ∈ [0,1]
|
||||
|
||||
|
||||
约束(Hard Rule):
|
||||
|
||||
仅允许 线性 lerp
|
||||
|
||||
禁止 easing / bounce / overshoot
|
||||
|
||||
禁止非连续函数
|
||||
|
||||
五、V1.2 新增:渐变“质感层”增强(不增加颜色数量)
|
||||
|
||||
⚠️ 本节为「可选增强」,不影响语义判断
|
||||
|
||||
5.1 渐变重心微移(推荐)
|
||||
|
||||
随 scroll,渐变中段色的占比 ±5% 内浮动
|
||||
|
||||
不改变颜色值,仅改变 stop 分布
|
||||
|
||||
目的:
|
||||
|
||||
提升阅读流动感
|
||||
|
||||
避免“静态模板感”
|
||||
|
||||
5.2 亮度微扰(极弱)
|
||||
ΔL ≤ ±2%
|
||||
|
||||
|
||||
触发条件:
|
||||
|
||||
emotion_score ∈ [0.3, 0.6]
|
||||
|
||||
profile_confidence ≥ 0.6
|
||||
|
||||
⚠️ emotion ≤ 0.3 时 禁止任何亮度扰动
|
||||
|
||||
六、Session 稳定性规则(V1.2 新增)
|
||||
6.1 主题锁定(Theme Lock)
|
||||
|
||||
在以下条件下 锁定 Base Theme:
|
||||
|
||||
用户进入文案页
|
||||
|
||||
直到退出文案页或 session 结束
|
||||
|
||||
即使:
|
||||
|
||||
用户画像更新
|
||||
|
||||
滑动到新文案
|
||||
|
||||
➡ Base Theme 不变
|
||||
|
||||
6.2 Session 内禁止重新采样
|
||||
|
||||
不允许重新随机
|
||||
|
||||
不允许重新计算 need
|
||||
|
||||
不允许跨 need 插值
|
||||
|
||||
七、Emotion / Confidence 调节(保持 V1.1)
|
||||
|
||||
本节逻辑不变,仅声明适用范围
|
||||
|
||||
emotion_score
|
||||
→ 仅影响 饱和度 / 亮度 / 动态强度
|
||||
|
||||
profile_confidence
|
||||
→ 仅影响 个性化混合比例
|
||||
|
||||
不允许:
|
||||
|
||||
emotion 导致色系改变
|
||||
|
||||
confidence 导致主题切换
|
||||
|
||||
八、Hard Visual Rules(V1.2 汇总)
|
||||
条件 强制规则
|
||||
mom_stage = unknown 强制 Neutral Theme
|
||||
emotion_score ≤ 0.2 禁止高饱和 / 禁止动态
|
||||
profile_confidence ≤ 0.4 最大饱和度 ≤ 60%
|
||||
文案页 session 中 禁止 Base Theme 切换
|
||||
|
||||
Hard Rules 优先于任何计算结果。
|
||||
|
||||
九、V1.2 Pipeline(更新版)
|
||||
进入文案页
|
||||
↓
|
||||
读取用户画像 U
|
||||
↓
|
||||
need → Base Theme(或 Neutral)
|
||||
↓
|
||||
锁定 Base Theme(session)
|
||||
↓
|
||||
emotion / confidence → 强度调节
|
||||
↓
|
||||
scroll → 线性渐变插值 + 微质感
|
||||
↓
|
||||
输出连续背景颜色
|
||||
|
||||
十、V1.2 设计总结(评审友好版)
|
||||
|
||||
颜色数量不多,是刻意选择
|
||||
|
||||
变化来自滑动,不来自判断
|
||||
|
||||
颜色不“解释”用户,只“陪伴”用户
|
||||
|
||||
在文案阅读场景中,
|
||||
稳定本身就是高级体验。
|
||||
Reference in New Issue
Block a user