feat(mobile): track mobile app scaffold

This commit is contained in:
caoxiaozhu
2026-05-22 12:41:45 +08:00
parent 222ba0bfdc
commit 1f15699013
79 changed files with 16854 additions and 0 deletions

View File

@@ -0,0 +1,64 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Tabs } from 'expo-router';
import React from 'react';
import { Ionicons } from '@expo/vector-icons';
import { Colors } from '@/constants/theme';
const queryClient = new QueryClient();
type TabIconProps = {
color: string;
size: number;
};
function HomeIcon(props: TabIconProps) {
return <Ionicons name="home-outline" {...props} />;
}
function ClaimsIcon(props: TabIconProps) {
return <Ionicons name="document-text-outline" {...props} />;
}
function ApprovalsIcon(props: TabIconProps) {
return <Ionicons name="people-outline" {...props} />;
}
function AssistantIcon(props: TabIconProps) {
return <Ionicons name="sparkles-outline" {...props} />;
}
function ProfileIcon(props: TabIconProps) {
return <Ionicons name="person-outline" {...props} />;
}
export default function RootLayout() {
return (
<QueryClientProvider client={queryClient}>
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: Colors.light.primary,
tabBarInactiveTintColor: Colors.light.textSecondary,
tabBarStyle: {
height: 72,
paddingTop: 8,
paddingBottom: 10,
borderTopColor: Colors.light.border,
backgroundColor: '#FFFFFF',
},
tabBarLabelStyle: {
fontSize: 12,
fontWeight: '800',
},
}}>
<Tabs.Screen name="index" options={{ title: '首页', tabBarIcon: HomeIcon }} />
<Tabs.Screen name="claims" options={{ title: '报销', tabBarIcon: ClaimsIcon }} />
<Tabs.Screen name="approvals" options={{ title: '审批', tabBarIcon: ApprovalsIcon }} />
<Tabs.Screen name="assistant" options={{ title: 'AI 助手', tabBarIcon: AssistantIcon }} />
<Tabs.Screen name="profile" options={{ title: '我的', tabBarIcon: ProfileIcon }} />
<Tabs.Screen name="explore" options={{ href: null }} />
</Tabs>
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,64 @@
import { StyleSheet, Text, View } from 'react-native';
import { ActionButton } from '@/shared/components/ActionButton';
import { ClaimCard } from '@/shared/components/ClaimCard';
import { Screen } from '@/shared/components/Screen';
import { Colors, Radius, Spacing } from '@/constants/theme';
import { approvalClaims } from '@/shared/mock/claims';
export default function ApprovalsScreen() {
return (
<Screen title="审批中心" subtitle="处理待我审批的报销单,查看 AI 风控提示后再做决定。">
<View style={styles.summary}>
<View>
<Text style={styles.summaryNumber}>{approvalClaims.length}</Text>
<Text style={styles.summaryLabel}></Text>
</View>
<View>
<Text style={styles.summaryNumber}>1</Text>
<Text style={styles.summaryLabel}></Text>
</View>
</View>
{approvalClaims.map((claim) => (
<View key={claim.id} style={styles.approvalItem}>
<ClaimCard claim={claim} />
<View style={styles.actionRow}>
<ActionButton accessibilityLabel={`驳回 ${claim.claimNo}`} variant="danger">
</ActionButton>
<ActionButton accessibilityLabel={`同意 ${claim.claimNo}`}></ActionButton>
</View>
</View>
))}
</Screen>
);
}
const styles = StyleSheet.create({
summary: {
flexDirection: 'row',
gap: Spacing.three,
borderRadius: Radius.lg,
backgroundColor: Colors.light.primarySoft,
borderWidth: 1,
borderColor: '#BFEEDB',
padding: Spacing.four,
},
summaryNumber: {
color: Colors.light.primary,
fontSize: 26,
fontWeight: '900',
},
summaryLabel: {
color: Colors.light.textSecondary,
fontSize: 13,
},
approvalItem: {
gap: Spacing.three,
},
actionRow: {
flexDirection: 'row',
gap: Spacing.three,
},
});

View File

@@ -0,0 +1,180 @@
import { useState } from 'react';
import { Alert, StyleSheet, Text, TextInput, View } from 'react-native';
import { ActionButton } from '@/shared/components/ActionButton';
import { Screen } from '@/shared/components/Screen';
import { Colors, Radius, Spacing } from '@/constants/theme';
import { captureReceiptFromCamera, pickReceiptFromLibrary } from '@/platform/camera/receiptCapture';
import { transcribeVoice, useVoiceRecorder } from '@/platform/voice/voiceInput';
export default function AssistantScreen() {
const [message, setMessage] = useState('');
const [receiptName, setReceiptName] = useState('');
const [isRecording, setIsRecording] = useState(false);
const voice = useVoiceRecorder();
async function handleCamera() {
try {
const receipt = await captureReceiptFromCamera();
if (receipt) {
setReceiptName(receipt.fileName);
}
} catch (error) {
Alert.alert('无法拍照', error instanceof Error ? error.message : '请检查相机权限。');
}
}
async function handleLibrary() {
try {
const receipt = await pickReceiptFromLibrary();
if (receipt) {
setReceiptName(receipt.fileName);
}
} catch (error) {
Alert.alert('无法选择票据', error instanceof Error ? error.message : '请检查相册权限。');
}
}
async function toggleVoice() {
try {
if (!isRecording) {
setIsRecording(true);
await voice.start();
return;
}
const uri = await voice.stop();
setIsRecording(false);
if (uri) {
const result = await transcribeVoice(uri);
setMessage(result.text);
}
} catch (error) {
setIsRecording(false);
Alert.alert('语音输入失败', error instanceof Error ? error.message : '请检查麦克风权限。');
}
}
return (
<Screen title="AI 助手" subtitle="输入问题、拍摄票据或使用语音描述费用,助手会返回可确认的报销建议。">
<View style={styles.hero}>
<Text style={styles.badge}>AI </Text>
<Text style={styles.title}></Text>
<Text style={styles.copy}></Text>
</View>
<View style={styles.answerCard}>
<Text style={styles.answerTitle}></Text>
<Text style={styles.answerText}>
86 320
</Text>
</View>
{receiptName ? (
<View style={styles.receiptBar}>
<Text style={styles.receiptText}>{receiptName}</Text>
</View>
) : null}
<View style={styles.inputCard}>
<TextInput
accessibilityLabel="助手输入框"
multiline
placeholder="描述费用或输入你的问题"
placeholderTextColor={Colors.light.textSecondary}
style={styles.input}
value={message}
onChangeText={setMessage}
/>
<View style={styles.actionRow}>
<ActionButton accessibilityLabel="拍照识别票据" variant="secondary" onPress={handleCamera}>
</ActionButton>
<ActionButton accessibilityLabel="从相册上传票据" variant="secondary" onPress={handleLibrary}>
</ActionButton>
<ActionButton accessibilityLabel="语音输入" variant={isRecording ? 'danger' : 'secondary'} onPress={toggleVoice}>
{isRecording ? '停止' : '语音'}
</ActionButton>
<ActionButton accessibilityLabel="发送给 AI 助手"></ActionButton>
</View>
</View>
</Screen>
);
}
const styles = StyleSheet.create({
hero: {
gap: Spacing.three,
borderRadius: Radius.lg,
backgroundColor: Colors.light.primarySoft,
borderWidth: 1,
borderColor: '#BFEEDB',
padding: Spacing.four,
},
badge: {
alignSelf: 'flex-start',
borderRadius: Radius.pill,
backgroundColor: '#DFF8EC',
color: Colors.light.primary,
paddingHorizontal: 10,
paddingVertical: 4,
fontSize: 12,
fontWeight: '900',
},
title: {
color: Colors.light.text,
fontSize: 23,
fontWeight: '900',
},
copy: {
color: Colors.light.textSecondary,
fontSize: 14,
lineHeight: 23,
},
answerCard: {
gap: Spacing.two,
borderRadius: Radius.lg,
backgroundColor: '#FFFFFF',
borderWidth: 1,
borderColor: Colors.light.border,
padding: Spacing.four,
},
answerTitle: {
color: Colors.light.text,
fontSize: 16,
fontWeight: '900',
},
answerText: {
color: Colors.light.textSecondary,
fontSize: 14,
lineHeight: 23,
},
receiptBar: {
borderRadius: Radius.md,
backgroundColor: '#E8F1FF',
padding: Spacing.three,
},
receiptText: {
color: Colors.light.info,
fontWeight: '800',
},
inputCard: {
gap: Spacing.three,
borderRadius: Radius.lg,
backgroundColor: '#FFFFFF',
borderWidth: 1,
borderColor: Colors.light.border,
padding: Spacing.four,
},
input: {
minHeight: 92,
color: Colors.light.text,
fontSize: 15,
textAlignVertical: 'top',
},
actionRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: Spacing.three,
},
});

View File

@@ -0,0 +1,48 @@
import { StyleSheet, Text, View } from 'react-native';
import { ActionButton } from '@/shared/components/ActionButton';
import { ClaimCard } from '@/shared/components/ClaimCard';
import { Screen } from '@/shared/components/Screen';
import { Colors, Radius, Spacing } from '@/constants/theme';
import { myClaims } from '@/shared/mock/claims';
export default function ClaimsScreen() {
return (
<Screen title="我的报销" subtitle="查看草稿、审批中、已通过、已驳回和已付款的报销单。">
<View style={styles.searchBox}>
<Text style={styles.searchText}></Text>
</View>
<View style={styles.actionRow}>
<ActionButton accessibilityLabel="新建报销"></ActionButton>
<ActionButton accessibilityLabel="筛选报销单" variant="secondary">
</ActionButton>
</View>
{myClaims.map((claim) => (
<ClaimCard key={claim.id} claim={claim} />
))}
</Screen>
);
}
const styles = StyleSheet.create({
searchBox: {
minHeight: 48,
borderRadius: Radius.md,
borderWidth: 1,
borderColor: Colors.light.border,
backgroundColor: '#FFFFFF',
justifyContent: 'center',
paddingHorizontal: Spacing.four,
},
searchText: {
color: Colors.light.textSecondary,
fontSize: 14,
},
actionRow: {
flexDirection: 'row',
gap: Spacing.three,
},
});

View File

@@ -0,0 +1,181 @@
import { Image } from 'expo-image';
import { SymbolView } from 'expo-symbols';
import React from 'react';
import { Platform, Pressable, ScrollView, StyleSheet } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { ExternalLink } from '@/components/external-link';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { Collapsible } from '@/components/ui/collapsible';
import { WebBadge } from '@/components/web-badge';
import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme';
import { useTheme } from '@/hooks/use-theme';
export default function TabTwoScreen() {
const safeAreaInsets = useSafeAreaInsets();
const insets = {
...safeAreaInsets,
bottom: safeAreaInsets.bottom + BottomTabInset + Spacing.three,
};
const theme = useTheme();
const contentPlatformStyle = Platform.select({
android: {
paddingTop: insets.top,
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: insets.bottom,
},
web: {
paddingTop: Spacing.six,
paddingBottom: Spacing.four,
},
});
return (
<ScrollView
style={[styles.scrollView, { backgroundColor: theme.background }]}
contentInset={insets}
contentContainerStyle={[styles.contentContainer, contentPlatformStyle]}>
<ThemedView style={styles.container}>
<ThemedView style={styles.titleContainer}>
<ThemedText type="subtitle">Explore</ThemedText>
<ThemedText style={styles.centerText} themeColor="textSecondary">
This starter app includes example{'\n'}code to help you get started.
</ThemedText>
<ExternalLink href="https://docs.expo.dev" asChild>
<Pressable style={({ pressed }) => pressed && styles.pressed}>
<ThemedView type="backgroundElement" style={styles.linkButton}>
<ThemedText type="link">Expo documentation</ThemedText>
<SymbolView
tintColor={theme.text}
name={{ ios: 'arrow.up.right.square', android: 'link', web: 'link' }}
size={12}
/>
</ThemedView>
</Pressable>
</ExternalLink>
</ThemedView>
<ThemedView style={styles.sectionsWrapper}>
<Collapsible title="File-based routing">
<ThemedText type="small">
This app has two screens: <ThemedText type="code">src/app/index.tsx</ThemedText> and{' '}
<ThemedText type="code">src/app/explore.tsx</ThemedText>
</ThemedText>
<ThemedText type="small">
The layout file in <ThemedText type="code">src/app/_layout.tsx</ThemedText> sets up
the tab navigator.
</ThemedText>
<ExternalLink href="https://docs.expo.dev/router/introduction">
<ThemedText type="linkPrimary">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Android, iOS, and web support">
<ThemedView type="backgroundElement" style={styles.collapsibleContent}>
<ThemedText type="small">
You can open this project on Android, iOS, and the web. To open the web version,
press <ThemedText type="smallBold">w</ThemedText> in the terminal running this
project.
</ThemedText>
<Image
source={require('@/assets/images/tutorial-web.png')}
style={styles.imageTutorial}
/>
</ThemedView>
</Collapsible>
<Collapsible title="Images">
<ThemedText type="small">
For static images, you can use the <ThemedText type="code">@2x</ThemedText> and{' '}
<ThemedText type="code">@3x</ThemedText> suffixes to provide files for different
screen densities.
</ThemedText>
<Image source={require('@/assets/images/react-logo.png')} style={styles.imageReact} />
<ExternalLink href="https://reactnative.dev/docs/images">
<ThemedText type="linkPrimary">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Light and dark mode components">
<ThemedText type="small">
This template has light and dark mode support. The{' '}
<ThemedText type="code">useColorScheme()</ThemedText> hook lets you inspect what the
user&apos;s current color scheme is, and so you can adjust UI colors accordingly.
</ThemedText>
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
<ThemedText type="linkPrimary">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Animations">
<ThemedText type="small">
This template includes an example of an animated component. The{' '}
<ThemedText type="code">src/components/ui/collapsible.tsx</ThemedText> component uses
the powerful <ThemedText type="code">react-native-reanimated</ThemedText> library to
animate opening this hint.
</ThemedText>
</Collapsible>
</ThemedView>
{Platform.OS === 'web' && <WebBadge />}
</ThemedView>
</ScrollView>
);
}
const styles = StyleSheet.create({
scrollView: {
flex: 1,
},
contentContainer: {
flexDirection: 'row',
justifyContent: 'center',
},
container: {
maxWidth: MaxContentWidth,
flexGrow: 1,
},
titleContainer: {
gap: Spacing.three,
alignItems: 'center',
paddingHorizontal: Spacing.four,
paddingVertical: Spacing.six,
},
centerText: {
textAlign: 'center',
},
pressed: {
opacity: 0.7,
},
linkButton: {
flexDirection: 'row',
paddingHorizontal: Spacing.four,
paddingVertical: Spacing.two,
borderRadius: Spacing.five,
justifyContent: 'center',
gap: Spacing.one,
alignItems: 'center',
},
sectionsWrapper: {
gap: Spacing.five,
paddingHorizontal: Spacing.four,
paddingTop: Spacing.three,
},
collapsibleContent: {
alignItems: 'center',
},
imageTutorial: {
width: '100%',
aspectRatio: 296 / 171,
borderRadius: Spacing.three,
marginTop: Spacing.two,
},
imageReact: {
width: 100,
height: 100,
alignSelf: 'center',
},
});

View File

@@ -0,0 +1,105 @@
import { StyleSheet, Text, View } from 'react-native';
import { ActionButton } from '@/shared/components/ActionButton';
import { ClaimCard } from '@/shared/components/ClaimCard';
import { Screen } from '@/shared/components/Screen';
import { Colors, Radius, Spacing } from '@/constants/theme';
import { myClaims } from '@/shared/mock/claims';
export default function HomeScreen() {
return (
<Screen title="上午好,张三" subtitle="使用 AI 报销助手快速识别票据、生成草稿和跟踪审批。">
<View style={styles.hero}>
<Text style={styles.heroLabel}>AI </Text>
<Text style={styles.heroTitle}>AI </Text>
<Text style={styles.heroCopy}></Text>
<View style={styles.heroActions}>
<ActionButton accessibilityLabel="拍照识别票据"></ActionButton>
<ActionButton accessibilityLabel="上传票据" variant="secondary">
</ActionButton>
</View>
</View>
<View style={styles.quickGrid}>
<View style={styles.quickCard}>
<Text style={styles.quickTitle}></Text>
<Text style={styles.quickCopy}></Text>
</View>
<View style={styles.quickCard}>
<Text style={styles.quickTitle}></Text>
<Text style={styles.quickCopy}>2 </Text>
</View>
</View>
<Text style={styles.sectionTitle}></Text>
{myClaims.slice(0, 2).map((claim) => (
<ClaimCard key={claim.id} claim={claim} />
))}
</Screen>
);
}
const styles = StyleSheet.create({
hero: {
gap: Spacing.three,
padding: Spacing.four,
borderRadius: Radius.lg,
borderWidth: 1,
borderColor: '#BFEEDB',
backgroundColor: Colors.light.primarySoft,
},
heroLabel: {
alignSelf: 'flex-start',
borderRadius: Radius.pill,
backgroundColor: '#DFF8EC',
color: Colors.light.primary,
paddingHorizontal: 10,
paddingVertical: 4,
fontSize: 12,
fontWeight: '900',
},
heroTitle: {
color: Colors.light.text,
fontSize: 21,
fontWeight: '900',
lineHeight: 29,
},
heroCopy: {
color: Colors.light.textSecondary,
fontSize: 14,
lineHeight: 22,
},
heroActions: {
flexDirection: 'row',
gap: Spacing.three,
},
quickGrid: {
flexDirection: 'row',
gap: Spacing.three,
},
quickCard: {
flex: 1,
gap: 5,
borderRadius: Radius.lg,
borderWidth: 1,
borderColor: Colors.light.border,
backgroundColor: '#FFFFFF',
padding: Spacing.four,
},
quickTitle: {
color: Colors.light.text,
fontSize: 16,
fontWeight: '900',
},
quickCopy: {
color: Colors.light.textSecondary,
fontSize: 13,
},
sectionTitle: {
marginTop: Spacing.two,
color: Colors.light.text,
fontSize: 18,
fontWeight: '900',
},
});

View File

@@ -0,0 +1,92 @@
import { StyleSheet, Text, View } from 'react-native';
import { ActionButton } from '@/shared/components/ActionButton';
import { Screen } from '@/shared/components/Screen';
import { Colors, Radius, Spacing } from '@/constants/theme';
import { useSessionStore } from '@/shared/auth/session';
export default function ProfileScreen() {
const user = useSessionStore((state) => state.user);
const signInAsDemoUser = useSessionStore((state) => state.signInAsDemoUser);
return (
<Screen title="我的" subtitle="查看当前移动端登录身份、角色和基础设置。">
<View style={styles.profileCard}>
<View style={styles.avatar}>
<Text style={styles.avatarText}>{user?.displayName.slice(0, 1) || '未'}</Text>
</View>
<View style={styles.info}>
<Text style={styles.name}>{user?.displayName || '未登录'}</Text>
<Text style={styles.meta}>{user?.username || '-'}</Text>
<Text style={styles.meta}>{user?.roleCodes.join(', ') || '-'}</Text>
</View>
</View>
<View style={styles.settingCard}>
<Text style={styles.settingTitle}></Text>
<Text style={styles.settingText}>SecureStore </Text>
</View>
<ActionButton accessibilityLabel="恢复演示用户" onPress={signInAsDemoUser}>
</ActionButton>
</Screen>
);
}
const styles = StyleSheet.create({
profileCard: {
flexDirection: 'row',
gap: Spacing.four,
alignItems: 'center',
borderRadius: Radius.lg,
backgroundColor: '#FFFFFF',
borderWidth: 1,
borderColor: Colors.light.border,
padding: Spacing.four,
},
avatar: {
width: 60,
height: 60,
borderRadius: Radius.lg,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: Colors.light.primarySoft,
},
avatarText: {
color: Colors.light.primary,
fontSize: 24,
fontWeight: '900',
},
info: {
flex: 1,
gap: 4,
},
name: {
color: Colors.light.text,
fontSize: 20,
fontWeight: '900',
},
meta: {
color: Colors.light.textSecondary,
fontSize: 13,
},
settingCard: {
gap: Spacing.two,
borderRadius: Radius.lg,
backgroundColor: Colors.light.primarySoft,
borderWidth: 1,
borderColor: '#BFEEDB',
padding: Spacing.four,
},
settingTitle: {
color: Colors.light.text,
fontSize: 16,
fontWeight: '900',
},
settingText: {
color: Colors.light.textSecondary,
fontSize: 14,
lineHeight: 22,
},
});

View File

@@ -0,0 +1,6 @@
.expoLogoBackground {
background-image: linear-gradient(180deg, #3c9ffe, #0274df);
border-radius: 40px;
width: 128px;
height: 128px;
}

View File

@@ -0,0 +1,132 @@
import { Image } from 'expo-image';
import { useState } from 'react';
import { Dimensions, StyleSheet, View } from 'react-native';
import Animated, { Easing, Keyframe } from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';
const INITIAL_SCALE_FACTOR = Dimensions.get('screen').height / 90;
const DURATION = 600;
export function AnimatedSplashOverlay() {
const [visible, setVisible] = useState(true);
if (!visible) return null;
const splashKeyframe = new Keyframe({
0: {
transform: [{ scale: INITIAL_SCALE_FACTOR }],
opacity: 1,
},
20: {
opacity: 1,
},
70: {
opacity: 0,
easing: Easing.elastic(0.7),
},
100: {
opacity: 0,
transform: [{ scale: 1 }],
easing: Easing.elastic(0.7),
},
});
return (
<Animated.View
entering={splashKeyframe.duration(DURATION).withCallback((finished) => {
'worklet';
if (finished) {
scheduleOnRN(setVisible, false);
}
})}
style={styles.backgroundSolidColor}
/>
);
}
const keyframe = new Keyframe({
0: {
transform: [{ scale: INITIAL_SCALE_FACTOR }],
},
100: {
transform: [{ scale: 1 }],
easing: Easing.elastic(0.7),
},
});
const logoKeyframe = new Keyframe({
0: {
transform: [{ scale: 1.3 }],
opacity: 0,
},
40: {
transform: [{ scale: 1.3 }],
opacity: 0,
easing: Easing.elastic(0.7),
},
100: {
opacity: 1,
transform: [{ scale: 1 }],
easing: Easing.elastic(0.7),
},
});
const glowKeyframe = new Keyframe({
0: {
transform: [{ rotateZ: '0deg' }],
},
100: {
transform: [{ rotateZ: '7200deg' }],
},
});
export function AnimatedIcon() {
return (
<View style={styles.iconContainer}>
<Animated.View entering={glowKeyframe.duration(60 * 1000 * 4)} style={styles.glow}>
<Image style={styles.glow} source={require('@/assets/images/logo-glow.png')} />
</Animated.View>
<Animated.View entering={keyframe.duration(DURATION)} style={styles.background} />
<Animated.View style={styles.imageContainer} entering={logoKeyframe.duration(DURATION)}>
<Image style={styles.image} source={require('@/assets/images/expo-logo.png')} />
</Animated.View>
</View>
);
}
const styles = StyleSheet.create({
imageContainer: {
justifyContent: 'center',
alignItems: 'center',
},
glow: {
width: 201,
height: 201,
position: 'absolute',
},
iconContainer: {
justifyContent: 'center',
alignItems: 'center',
width: 128,
height: 128,
zIndex: 100,
},
image: {
position: 'absolute',
width: 76,
height: 71,
},
background: {
borderRadius: 40,
experimental_backgroundImage: `linear-gradient(180deg, #3C9FFE, #0274DF)`,
width: 128,
height: 128,
position: 'absolute',
},
backgroundSolidColor: {
...StyleSheet.absoluteFillObject,
backgroundColor: '#208AEF',
zIndex: 1000,
},
});

View File

@@ -0,0 +1,108 @@
import { Image } from 'expo-image';
import { StyleSheet, View } from 'react-native';
import Animated, { Keyframe, Easing } from 'react-native-reanimated';
import classes from './animated-icon.module.css';
const DURATION = 300;
export function AnimatedSplashOverlay() {
return null;
}
const keyframe = new Keyframe({
0: {
transform: [{ scale: 0 }],
},
60: {
transform: [{ scale: 1.2 }],
easing: Easing.elastic(1.2),
},
100: {
transform: [{ scale: 1 }],
easing: Easing.elastic(1.2),
},
});
const logoKeyframe = new Keyframe({
0: {
opacity: 0,
},
60: {
transform: [{ scale: 1.2 }],
opacity: 0,
easing: Easing.elastic(1.2),
},
100: {
transform: [{ scale: 1 }],
opacity: 1,
easing: Easing.elastic(1.2),
},
});
const glowKeyframe = new Keyframe({
0: {
transform: [{ rotateZ: '-180deg' }, { scale: 0.8 }],
opacity: 0,
},
[DURATION / 1000]: {
transform: [{ rotateZ: '0deg' }, { scale: 1 }],
opacity: 1,
easing: Easing.elastic(0.7),
},
100: {
transform: [{ rotateZ: '7200deg' }],
},
});
export function AnimatedIcon() {
return (
<View style={styles.iconContainer}>
<Animated.View entering={glowKeyframe.duration(60 * 1000 * 4)} style={styles.glow}>
<Image style={styles.glow} source={require('@/assets/images/logo-glow.png')} />
</Animated.View>
<Animated.View style={styles.background} entering={keyframe.duration(DURATION)}>
<div className={classes.expoLogoBackground} />
</Animated.View>
<Animated.View style={styles.imageContainer} entering={logoKeyframe.duration(DURATION)}>
<Image style={styles.image} source={require('@/assets/images/expo-logo.png')} />
</Animated.View>
</View>
);
}
const styles = StyleSheet.create({
container: {
alignItems: 'center',
width: '100%',
zIndex: 1000,
position: 'absolute',
top: 128 / 2 + 138,
},
imageContainer: {
justifyContent: 'center',
alignItems: 'center',
},
glow: {
width: 201,
height: 201,
position: 'absolute',
},
iconContainer: {
justifyContent: 'center',
alignItems: 'center',
width: 128,
height: 128,
},
image: {
position: 'absolute',
width: 76,
height: 71,
},
background: {
width: 128,
height: 128,
position: 'absolute',
},
});

View File

@@ -0,0 +1,33 @@
import { NativeTabs } from 'expo-router/unstable-native-tabs';
import React from 'react';
import { useColorScheme } from 'react-native';
import { Colors } from '@/constants/theme';
export default function AppTabs() {
const scheme = useColorScheme();
const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
return (
<NativeTabs
backgroundColor={colors.background}
indicatorColor={colors.backgroundElement}
labelStyle={{ selected: { color: colors.text } }}>
<NativeTabs.Trigger name="index">
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon
src={require('@/assets/images/tabIcons/home.png')}
renderingMode="template"
/>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="explore">
<NativeTabs.Trigger.Label>Explore</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon
src={require('@/assets/images/tabIcons/explore.png')}
renderingMode="template"
/>
</NativeTabs.Trigger>
</NativeTabs>
);
}

View File

@@ -0,0 +1,116 @@
import {
Tabs,
TabList,
TabTrigger,
TabSlot,
TabTriggerSlotProps,
TabListProps,
} from 'expo-router/ui';
import { SymbolView } from 'expo-symbols';
import React from 'react';
import { Pressable, useColorScheme, View, StyleSheet } from 'react-native';
import { ExternalLink } from './external-link';
import { ThemedText } from './themed-text';
import { ThemedView } from './themed-view';
import { Colors, MaxContentWidth, Spacing } from '@/constants/theme';
export default function AppTabs() {
return (
<Tabs>
<TabSlot style={{ height: '100%' }} />
<TabList asChild>
<CustomTabList>
<TabTrigger name="home" href="/" asChild>
<TabButton>Home</TabButton>
</TabTrigger>
<TabTrigger name="explore" href="/explore" asChild>
<TabButton>Explore</TabButton>
</TabTrigger>
</CustomTabList>
</TabList>
</Tabs>
);
}
export function TabButton({ children, isFocused, ...props }: TabTriggerSlotProps) {
return (
<Pressable {...props} style={({ pressed }) => pressed && styles.pressed}>
<ThemedView
type={isFocused ? 'backgroundSelected' : 'backgroundElement'}
style={styles.tabButtonView}>
<ThemedText type="small" themeColor={isFocused ? 'text' : 'textSecondary'}>
{children}
</ThemedText>
</ThemedView>
</Pressable>
);
}
export function CustomTabList(props: TabListProps) {
const scheme = useColorScheme();
const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
return (
<View {...props} style={styles.tabListContainer}>
<ThemedView type="backgroundElement" style={styles.innerContainer}>
<ThemedText type="smallBold" style={styles.brandText}>
Expo Starter
</ThemedText>
{props.children}
<ExternalLink href="https://docs.expo.dev" asChild>
<Pressable style={styles.externalPressable}>
<ThemedText type="link">Docs</ThemedText>
<SymbolView
tintColor={colors.text}
name={{ ios: 'arrow.up.right.square', web: 'link' }}
size={12}
/>
</Pressable>
</ExternalLink>
</ThemedView>
</View>
);
}
const styles = StyleSheet.create({
tabListContainer: {
position: 'absolute',
width: '100%',
padding: Spacing.three,
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'row',
},
innerContainer: {
paddingVertical: Spacing.two,
paddingHorizontal: Spacing.five,
borderRadius: Spacing.five,
flexDirection: 'row',
alignItems: 'center',
flexGrow: 1,
gap: Spacing.two,
maxWidth: MaxContentWidth,
},
brandText: {
marginRight: 'auto',
},
pressed: {
opacity: 0.7,
},
tabButtonView: {
paddingVertical: Spacing.one,
paddingHorizontal: Spacing.three,
borderRadius: Spacing.three,
},
externalPressable: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
gap: Spacing.one,
marginLeft: Spacing.three,
},
});

View File

@@ -0,0 +1,25 @@
import { Href, Link } from 'expo-router';
import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
import { type ComponentProps } from 'react';
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
export function ExternalLink({ href, ...rest }: Props) {
return (
<Link
target="_blank"
{...rest}
href={href}
onPress={async (event) => {
if (process.env.EXPO_OS !== 'web') {
// Prevent the default behavior of linking to the default browser on native.
event.preventDefault();
// Open the link in an in-app browser.
await openBrowserAsync(href, {
presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
});
}
}}
/>
);
}

View File

@@ -0,0 +1,35 @@
import React, { type ReactNode } from 'react';
import { View, StyleSheet } from 'react-native';
import { ThemedText } from './themed-text';
import { ThemedView } from './themed-view';
import { Spacing } from '@/constants/theme';
type HintRowProps = {
title?: string;
hint?: ReactNode;
};
export function HintRow({ title = 'Try editing', hint = 'app/index.tsx' }: HintRowProps) {
return (
<View style={styles.stepRow}>
<ThemedText type="small">{title}</ThemedText>
<ThemedView type="backgroundSelected" style={styles.codeSnippet}>
<ThemedText themeColor="textSecondary">{hint}</ThemedText>
</ThemedView>
</View>
);
}
const styles = StyleSheet.create({
stepRow: {
flexDirection: 'row',
justifyContent: 'space-between',
},
codeSnippet: {
borderRadius: Spacing.two,
paddingVertical: Spacing.half,
paddingHorizontal: Spacing.two,
},
});

View File

@@ -0,0 +1,73 @@
import { Platform, StyleSheet, Text, type TextProps } from 'react-native';
import { Fonts, ThemeColor } from '@/constants/theme';
import { useTheme } from '@/hooks/use-theme';
export type ThemedTextProps = TextProps & {
type?: 'default' | 'title' | 'small' | 'smallBold' | 'subtitle' | 'link' | 'linkPrimary' | 'code';
themeColor?: ThemeColor;
};
export function ThemedText({ style, type = 'default', themeColor, ...rest }: ThemedTextProps) {
const theme = useTheme();
return (
<Text
style={[
{ color: theme[themeColor ?? 'text'] },
type === 'default' && styles.default,
type === 'title' && styles.title,
type === 'small' && styles.small,
type === 'smallBold' && styles.smallBold,
type === 'subtitle' && styles.subtitle,
type === 'link' && styles.link,
type === 'linkPrimary' && styles.linkPrimary,
type === 'code' && styles.code,
style,
]}
{...rest}
/>
);
}
const styles = StyleSheet.create({
small: {
fontSize: 14,
lineHeight: 20,
fontWeight: 500,
},
smallBold: {
fontSize: 14,
lineHeight: 20,
fontWeight: 700,
},
default: {
fontSize: 16,
lineHeight: 24,
fontWeight: 500,
},
title: {
fontSize: 48,
fontWeight: 600,
lineHeight: 52,
},
subtitle: {
fontSize: 32,
lineHeight: 44,
fontWeight: 600,
},
link: {
lineHeight: 30,
fontSize: 14,
},
linkPrimary: {
lineHeight: 30,
fontSize: 14,
color: '#3c87f7',
},
code: {
fontFamily: Fonts.mono,
fontWeight: Platform.select({ android: 700 }) ?? 500,
fontSize: 12,
},
});

View File

@@ -0,0 +1,16 @@
import { View, type ViewProps } from 'react-native';
import { ThemeColor } from '@/constants/theme';
import { useTheme } from '@/hooks/use-theme';
export type ThemedViewProps = ViewProps & {
lightColor?: string;
darkColor?: string;
type?: ThemeColor;
};
export function ThemedView({ style, lightColor, darkColor, type, ...otherProps }: ThemedViewProps) {
const theme = useTheme();
return <View style={[{ backgroundColor: theme[type ?? 'background'] }, style]} {...otherProps} />;
}

View File

@@ -0,0 +1,65 @@
import { SymbolView } from 'expo-symbols';
import { PropsWithChildren, useState } from 'react';
import { Pressable, StyleSheet } from 'react-native';
import Animated, { FadeIn } from 'react-native-reanimated';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { Spacing } from '@/constants/theme';
import { useTheme } from '@/hooks/use-theme';
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
const [isOpen, setIsOpen] = useState(false);
const theme = useTheme();
return (
<ThemedView>
<Pressable
style={({ pressed }) => [styles.heading, pressed && styles.pressedHeading]}
onPress={() => setIsOpen((value) => !value)}>
<ThemedView type="backgroundElement" style={styles.button}>
<SymbolView
name={{ ios: 'chevron.right', android: 'chevron_right', web: 'chevron_right' }}
size={14}
weight="bold"
tintColor={theme.text}
style={{ transform: [{ rotate: isOpen ? '-90deg' : '90deg' }] }}
/>
</ThemedView>
<ThemedText type="small">{title}</ThemedText>
</Pressable>
{isOpen && (
<Animated.View entering={FadeIn.duration(200)}>
<ThemedView type="backgroundElement" style={styles.content}>
{children}
</ThemedView>
</Animated.View>
)}
</ThemedView>
);
}
const styles = StyleSheet.create({
heading: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.two,
},
pressedHeading: {
opacity: 0.7,
},
button: {
width: Spacing.four,
height: Spacing.four,
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
},
content: {
marginTop: Spacing.three,
borderRadius: Spacing.three,
marginLeft: Spacing.four,
padding: Spacing.four,
},
});

View File

@@ -0,0 +1,44 @@
import { version } from 'expo/package.json';
import { Image } from 'expo-image';
import React from 'react';
import { useColorScheme, StyleSheet } from 'react-native';
import { ThemedText } from './themed-text';
import { ThemedView } from './themed-view';
import { Spacing } from '@/constants/theme';
export function WebBadge() {
const scheme = useColorScheme();
return (
<ThemedView style={styles.container}>
<ThemedText type="code" themeColor="textSecondary" style={styles.versionText}>
v{version}
</ThemedText>
<Image
source={
scheme === 'dark'
? require('@/assets/images/expo-badge-white.png')
: require('@/assets/images/expo-badge.png')
}
style={styles.badgeImage}
/>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
padding: Spacing.five,
alignItems: 'center',
gap: Spacing.two,
},
versionText: {
textAlign: 'center',
},
badgeImage: {
width: 123,
aspectRatio: 123 / 24,
},
});

View File

@@ -0,0 +1,80 @@
import '@/global.css';
import { Platform } from 'react-native';
export const Colors = {
light: {
text: '#071124',
background: '#F7FAFC',
backgroundElement: '#FFFFFF',
backgroundSelected: '#DFF8EC',
textSecondary: '#58677F',
primary: '#059669',
primarySoft: '#EFFCF6',
border: '#DBE5EF',
warning: '#F59E0B',
danger: '#EF4444',
info: '#2563EB',
},
dark: {
text: '#ffffff',
background: '#071124',
backgroundElement: '#111C2F',
backgroundSelected: '#143B35',
textSecondary: '#B0B8C8',
primary: '#34D399',
primarySoft: '#0F2F2A',
border: '#26364E',
warning: '#FBBF24',
danger: '#F87171',
info: '#60A5FA',
},
} as const;
export type ThemeColor = keyof typeof Colors.light & keyof typeof Colors.dark;
export const Fonts = Platform.select({
ios: {
/** iOS `UIFontDescriptorSystemDesignDefault` */
sans: 'system-ui',
/** iOS `UIFontDescriptorSystemDesignSerif` */
serif: 'ui-serif',
/** iOS `UIFontDescriptorSystemDesignRounded` */
rounded: 'ui-rounded',
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
mono: 'ui-monospace',
},
default: {
sans: 'normal',
serif: 'serif',
rounded: 'normal',
mono: 'monospace',
},
web: {
sans: 'var(--font-display)',
serif: 'var(--font-serif)',
rounded: 'var(--font-rounded)',
mono: 'var(--font-mono)',
},
});
export const Spacing = {
half: 2,
one: 4,
two: 8,
three: 12,
four: 16,
five: 24,
six: 32,
seven: 48,
} as const;
export const Radius = {
sm: 6,
md: 8,
lg: 12,
pill: 999,
} as const;
export const BottomTabInset = Platform.select({ ios: 54, android: 76 }) ?? 0;
export const MaxContentWidth = 800;

View File

@@ -0,0 +1,11 @@
# X-Financial Mobile Features
本目录按业务功能拆分移动端页面和逻辑:
- `home`:首页聚合、待办、最近报销进度。
- `claims`:我的报销、新建报销、草稿、补材料。
- `approvals`:审批列表、审批详情、同意、驳回、转交。
- `assistant`AI 助手、语音输入、票据识别建议。
- `profile`:个人信息、角色、设置、退出登录。
初始化阶段页面入口仍放在 `src/app`,后续复杂业务逻辑优先下沉到对应 feature。

View File

@@ -0,0 +1,9 @@
:root {
--font-display:
Spline Sans, Inter, ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji,
Segoe UI Symbol, Noto Color Emoji;
--font-mono:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace;
--font-rounded: 'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif;
--font-serif: Georgia, 'Times New Roman', serif;
}

View File

@@ -0,0 +1 @@
export { useColorScheme } from 'react-native';

View File

@@ -0,0 +1,21 @@
import { useEffect, useState } from 'react';
import { useColorScheme as useRNColorScheme } from 'react-native';
/**
* To support static rendering, this value needs to be re-calculated on the client side for web
*/
export function useColorScheme() {
const [hasHydrated, setHasHydrated] = useState(false);
useEffect(() => {
setHasHydrated(true);
}, []);
const colorScheme = useRNColorScheme();
if (hasHydrated) {
return colorScheme;
}
return 'light';
}

View File

@@ -0,0 +1,14 @@
/**
* Learn more about light and dark modes:
* https://docs.expo.dev/guides/color-schemes/
*/
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
export function useTheme() {
const scheme = useColorScheme();
const theme = scheme === 'unspecified' ? 'light' : scheme;
return Colors[theme];
}

View File

@@ -0,0 +1,10 @@
# Platform Layer
平台能力统一放在本目录,页面不直接绑定具体 Expo/原生库。
- `camera`:相机、相册、票据采集和本地预处理。
- `voice`:录音、语音转写、麦克风权限。
- `upload`:附件上传、进度、重试、临时附件引用。
- `permissions`Android / iOS 权限文案和降级策略。
相机与语音都先产生用户可确认的中间结果,不直接触发草稿持久化或提交审批。

View File

@@ -0,0 +1,55 @@
import * as ImagePicker from 'expo-image-picker';
export type CapturedReceipt = {
uri: string;
fileName: string;
mimeType?: string;
source: 'camera' | 'library';
};
function toReceipt(asset: ImagePicker.ImagePickerAsset, source: CapturedReceipt['source']): CapturedReceipt {
return {
uri: asset.uri,
fileName: asset.fileName || `receipt-${Date.now()}.jpg`,
mimeType: asset.mimeType,
source,
};
}
export async function captureReceiptFromCamera(): Promise<CapturedReceipt | null> {
const permission = await ImagePicker.requestCameraPermissionsAsync();
if (!permission.granted) {
throw new Error('需要相机权限才能拍摄票据。');
}
const result = await ImagePicker.launchCameraAsync({
allowsEditing: false,
quality: 0.84,
mediaTypes: ['images'],
});
if (result.canceled || !result.assets[0]) {
return null;
}
return toReceipt(result.assets[0], 'camera');
}
export async function pickReceiptFromLibrary(): Promise<CapturedReceipt | null> {
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permission.granted) {
throw new Error('需要相册权限才能选择票据图片。');
}
const result = await ImagePicker.launchImageLibraryAsync({
allowsEditing: false,
quality: 0.88,
mediaTypes: ['images'],
});
if (result.canceled || !result.assets[0]) {
return null;
}
return toReceipt(result.assets[0], 'library');
}

View File

@@ -0,0 +1,25 @@
import { useAudioRecorder, RecordingPresets } from 'expo-audio';
export function useVoiceRecorder() {
const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
return {
recorder,
async start() {
await recorder.prepareToRecordAsync();
recorder.record();
},
async stop() {
await recorder.stop();
return recorder.uri;
},
};
}
export async function transcribeVoice(uri: string) {
// 后续接入 /api/v1/mobile/voice/transcribe初始化阶段先返回可见占位结果。
return {
text: `已收到语音文件:${uri.split('/').pop() || 'voice-recording'}`,
confidence: 0,
};
}

View File

@@ -0,0 +1,11 @@
# Shared Layer
共享层承载跨 feature 的稳定能力:
- `api`:接口 client、OpenAPI 生成类型、请求错误映射。
- `auth`登录态、SecureStore、后端模拟身份请求头。
- `components`:可复用展示组件和触控组件。
- `domain`:报销状态、审批阶段、权限判断和 view model 映射。
- `mock`:初始化阶段的本地演示数据,接入后端后逐步替换。
业务状态判断优先放在 `domain`,避免 Web 和 mobile 对同一状态出现不同解释。

View File

@@ -0,0 +1,29 @@
import Constants from 'expo-constants';
type ApiOptions = RequestInit & {
authHeaders?: Record<string, string>;
};
const fallbackApiBaseUrl = 'http://10.0.2.2:8000/api/v1';
export const apiBaseUrl =
(Constants.expoConfig?.extra?.apiBaseUrl as string | undefined) || fallbackApiBaseUrl;
export async function apiRequest<T>(path: string, options: ApiOptions = {}): Promise<T> {
const response = await fetch(`${apiBaseUrl}${path}`, {
...options,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...options.authHeaders,
...options.headers,
},
});
if (!response.ok) {
const body = await response.text();
throw new Error(body || `HTTP ${response.status}`);
}
return response.json() as Promise<T>;
}

View File

@@ -0,0 +1,51 @@
import * as SecureStore from 'expo-secure-store';
import { create } from 'zustand';
type UserSession = {
username: string;
displayName: string;
roleCodes: string[];
};
type SessionState = {
user: UserSession | null;
restore: () => Promise<void>;
signInAsDemoUser: () => Promise<void>;
signOut: () => Promise<void>;
};
const storageKey = 'x-financial-mobile-session';
const demoSession: UserSession = {
username: 'zhangsan',
displayName: '张三',
roleCodes: ['employee'],
};
export const useSessionStore = create<SessionState>((set) => ({
user: demoSession,
async restore() {
const raw = await SecureStore.getItemAsync(storageKey);
set({ user: raw ? (JSON.parse(raw) as UserSession) : demoSession });
},
async signInAsDemoUser() {
await SecureStore.setItemAsync(storageKey, JSON.stringify(demoSession));
set({ user: demoSession });
},
async signOut() {
await SecureStore.deleteItemAsync(storageKey);
set({ user: null });
},
}));
export function buildAuthHeaders(user: UserSession | null) {
if (!user) {
return {};
}
return {
'X-Auth-Username': user.username,
'X-Auth-Name': user.displayName,
'X-Auth-Role-Codes': user.roleCodes.join(','),
'X-Auth-Is-Admin': 'false',
};
}

View File

@@ -0,0 +1,60 @@
import type { PropsWithChildren } from 'react';
import { Pressable, StyleSheet, Text } from 'react-native';
import { Colors, Radius, Spacing } from '@/constants/theme';
type Props = PropsWithChildren<{
onPress?: () => void;
variant?: 'primary' | 'secondary' | 'danger';
accessibilityLabel: string;
}>;
export function ActionButton({ children, onPress, variant = 'primary', accessibilityLabel }: Props) {
return (
<Pressable
accessibilityLabel={accessibilityLabel}
accessibilityRole="button"
onPress={onPress}
style={({ pressed }) => [
styles.button,
styles[variant],
pressed && styles.pressed,
]}>
<Text style={[styles.text, variant !== 'primary' && styles.secondaryText]}>{children}</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
button: {
minHeight: 48,
borderRadius: Radius.md,
paddingHorizontal: Spacing.four,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
},
primary: {
backgroundColor: Colors.light.primary,
borderColor: Colors.light.primary,
},
secondary: {
backgroundColor: '#FFFFFF',
borderColor: Colors.light.border,
},
danger: {
backgroundColor: '#FFFFFF',
borderColor: Colors.light.danger,
},
text: {
color: '#FFFFFF',
fontSize: 15,
fontWeight: '900',
},
secondaryText: {
color: Colors.light.text,
},
pressed: {
opacity: 0.76,
},
});

View File

@@ -0,0 +1,91 @@
import { StyleSheet, Text, View } from 'react-native';
import { Colors, Radius, Spacing } from '@/constants/theme';
import { formatMoney, type ClaimSummary } from '@/shared/domain/claim';
import { StatusPill } from '@/shared/components/StatusPill';
type Props = {
claim: ClaimSummary;
};
export function ClaimCard({ claim }: Props) {
return (
<View style={styles.card}>
<View style={styles.top}>
<View style={styles.iconBox}>
<Text style={styles.iconText}>{claim.title.slice(0, 1)}</Text>
</View>
<View style={styles.titleBlock}>
<Text style={styles.title}>{claim.title}</Text>
<Text style={styles.meta}>{claim.claimNo}</Text>
</View>
<Text style={styles.amount}>{formatMoney(claim.amount)}</Text>
</View>
<View style={styles.bottom}>
<View>
<Text style={styles.meta}>{claim.applicant}</Text>
<Text style={styles.meta}>{claim.approvalStage}</Text>
</View>
<StatusPill status={claim.status} />
</View>
</View>
);
}
const styles = StyleSheet.create({
card: {
gap: Spacing.four,
borderRadius: Radius.lg,
borderWidth: 1,
borderColor: Colors.light.border,
backgroundColor: Colors.light.backgroundElement,
padding: Spacing.four,
shadowColor: '#0F172A',
shadowOpacity: 0.08,
shadowRadius: 14,
elevation: 2,
},
top: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.three,
},
iconBox: {
width: 46,
height: 46,
borderRadius: Radius.lg,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: Colors.light.primarySoft,
},
iconText: {
color: Colors.light.primary,
fontWeight: '900',
},
titleBlock: {
flex: 1,
gap: 3,
},
title: {
color: Colors.light.text,
fontSize: 17,
fontWeight: '900',
},
amount: {
color: Colors.light.text,
fontSize: 20,
fontWeight: '900',
},
bottom: {
flexDirection: 'row',
alignItems: 'flex-end',
justifyContent: 'space-between',
gap: Spacing.three,
},
meta: {
color: Colors.light.textSecondary,
fontSize: 13,
lineHeight: 21,
},
});

View File

@@ -0,0 +1,50 @@
import type { PropsWithChildren } from 'react';
import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Colors, Spacing } from '@/constants/theme';
type Props = PropsWithChildren<{
title: string;
subtitle?: string;
}>;
export function Screen({ title, subtitle, children }: Props) {
return (
<SafeAreaView style={styles.safeArea}>
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
<View style={styles.header}>
<Text style={styles.title}>{title}</Text>
{subtitle ? <Text style={styles.subtitle}>{subtitle}</Text> : null}
</View>
{children}
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: Colors.light.background,
},
content: {
gap: Spacing.four,
paddingHorizontal: Spacing.four,
paddingTop: Spacing.four,
paddingBottom: 112,
},
header: {
gap: Spacing.two,
},
title: {
color: Colors.light.text,
fontSize: 28,
fontWeight: '900',
},
subtitle: {
color: Colors.light.textSecondary,
fontSize: 14,
lineHeight: 22,
},
});

View File

@@ -0,0 +1,44 @@
import { StyleSheet, Text, View } from 'react-native';
import { Colors, Radius } from '@/constants/theme';
import { claimStatusMeta, type ClaimStatus } from '@/shared/domain/claim';
const toneColors = {
success: { background: '#DFF8EC', text: '#047857' },
warning: { background: '#FFF3DC', text: '#B45309' },
danger: { background: '#FEE2E2', text: '#B91C1C' },
info: { background: '#E8F1FF', text: '#1D4ED8' },
muted: { background: '#EEF4F8', text: '#58677F' },
};
type Props = {
status: ClaimStatus;
};
export function StatusPill({ status }: Props) {
const meta = claimStatusMeta[status];
const tone = toneColors[meta.tone];
return (
<View style={[styles.pill, { backgroundColor: tone.background }]}>
<Text style={[styles.text, { color: tone.text }]}>{meta.label}</Text>
</View>
);
}
const styles = StyleSheet.create({
pill: {
alignSelf: 'flex-start',
borderRadius: Radius.pill,
minHeight: 28,
paddingHorizontal: 12,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: Colors.light.border,
},
text: {
fontSize: 12,
fontWeight: '800',
},
});

View File

@@ -0,0 +1,39 @@
export type ClaimStatus =
| 'draft'
| 'pending_submission'
| 'manager_review'
| 'finance_review'
| 'returned'
| 'approved'
| 'paid'
| 'rejected';
export type ClaimAction = 'continue' | 'supplement' | 'submit' | 'approve' | 'return' | 'view';
export type ClaimSummary = {
id: string;
claimNo: string;
title: string;
amount: number;
applicant: string;
submittedAt: string;
status: ClaimStatus;
approvalStage: string;
canEdit: boolean;
canSupplement: boolean;
};
export const claimStatusMeta: Record<ClaimStatus, { label: string; tone: 'success' | 'warning' | 'danger' | 'info' | 'muted' }> = {
draft: { label: '草稿', tone: 'warning' },
pending_submission: { label: '待提交', tone: 'warning' },
manager_review: { label: '主管审批中', tone: 'success' },
finance_review: { label: '财务复核中', tone: 'info' },
returned: { label: '待补充', tone: 'warning' },
approved: { label: '已通过', tone: 'success' },
paid: { label: '已打款', tone: 'success' },
rejected: { label: '已驳回', tone: 'danger' },
};
export function formatMoney(value: number) {
return `¥${value.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`;
}

View File

@@ -0,0 +1,67 @@
import type { ClaimSummary } from '@/shared/domain/claim';
export const myClaims: ClaimSummary[] = [
{
id: 'claim-travel-0422',
claimNo: 'REQ-2026-0422',
title: '差旅报销',
amount: 3280,
applicant: '张三',
submittedAt: '2026-05-03',
status: 'manager_review',
approvalStage: '主管审批中',
canEdit: false,
canSupplement: false,
},
{
id: 'claim-office-0431',
claimNo: 'REQ-2026-0431',
title: '办公采购',
amount: 458,
applicant: '张三',
submittedAt: '2026-05-01',
status: 'finance_review',
approvalStage: '财务复核',
canEdit: false,
canSupplement: false,
},
{
id: 'claim-business-0458',
claimNo: 'REQ-2026-0458',
title: '业务招待费',
amount: 860,
applicant: '张三',
submittedAt: '2026-04-30',
status: 'returned',
approvalStage: '部门经理审批',
canEdit: true,
canSupplement: true,
},
];
export const approvalClaims: ClaimSummary[] = [
{
id: 'approval-travel-0422',
claimNo: 'REQ-2026-0422',
title: '差旅报销',
amount: 3280,
applicant: '李四',
submittedAt: '2026-05-03',
status: 'manager_review',
approvalStage: '当前审批',
canEdit: false,
canSupplement: false,
},
{
id: 'approval-meal-0468',
claimNo: 'REQ-2026-0468',
title: '客户招待费',
amount: 1180,
applicant: '王五',
submittedAt: '2026-05-02',
status: 'manager_review',
approvalStage: '当前审批',
canEdit: false,
canSupplement: false,
},
];

4
mobile/app/src/types/css.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare module '*.module.css' {
const classes: Record<string, string>;
export default classes;
}