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,50 @@
import { Stack } from 'expo-router';
import { Pressable, Text } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useRouter } from 'expo-router';
export default function AppLayout() {
const router = useRouter();
const { t } = useTranslation();
return (
<Stack
screenOptions={{
headerTitleAlign: 'center',
}}
>
<Stack.Screen
name="home"
options={{
title: t('home.title'),
headerRight: () => (
<Pressable
onPress={() => router.push('/(app)/settings')}
hitSlop={10}
>
<Text>{t('home.settings')}</Text>
</Pressable>
),
headerLeft: () => (
<Pressable onPress={() => router.push('/(app)/favorites')} hitSlop={10}>
<Text>{t('home.favorites')}</Text>
</Pressable>
),
}}
/>
<Stack.Screen
name="favorites"
options={{
title: t('favorites.title'),
}}
/>
<Stack.Screen
name="settings"
options={{
title: t('settings.title'),
}}
/>
</Stack>
);
}

View File

@@ -0,0 +1,61 @@
import { useEffect, useMemo, useState } from 'react';
import { FlatList, StyleSheet, Text, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { MOCK_CONTENT } from '@/src/constants/mockContent';
import { getFavorites } from '@/src/storage/appStorage';
export default function FavoritesScreen() {
const { t } = useTranslation();
const [ids, setIds] = useState<string[]>([]);
useEffect(() => {
let cancelled = false;
(async () => {
const list = await getFavorites();
if (!cancelled) setIds(list);
})();
return () => {
cancelled = true;
};
}, []);
const items = useMemo(() => {
const map = new Map(MOCK_CONTENT.map((c) => [c.id, c]));
return ids.map((id) => map.get(id)).filter(Boolean) as { id: string; text: string }[];
}, [ids]);
return (
<View style={styles.container}>
{items.length === 0 ? (
<Text style={styles.empty}>{t('favorites.empty')}</Text>
) : (
<FlatList
data={items}
keyExtractor={(it) => it.id}
contentContainerStyle={styles.list}
renderItem={({ item }) => (
<View style={styles.row}>
<Text style={styles.text}>{item.text}</Text>
</View>
)}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 16 },
empty: { color: '#6B7280', fontSize: 16, textAlign: 'center', marginTop: 40 },
list: { gap: 12, paddingBottom: 24 },
row: {
borderRadius: 14,
padding: 16,
backgroundColor: '#F9FAFB',
borderWidth: StyleSheet.hairlineWidth,
borderColor: '#E5E7EB',
},
text: { color: '#111827', fontSize: 16, lineHeight: 22 },
});

76
client/app/(app)/home.tsx Normal file
View File

@@ -0,0 +1,76 @@
import { useMemo, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { MOCK_CONTENT } from '@/src/constants/mockContent';
import { addFavorite, setReaction } from '@/src/storage/appStorage';
export default function HomeScreen() {
const { t } = useTranslation();
const [index, setIndex] = useState(0);
const item = useMemo(() => MOCK_CONTENT[index % MOCK_CONTENT.length], [index]);
async function onLike() {
await setReaction(item.id, 'like');
await addFavorite(item.id);
setIndex((i) => i + 1);
}
async function onDislike() {
await setReaction(item.id, 'dislike');
setIndex((i) => i + 1);
}
return (
<View style={styles.container}>
<View style={styles.card}>
<Text style={styles.text}>{item.text}</Text>
</View>
<View style={styles.actions}>
<Pressable style={[styles.button, styles.dislike]} onPress={onDislike}>
<Text style={styles.buttonText}>{t('home.dislike')}</Text>
</Pressable>
<Pressable style={[styles.button, styles.like]} onPress={onLike}>
<Text style={styles.buttonText}>{t('home.like')}</Text>
</Pressable>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
justifyContent: 'center',
gap: 16,
},
card: {
borderRadius: 16,
padding: 20,
backgroundColor: '#F6F7FB',
borderWidth: StyleSheet.hairlineWidth,
borderColor: '#E5E7EB',
},
text: {
fontSize: 20,
lineHeight: 28,
color: '#111827',
},
actions: {
flexDirection: 'row',
gap: 12,
},
button: {
flex: 1,
paddingVertical: 14,
borderRadius: 14,
alignItems: 'center',
},
like: { backgroundColor: '#16A34A' },
dislike: { backgroundColor: '#EF4444' },
buttonText: { color: 'white', fontSize: 16, fontWeight: '600' },
});

View File

@@ -0,0 +1,50 @@
import Constants from 'expo-constants';
import { StyleSheet, Text, View } from 'react-native';
import { useTranslation } from 'react-i18next';
export default function SettingsScreen() {
const { t } = useTranslation();
const version =
Constants.expoConfig?.version ??
// 兜底:部分环境下 expoConfig 可能为空
'unknown';
return (
<View style={styles.container}>
<View style={styles.section}>
<Text style={styles.label}>{t('settings.version')}</Text>
<Text style={styles.value}>{version}</Text>
</View>
<View style={styles.card}>
<Text style={styles.cardTitle}>{t('settings.widgetTitle')}</Text>
<Text style={styles.cardText}>{t('settings.widgetDesc')}</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 16, gap: 16 },
section: {
borderRadius: 14,
padding: 16,
backgroundColor: '#FFFFFF',
borderWidth: StyleSheet.hairlineWidth,
borderColor: '#E5E7EB',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
label: { color: '#374151', fontSize: 16 },
value: { color: '#111827', fontSize: 16, fontWeight: '600' },
card: {
borderRadius: 16,
padding: 16,
backgroundColor: '#111827',
},
cardTitle: { color: 'white', fontSize: 16, fontWeight: '700', marginBottom: 8 },
cardText: { color: '#E5E7EB', fontSize: 14, lineHeight: 20 },
});

View File

@@ -0,0 +1,18 @@
import { Stack } from 'expo-router';
import { useTranslation } from 'react-i18next';
export default function OnboardingLayout() {
const { t } = useTranslation();
return (
<Stack
screenOptions={{
headerTitleAlign: 'center',
}}
>
<Stack.Screen name="onboarding" options={{ title: t('onboarding.title') }} />
<Stack.Screen name="push-prompt" options={{ title: t('push.title') }} />
</Stack>
);
}

View File

@@ -0,0 +1,104 @@
import { useMemo, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useRouter } from 'expo-router';
import { useTranslation } from 'react-i18next';
import { setOnboardingCompleted } from '@/src/storage/appStorage';
type OnboardingPage = {
title: string;
desc: string;
};
export default function OnboardingScreen() {
const router = useRouter();
const { t } = useTranslation();
// 35 页:这里默认 4 页,后续可按产品调整为 3 或 5
const pages = useMemo<OnboardingPage[]>(
() => [
{ title: t('onboarding.q1Title'), desc: t('onboarding.q1Desc') },
{ title: t('onboarding.q2Title'), desc: t('onboarding.q2Desc') },
{ title: t('onboarding.q3Title'), desc: t('onboarding.q3Desc') },
{ title: t('onboarding.q4Title'), desc: t('onboarding.q4Desc') }
],
[t]
);
const total = pages.length;
const [step, setStep] = useState(0);
const page = pages[Math.min(step, total - 1)];
async function finishAndNext() {
await setOnboardingCompleted(true);
router.replace('/(onboarding)/push-prompt');
}
async function onNext() {
if (step >= total - 1) {
await finishAndNext();
return;
}
setStep((s) => s + 1);
}
async function onSkipPage() {
// 每页可跳过:直接进入下一页(最后一页则结束)
await onNext();
}
async function onSkipAll() {
// 一键跳过整个 Onboarding
await finishAndNext();
}
return (
<View style={styles.container}>
<Text style={styles.progress}>
{t('onboarding.progress', { current: step + 1, total })}
</Text>
<View style={styles.card}>
<Text style={styles.title}>{page.title}</Text>
<Text style={styles.desc}>{page.desc}</Text>
</View>
<View style={styles.actions}>
<Pressable style={[styles.btn, styles.secondary]} onPress={onSkipPage}>
<Text style={[styles.btnText, styles.secondaryText]}>{t('onboarding.skip')}</Text>
</Pressable>
<Pressable style={[styles.btn, styles.primary]} onPress={onNext}>
<Text style={styles.btnText}>{t('onboarding.next')}</Text>
</Pressable>
</View>
<Pressable style={styles.skipAll} onPress={onSkipAll}>
<Text style={styles.skipAllText}>{t('onboarding.skipAll')}</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 20, justifyContent: 'center', gap: 16 },
progress: { textAlign: 'center', color: '#6B7280' },
card: {
borderRadius: 18,
padding: 20,
backgroundColor: '#FFFFFF',
borderWidth: StyleSheet.hairlineWidth,
borderColor: '#E5E7EB',
gap: 10
},
title: { fontSize: 22, fontWeight: '700', color: '#111827' },
desc: { fontSize: 16, lineHeight: 22, color: '#374151' },
actions: { flexDirection: 'row', gap: 12 },
btn: { flex: 1, paddingVertical: 14, borderRadius: 14, alignItems: 'center' },
primary: { backgroundColor: '#111827' },
secondary: { backgroundColor: '#F3F4F6' },
btnText: { fontSize: 16, fontWeight: '600', color: '#FFFFFF' },
secondaryText: { color: '#111827' },
skipAll: { alignItems: 'center', paddingTop: 10 },
skipAllText: { color: '#6B7280', textDecorationLine: 'underline' }
});

View File

@@ -0,0 +1,74 @@
import { useState } from 'react';
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
import { useRouter } from 'expo-router';
import { useTranslation } from 'react-i18next';
import * as Notifications from 'expo-notifications';
import { setPushPromptState } from '@/src/storage/appStorage';
export default function PushPromptScreen() {
const router = useRouter();
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
async function goHome() {
router.replace('/(app)/home');
}
async function onLater() {
await setPushPromptState('skipped');
await goHome();
}
async function onEnableNow() {
// 触发系统权限申请(可失败,但不阻塞进入主功能)
setLoading(true);
try {
await Notifications.requestPermissionsAsync();
await setPushPromptState('enabled');
await goHome();
} catch (e) {
Alert.alert(t('push.errorTitle'), t('push.errorDesc'));
await goHome();
} finally {
setLoading(false);
}
}
return (
<View style={styles.container}>
<View style={styles.card}>
<Text style={styles.title}>{t('push.cardTitle')}</Text>
<Text style={styles.desc}>{t('push.cardDesc')}</Text>
</View>
<View style={styles.actions}>
<Pressable style={[styles.btn, styles.secondary]} onPress={onLater} disabled={loading}>
<Text style={[styles.btnText, styles.secondaryText]}>{t('push.later')}</Text>
</Pressable>
<Pressable style={[styles.btn, styles.primary]} onPress={onEnableNow} disabled={loading}>
<Text style={styles.btnText}>{loading ? t('push.loading') : t('push.enable')}</Text>
</Pressable>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 20, justifyContent: 'center', gap: 16 },
card: {
borderRadius: 18,
padding: 20,
backgroundColor: '#111827',
gap: 10
},
title: { color: 'white', fontSize: 20, fontWeight: '700' },
desc: { color: '#E5E7EB', fontSize: 15, lineHeight: 21 },
actions: { flexDirection: 'row', gap: 12 },
btn: { flex: 1, paddingVertical: 14, borderRadius: 14, alignItems: 'center' },
primary: { backgroundColor: '#16A34A' },
secondary: { backgroundColor: '#F3F4F6' },
btnText: { fontSize: 16, fontWeight: '600', color: '#FFFFFF' },
secondaryText: { color: '#111827' }
});

38
client/app/+html.tsx Normal file
View File

@@ -0,0 +1,38 @@
import { ScrollViewStyleReset } from 'expo-router/html';
// This file is web-only and used to configure the root HTML for every
// web page during static rendering.
// The contents of this function only run in Node.js environments and
// do not have access to the DOM or browser APIs.
export default function Root({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
{/*
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
*/}
<ScrollViewStyleReset />
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
{/* Add any additional <head> elements that you want globally available on web... */}
</head>
<body>{children}</body>
</html>
);
}
const responsiveBackground = `
body {
background-color: #fff;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #000;
}
}`;

40
client/app/+not-found.tsx Normal file
View File

@@ -0,0 +1,40 @@
import { Link, Stack } from 'expo-router';
import { StyleSheet } from 'react-native';
import { Text, View } from '@/components/Themed';
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: 'Oops!' }} />
<View style={styles.container}>
<Text style={styles.title}>This screen doesn't exist.</Text>
<Link href="/" style={styles.link}>
<Text style={styles.linkText}>Go to home screen!</Text>
</Link>
</View>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
title: {
fontSize: 20,
fontWeight: 'bold',
},
link: {
marginTop: 15,
paddingVertical: 15,
},
linkText: {
fontSize: 14,
color: '#2e78b7',
},
});

81
client/app/_layout.tsx Normal file
View File

@@ -0,0 +1,81 @@
import FontAwesome from '@expo/vector-icons/FontAwesome';
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { useFonts } from 'expo-font';
import { Stack } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { useEffect, useState } from 'react';
import 'react-native-reanimated';
import { useColorScheme } from '@/components/useColorScheme';
import { initI18n } from '@/src/i18n';
export {
// Catch any errors thrown by the Layout component.
ErrorBoundary,
} from 'expo-router';
export const unstable_settings = {
// Ensure that reloading on `/modal` keeps a back button present.
initialRouteName: 'index',
};
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const [loaded, error] = useFonts({
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
...FontAwesome.font,
});
const [i18nReady, setI18nReady] = useState(false);
// Expo Router uses Error Boundaries to catch errors in the navigation tree.
useEffect(() => {
if (error) throw error;
}, [error]);
useEffect(() => {
initI18n()
.catch((e) => {
// i18n 初始化失败不应阻塞 App 启动,先打印错误再继续
console.error('i18n 初始化失败', e);
})
.finally(() => setI18nReady(true));
}, []);
useEffect(() => {
// 等字体与 i18n 都准备好后再隐藏启动页,避免文案闪烁
if (loaded && i18nReady) {
SplashScreen.hideAsync();
}
}, [loaded, i18nReady]);
if (!loaded || !i18nReady) {
return null;
}
return <RootLayoutNav />;
}
function RootLayoutNav() {
const colorScheme = useColorScheme();
return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack screenOptions={{ headerShown: false }}>
{/* 启动分发页:根据 onboarding 状态跳转 */}
<Stack.Screen name="index" />
{/* Onboarding 分组 */}
<Stack.Screen name="(onboarding)" />
{/* 主应用分组(不使用 Tabs */}
<Stack.Screen name="(app)" />
{/* 其他 */}
<Stack.Screen name="modal" options={{ presentation: 'modal' }} />
<Stack.Screen name="+not-found" />
</Stack>
</ThemeProvider>
);
}

35
client/app/index.tsx Normal file
View File

@@ -0,0 +1,35 @@
import { useEffect } from 'react';
import { ActivityIndicator, StyleSheet, View } from 'react-native';
import { useRouter } from 'expo-router';
import { getOnboardingCompleted } from '@/src/storage/appStorage';
/**
* 启动分发:根据 onboarding 状态跳转
*/
export default function Index() {
const router = useRouter();
useEffect(() => {
let cancelled = false;
(async () => {
const completed = await getOnboardingCompleted();
if (cancelled) return;
router.replace(completed ? '/(app)/home' : '/(onboarding)/onboarding');
})();
return () => {
cancelled = true;
};
}, [router]);
return (
<View style={styles.container}>
<ActivityIndicator />
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, alignItems: 'center', justifyContent: 'center' },
});

35
client/app/modal.tsx Normal file
View File

@@ -0,0 +1,35 @@
import { StatusBar } from 'expo-status-bar';
import { Platform, StyleSheet } from 'react-native';
import EditScreenInfo from '@/components/EditScreenInfo';
import { Text, View } from '@/components/Themed';
export default function ModalScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Modal</Text>
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
<EditScreenInfo path="app/modal.tsx" />
{/* Use a light status bar on iOS to account for the black space above the modal */}
<StatusBar style={Platform.OS === 'ios' ? 'light' : 'auto'} />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontSize: 20,
fontWeight: 'bold',
},
separator: {
marginVertical: 30,
height: 1,
width: '80%',
},
});