feat(mobile): track mobile app scaffold
This commit is contained in:
64
mobile/app/src/app/_layout.tsx
Normal file
64
mobile/app/src/app/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
mobile/app/src/app/approvals.tsx
Normal file
64
mobile/app/src/app/approvals.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
180
mobile/app/src/app/assistant.tsx
Normal file
180
mobile/app/src/app/assistant.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
48
mobile/app/src/app/claims.tsx
Normal file
48
mobile/app/src/app/claims.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
181
mobile/app/src/app/explore.tsx
Normal file
181
mobile/app/src/app/explore.tsx
Normal 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'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',
|
||||
},
|
||||
});
|
||||
105
mobile/app/src/app/index.tsx
Normal file
105
mobile/app/src/app/index.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
92
mobile/app/src/app/profile.tsx
Normal file
92
mobile/app/src/app/profile.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
6
mobile/app/src/components/animated-icon.module.css
Normal file
6
mobile/app/src/components/animated-icon.module.css
Normal file
@@ -0,0 +1,6 @@
|
||||
.expoLogoBackground {
|
||||
background-image: linear-gradient(180deg, #3c9ffe, #0274df);
|
||||
border-radius: 40px;
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
}
|
||||
132
mobile/app/src/components/animated-icon.tsx
Normal file
132
mobile/app/src/components/animated-icon.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
108
mobile/app/src/components/animated-icon.web.tsx
Normal file
108
mobile/app/src/components/animated-icon.web.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
33
mobile/app/src/components/app-tabs.tsx
Normal file
33
mobile/app/src/components/app-tabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
mobile/app/src/components/app-tabs.web.tsx
Normal file
116
mobile/app/src/components/app-tabs.web.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
25
mobile/app/src/components/external-link.tsx
Normal file
25
mobile/app/src/components/external-link.tsx
Normal 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,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
35
mobile/app/src/components/hint-row.tsx
Normal file
35
mobile/app/src/components/hint-row.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
73
mobile/app/src/components/themed-text.tsx
Normal file
73
mobile/app/src/components/themed-text.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
16
mobile/app/src/components/themed-view.tsx
Normal file
16
mobile/app/src/components/themed-view.tsx
Normal 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} />;
|
||||
}
|
||||
65
mobile/app/src/components/ui/collapsible.tsx
Normal file
65
mobile/app/src/components/ui/collapsible.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
44
mobile/app/src/components/web-badge.tsx
Normal file
44
mobile/app/src/components/web-badge.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
80
mobile/app/src/constants/theme.ts
Normal file
80
mobile/app/src/constants/theme.ts
Normal 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;
|
||||
11
mobile/app/src/features/README.md
Normal file
11
mobile/app/src/features/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# X-Financial Mobile Features
|
||||
|
||||
本目录按业务功能拆分移动端页面和逻辑:
|
||||
|
||||
- `home`:首页聚合、待办、最近报销进度。
|
||||
- `claims`:我的报销、新建报销、草稿、补材料。
|
||||
- `approvals`:审批列表、审批详情、同意、驳回、转交。
|
||||
- `assistant`:AI 助手、语音输入、票据识别建议。
|
||||
- `profile`:个人信息、角色、设置、退出登录。
|
||||
|
||||
初始化阶段页面入口仍放在 `src/app`,后续复杂业务逻辑优先下沉到对应 feature。
|
||||
9
mobile/app/src/global.css
Normal file
9
mobile/app/src/global.css
Normal 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;
|
||||
}
|
||||
1
mobile/app/src/hooks/use-color-scheme.ts
Normal file
1
mobile/app/src/hooks/use-color-scheme.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { useColorScheme } from 'react-native';
|
||||
21
mobile/app/src/hooks/use-color-scheme.web.ts
Normal file
21
mobile/app/src/hooks/use-color-scheme.web.ts
Normal 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';
|
||||
}
|
||||
14
mobile/app/src/hooks/use-theme.ts
Normal file
14
mobile/app/src/hooks/use-theme.ts
Normal 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];
|
||||
}
|
||||
10
mobile/app/src/platform/README.md
Normal file
10
mobile/app/src/platform/README.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Platform Layer
|
||||
|
||||
平台能力统一放在本目录,页面不直接绑定具体 Expo/原生库。
|
||||
|
||||
- `camera`:相机、相册、票据采集和本地预处理。
|
||||
- `voice`:录音、语音转写、麦克风权限。
|
||||
- `upload`:附件上传、进度、重试、临时附件引用。
|
||||
- `permissions`:Android / iOS 权限文案和降级策略。
|
||||
|
||||
相机与语音都先产生用户可确认的中间结果,不直接触发草稿持久化或提交审批。
|
||||
55
mobile/app/src/platform/camera/receiptCapture.ts
Normal file
55
mobile/app/src/platform/camera/receiptCapture.ts
Normal 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');
|
||||
}
|
||||
25
mobile/app/src/platform/voice/voiceInput.ts
Normal file
25
mobile/app/src/platform/voice/voiceInput.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
11
mobile/app/src/shared/README.md
Normal file
11
mobile/app/src/shared/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Shared Layer
|
||||
|
||||
共享层承载跨 feature 的稳定能力:
|
||||
|
||||
- `api`:接口 client、OpenAPI 生成类型、请求错误映射。
|
||||
- `auth`:登录态、SecureStore、后端模拟身份请求头。
|
||||
- `components`:可复用展示组件和触控组件。
|
||||
- `domain`:报销状态、审批阶段、权限判断和 view model 映射。
|
||||
- `mock`:初始化阶段的本地演示数据,接入后端后逐步替换。
|
||||
|
||||
业务状态判断优先放在 `domain`,避免 Web 和 mobile 对同一状态出现不同解释。
|
||||
29
mobile/app/src/shared/api/client.ts
Normal file
29
mobile/app/src/shared/api/client.ts
Normal 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>;
|
||||
}
|
||||
51
mobile/app/src/shared/auth/session.ts
Normal file
51
mobile/app/src/shared/auth/session.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
60
mobile/app/src/shared/components/ActionButton.tsx
Normal file
60
mobile/app/src/shared/components/ActionButton.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
91
mobile/app/src/shared/components/ClaimCard.tsx
Normal file
91
mobile/app/src/shared/components/ClaimCard.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
50
mobile/app/src/shared/components/Screen.tsx
Normal file
50
mobile/app/src/shared/components/Screen.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
44
mobile/app/src/shared/components/StatusPill.tsx
Normal file
44
mobile/app/src/shared/components/StatusPill.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
39
mobile/app/src/shared/domain/claim.ts
Normal file
39
mobile/app/src/shared/domain/claim.ts
Normal 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 })}`;
|
||||
}
|
||||
67
mobile/app/src/shared/mock/claims.ts
Normal file
67
mobile/app/src/shared/mock/claims.ts
Normal 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
4
mobile/app/src/types/css.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>;
|
||||
export default classes;
|
||||
}
|
||||
Reference in New Issue
Block a user