chore: vendor client into main repo

This commit is contained in:
吕新雨
2026-01-28 22:54:21 +08:00
parent 7d743e78ea
commit 6a598f0a98
79 changed files with 12952 additions and 1 deletions

View File

@@ -0,0 +1,33 @@
/**
* 客户端环境变量统一入口Expo 推荐使用 EXPO_PUBLIC_ 前缀)
*
* 注意:
* - 这里读取的是构建时/运行时注入的环境变量EXPO_PUBLIC_*
* - 真机联调时不要用 localhost改为电脑局域网 IP例如http://192.168.1.10:8000
*/
export type AppEnv = 'dev' | 'prod';
function getRequiredEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`缺少环境变量:${name}(请检查 .env 配置)`);
}
return value;
}
function getOptionalEnv(name: string, fallback: string): string {
return process.env[name] ?? fallback;
}
export const APP_ENV = (getOptionalEnv('EXPO_PUBLIC_ENV', 'dev') as AppEnv) ?? 'dev';
export const API_BASE_URL = getRequiredEnv('EXPO_PUBLIC_API_BASE_URL');
/**
* 默认语言策略:
* - auto优先设备语言支持列表内时否则回退 zh-CN
* - zh-CN/en/es/pt/zh-TW固定默认语言仍允许用户在设置中手动切换并持久化
*/
export const DEFAULT_LANGUAGE = getOptionalEnv('EXPO_PUBLIC_DEFAULT_LANGUAGE', 'auto');

View File

@@ -0,0 +1,16 @@
export type MockContentItem = {
id: string;
text: string;
};
/**
* 本地 mock 内容(后续接后端时可替换)
*/
export const MOCK_CONTENT: MockContentItem[] = [
{ id: 'c1', text: '你已经很努力了,今天也值得被温柔对待。' },
{ id: 'c2', text: '深呼吸三次,把注意力带回当下。' },
{ id: 'c3', text: '允许自己慢一点,情绪会像云一样飘过。' },
{ id: 'c4', text: '你不需要完美,你已经足够好。' },
{ id: 'c5', text: '把手放在心口,对自己说一句:辛苦了。' }
];

123
client/src/i18n/index.ts Normal file
View File

@@ -0,0 +1,123 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as Localization from 'expo-localization';
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import en from './locales/en.json';
import es from './locales/es.json';
import pt from './locales/pt.json';
import zhCN from './locales/zh-CN.json';
import zhTW from './locales/zh-TW.json';
/**
* 语言码约定:
* - 简体中文zh-CN
* - 繁体中文zh-TW
* - 英语en
* - 西班牙语es
* - 葡萄牙语pt
*/
export type AppLanguage = 'zh-CN' | 'zh-TW' | 'en' | 'es' | 'pt';
export const SUPPORTED_LANGUAGES: readonly AppLanguage[] = [
'zh-CN',
'zh-TW',
'en',
'es',
'pt',
] as const;
const DEFAULT_FALLBACK_LANGUAGE: AppLanguage = 'zh-CN';
const STORAGE_KEY_LANGUAGE = 'settings.language';
function isSupportedLanguage(lang: string): lang is AppLanguage {
return (SUPPORTED_LANGUAGES as readonly string[]).includes(lang);
}
function normalizeDeviceLanguageTagToAppLanguage(languageTag: string): AppLanguage {
const tag = languageTag.toLowerCase();
// 中文:优先区分繁简
if (tag.startsWith('zh')) {
// 常见繁体标记zh-TW / zh-HK / zh-Hant
if (tag.includes('tw') || tag.includes('hk') || tag.includes('hant')) {
return 'zh-TW';
}
return 'zh-CN';
}
// 其他语言:按前缀匹配
if (tag.startsWith('en')) return 'en';
if (tag.startsWith('es')) return 'es';
if (tag.startsWith('pt')) return 'pt';
return DEFAULT_FALLBACK_LANGUAGE;
}
function getDeviceLanguage(): AppLanguage {
// expo-localization 返回系统 locale 列表,取第一个作为当前设备偏好
const locales = Localization.getLocales();
const first = locales?.[0]?.languageTag;
if (first) {
return normalizeDeviceLanguageTagToAppLanguage(first);
}
// 兜底:少数情况下 locales 为空
return DEFAULT_FALLBACK_LANGUAGE;
}
export async function getLanguagePreference(): Promise<AppLanguage | null> {
const value = await AsyncStorage.getItem(STORAGE_KEY_LANGUAGE);
if (!value) return null;
return isSupportedLanguage(value) ? value : null;
}
export async function setLanguagePreference(lang: AppLanguage): Promise<void> {
await AsyncStorage.setItem(STORAGE_KEY_LANGUAGE, lang);
}
export async function clearLanguagePreference(): Promise<void> {
await AsyncStorage.removeItem(STORAGE_KEY_LANGUAGE);
}
/**
* 初始化 i18n只会初始化一次
*
* 语言选择优先级:
* 1) 用户设置(若存在)
* 2) 设备语言(在支持列表内时生效;否则会被 normalize 到默认回退)
* 3) 默认回退zh-CN
*/
export async function initI18n(): Promise<void> {
if (i18n.isInitialized) return;
const userLang = await getLanguagePreference();
const deviceLang = getDeviceLanguage();
const initialLang = userLang ?? deviceLang;
await i18n.use(initReactI18next).init({
resources: {
'zh-CN': { translation: zhCN },
'zh-TW': { translation: zhTW },
en: { translation: en },
es: { translation: es },
pt: { translation: pt },
},
lng: initialLang,
fallbackLng: DEFAULT_FALLBACK_LANGUAGE,
interpolation: {
// React Native 不需要对内容做 HTML 转义
escapeValue: false,
},
});
}
/**
* 用户手动切换语言:立即生效 + 持久化
*/
export async function changeLanguage(lang: AppLanguage): Promise<void> {
await setLanguagePreference(lang);
await i18n.changeLanguage(lang);
}

View File

@@ -0,0 +1,50 @@
{
"common": {
"ok": "OK",
"cancel": "Cancel"
},
"onboarding": {
"title": "Welcome",
"progress": "{{current}}/{{total}}",
"next": "Next",
"skip": "Skip",
"skipAll": "Skip onboarding",
"q1Title": "How are you feeling lately?",
"q1Desc": "No right or wrong. You can skip and adjust later.",
"q2Title": "What kind of support do you want?",
"q2Desc": "For example: gentle reminders, mindfulness, emotional support.",
"q3Title": "When do you need comfort the most?",
"q3Desc": "Morning, afternoon, late night, or specific moments.",
"q4Title": "A gentle sentence for yourself",
"q4Desc": "You can skip. Well stay with you along the way."
},
"push": {
"title": "Notifications",
"cardTitle": "Turn on gentle reminders",
"cardDesc": "Well send a short mindful phrase when you may need it. You can change this anytime in Settings.",
"enable": "Enable",
"later": "Later",
"loading": "Working…",
"errorTitle": "Notice",
"errorDesc": "Its okay if enabling fails. You can keep using the app."
},
"home": {
"title": "Mindfulness",
"like": "Like",
"dislike": "Dislike",
"favorites": "Favorites",
"settings": "Settings"
},
"favorites": {
"title": "Favorites",
"empty": "No favorites yet."
},
"settings": {
"title": "Settings",
"language": "Language",
"version": "Version",
"widgetTitle": "iOS Widget",
"widgetDesc": "Put gentle reminders on your home screen: long-press → tap “+” → search “Mindfulness” → add a size you like."
}
}

View File

@@ -0,0 +1,50 @@
{
"common": {
"ok": "Aceptar",
"cancel": "Cancelar"
},
"onboarding": {
"title": "Bienvenida",
"progress": "{{current}}/{{total}}",
"next": "Siguiente",
"skip": "Saltar",
"skipAll": "Saltar introducción",
"q1Title": "¿Cómo te sientes últimamente?",
"q1Desc": "No hay respuestas correctas. Puedes saltar y ajustar después.",
"q2Title": "¿Qué tipo de apoyo quieres?",
"q2Desc": "Por ejemplo: recordatorios suaves, mindfulness, apoyo emocional.",
"q3Title": "¿Cuándo necesitas más consuelo?",
"q3Desc": "Mañana, tarde, noche o momentos específicos.",
"q4Title": "Una frase amable para ti",
"q4Desc": "Puedes saltar. Te acompañaremos en el camino."
},
"push": {
"title": "Notificaciones",
"cardTitle": "Activar recordatorios suaves",
"cardDesc": "Te enviaremos una frase breve cuando lo necesites. Puedes cambiarlo en Ajustes.",
"enable": "Activar",
"later": "Más tarde",
"loading": "Procesando…",
"errorTitle": "Aviso",
"errorDesc": "No pasa nada si falla. Puedes seguir usando la app."
},
"home": {
"title": "Mindfulness",
"like": "Me gusta",
"dislike": "No me gusta",
"favorites": "Favoritos",
"settings": "Ajustes"
},
"favorites": {
"title": "Favoritos",
"empty": "Aún no hay favoritos."
},
"settings": {
"title": "Ajustes",
"language": "Idioma",
"version": "Versión",
"widgetTitle": "Widget de iOS",
"widgetDesc": "Pon recordatorios en tu pantalla: mantén pulsado → “+” → busca “Mindfulness” → añade el tamaño."
}
}

View File

@@ -0,0 +1,50 @@
{
"common": {
"ok": "OK",
"cancel": "Cancelar"
},
"onboarding": {
"title": "Bem-vinda",
"progress": "{{current}}/{{total}}",
"next": "Próximo",
"skip": "Pular",
"skipAll": "Pular introdução",
"q1Title": "Como você tem se sentido ultimamente?",
"q1Desc": "Não há certo ou errado. Você pode pular e ajustar depois.",
"q2Title": "Que tipo de apoio você quer?",
"q2Desc": "Por exemplo: lembretes gentis, mindfulness, apoio emocional.",
"q3Title": "Quando você mais precisa de conforto?",
"q3Desc": "Manhã, tarde, noite ou momentos específicos.",
"q4Title": "Uma frase gentil para você",
"q4Desc": "Você pode pular. Vamos seguir com você no caminho."
},
"push": {
"title": "Notificações",
"cardTitle": "Ativar lembretes gentis",
"cardDesc": "Enviaremos uma frase curta quando você precisar. Você pode mudar isso em Configurações.",
"enable": "Ativar",
"later": "Depois",
"loading": "Processando…",
"errorTitle": "Aviso",
"errorDesc": "Tudo bem se falhar. Você pode continuar usando o app."
},
"home": {
"title": "Mindfulness",
"like": "Curtir",
"dislike": "Não curtir",
"favorites": "Favoritos",
"settings": "Configurações"
},
"favorites": {
"title": "Favoritos",
"empty": "Ainda não há favoritos."
},
"settings": {
"title": "Configurações",
"language": "Idioma",
"version": "Versão",
"widgetTitle": "Widget do iOS",
"widgetDesc": "Coloque lembretes na tela inicial: pressione e segure → “+” → procure “Mindfulness” → adicione o tamanho."
}
}

View File

@@ -0,0 +1,50 @@
{
"common": {
"ok": "确定",
"cancel": "取消"
},
"onboarding": {
"title": "欢迎",
"progress": "{{current}}/{{total}}",
"next": "下一步",
"skip": "跳过",
"skipAll": "跳过整个引导",
"q1Title": "你最近的感受更接近哪一种?",
"q1Desc": "没有对错,你可以跳过,之后也可以慢慢调整。",
"q2Title": "你更希望获得哪种支持?",
"q2Desc": "例如:温柔提醒、正念练习、情绪陪伴。",
"q3Title": "你通常在什么时候最需要被安慰?",
"q3Desc": "比如:清晨、午后、深夜,或某些特定时刻。",
"q4Title": "给自己一句温柔的话",
"q4Desc": "你可以直接跳过,我们会在之后继续陪你。"
},
"push": {
"title": "通知",
"cardTitle": "开启温柔提醒",
"cardDesc": "我们会在你需要的时候,送上一句正念短句或温柔提醒(可随时在设置中调整)。",
"enable": "立即开启",
"later": "稍后",
"loading": "处理中…",
"errorTitle": "提示",
"errorDesc": "开启失败也没关系,你仍然可以继续使用应用。"
},
"home": {
"title": "正念",
"like": "点赞",
"dislike": "讨厌",
"favorites": "收藏",
"settings": "设置"
},
"favorites": {
"title": "收藏夹",
"empty": "这里还没有收藏内容。"
},
"settings": {
"title": "设置",
"language": "语言",
"version": "版本",
"widgetTitle": "iOS 小组件",
"widgetDesc": "把温柔提醒放到桌面上:长按主屏幕 → 点“+” → 搜索“正念” → 添加你喜欢的尺寸。"
}
}

View File

@@ -0,0 +1,50 @@
{
"common": {
"ok": "確定",
"cancel": "取消"
},
"onboarding": {
"title": "歡迎",
"progress": "{{current}}/{{total}}",
"next": "下一步",
"skip": "跳過",
"skipAll": "跳過整個引導",
"q1Title": "你最近的感受更接近哪一種?",
"q1Desc": "沒有對錯,你可以跳過,之後也能慢慢調整。",
"q2Title": "你更希望獲得哪種支持?",
"q2Desc": "例如:溫柔提醒、正念練習、情緒陪伴。",
"q3Title": "你通常在什麼時候最需要被安慰?",
"q3Desc": "例如:清晨、午后、深夜,或某些特定時刻。",
"q4Title": "給自己一句溫柔的話",
"q4Desc": "你可以直接跳過,我們會在之後繼續陪你。"
},
"push": {
"title": "通知",
"cardTitle": "開啟溫柔提醒",
"cardDesc": "我們會在你需要的時候,送上一句正念短句或溫柔提醒(可隨時在設定中調整)。",
"enable": "立即開啟",
"later": "稍後",
"loading": "處理中…",
"errorTitle": "提示",
"errorDesc": "開啟失敗也沒關係,你仍然可以繼續使用應用。"
},
"home": {
"title": "正念",
"like": "喜歡",
"dislike": "不喜歡",
"favorites": "收藏",
"settings": "設定"
},
"favorites": {
"title": "收藏夾",
"empty": "這裡還沒有收藏內容。"
},
"settings": {
"title": "設定",
"language": "語言",
"version": "版本",
"widgetTitle": "iOS 小工具",
"widgetDesc": "把溫柔提醒放到桌面上:長按主畫面 → 點「+」 → 搜尋「正念」 → 添加你喜歡的尺寸。"
}
}

View File

@@ -0,0 +1,67 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
/**
* 本地存储 key 统一管理,避免 UI 里散落硬编码
*/
const KEY_ONBOARDING_COMPLETED = 'onboarding.completed';
const KEY_PUSH_PROMPT_STATE = 'push.promptState';
const KEY_CONTENT_REACTIONS = 'content.reactions';
const KEY_FAVORITES_ITEMS = 'favorites.items';
export type PushPromptState = 'enabled' | 'skipped' | 'unknown';
export type Reaction = 'like' | 'dislike';
export type ReactionsMap = Record<string, Reaction>;
async function getJson<T>(key: string, fallback: T): Promise<T> {
const raw = await AsyncStorage.getItem(key);
if (!raw) return fallback;
try {
return JSON.parse(raw) as T;
} catch {
return fallback;
}
}
async function setJson<T>(key: string, value: T): Promise<void> {
await AsyncStorage.setItem(key, JSON.stringify(value));
}
export async function getOnboardingCompleted(): Promise<boolean> {
const raw = await AsyncStorage.getItem(KEY_ONBOARDING_COMPLETED);
return raw === 'true';
}
export async function setOnboardingCompleted(completed: boolean): Promise<void> {
await AsyncStorage.setItem(KEY_ONBOARDING_COMPLETED, completed ? 'true' : 'false');
}
export async function getPushPromptState(): Promise<PushPromptState> {
const raw = await AsyncStorage.getItem(KEY_PUSH_PROMPT_STATE);
if (raw === 'enabled' || raw === 'skipped' || raw === 'unknown') return raw;
return 'unknown';
}
export async function setPushPromptState(state: PushPromptState): Promise<void> {
await AsyncStorage.setItem(KEY_PUSH_PROMPT_STATE, state);
}
export async function getReactions(): Promise<ReactionsMap> {
return await getJson<ReactionsMap>(KEY_CONTENT_REACTIONS, {});
}
export async function setReaction(contentId: string, reaction: Reaction): Promise<void> {
const next = await getReactions();
next[contentId] = reaction;
await setJson(KEY_CONTENT_REACTIONS, next);
}
export async function getFavorites(): Promise<string[]> {
return await getJson<string[]>(KEY_FAVORITES_ITEMS, []);
}
export async function addFavorite(contentId: string): Promise<void> {
const list = await getFavorites();
if (list.includes(contentId)) return;
await setJson(KEY_FAVORITES_ITEMS, [...list, contentId]);
}