feat(mobile): track mobile app scaffold
43
mobile/app/.gitignore
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
app-example
|
||||
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
1
mobile/app/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{ "recommendations": ["expo.vscode-expo-tools"] }
|
||||
7
mobile/app/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit",
|
||||
"source.organizeImports": "explicit",
|
||||
"source.sortMembers": "explicit"
|
||||
}
|
||||
}
|
||||
34
mobile/app/PROJECT.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# X-Financial Mobile 初始化说明
|
||||
|
||||
## 运行
|
||||
|
||||
```powershell
|
||||
npm.cmd install --registry=https://registry.npmjs.org
|
||||
npm.cmd run android
|
||||
```
|
||||
|
||||
Android 模拟器访问本机后端时,默认 API 地址为:
|
||||
|
||||
```text
|
||||
http://10.0.2.2:8000/api/v1
|
||||
```
|
||||
|
||||
## 当前工程边界
|
||||
|
||||
- `src/app`:Expo Router 页面入口,已初始化首页、报销、审批、AI 助手、我的。
|
||||
- `src/shared`:API、登录态、状态映射、公共组件和 mock 数据。
|
||||
- `src/platform`:相机、相册、语音输入等平台能力。
|
||||
- `src/features`:后续承载复杂业务逻辑,避免页面文件继续膨胀。
|
||||
|
||||
## 已接入能力
|
||||
|
||||
- Android 权限:相机、麦克风、相册图片读取。
|
||||
- 相机/相册:`platform/camera/receiptCapture.ts`。
|
||||
- 语音录制占位:`platform/voice/voiceInput.ts`。
|
||||
- 请求缓存:`@tanstack/react-query`。
|
||||
- 轻量状态:`zustand`。
|
||||
- 安全存储:`expo-secure-store`。
|
||||
|
||||
## 开发原则
|
||||
|
||||
普通问答、票据预览和 AI 识别建议不自动保存草稿;只有用户明确点击保存、生成、继续提交或关联已有草稿,才进入持久化链路。
|
||||
56
mobile/app/README.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Welcome to your Expo app 👋
|
||||
|
||||
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
|
||||
|
||||
## Get started
|
||||
|
||||
1. Install dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Start the app
|
||||
|
||||
```bash
|
||||
npx expo start
|
||||
```
|
||||
|
||||
In the output, you'll find options to open the app in a
|
||||
|
||||
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
|
||||
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
|
||||
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
|
||||
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
|
||||
|
||||
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
|
||||
|
||||
## Get a fresh project
|
||||
|
||||
When you're ready, run:
|
||||
|
||||
```bash
|
||||
npm run reset-project
|
||||
```
|
||||
|
||||
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
|
||||
|
||||
### Other setup steps
|
||||
|
||||
- To set up ESLint for linting, run `npx expo lint`, or follow our guide on ["Using ESLint and Prettier"](https://docs.expo.dev/guides/using-eslint/)
|
||||
- If you'd like to set up unit testing, follow our guide on ["Unit Testing with Jest"](https://docs.expo.dev/develop/unit-testing/)
|
||||
- Learn more about the TypeScript setup in this template in our guide on ["Using TypeScript"](https://docs.expo.dev/guides/typescript/)
|
||||
|
||||
## Learn more
|
||||
|
||||
To learn more about developing your project with Expo, look at the following resources:
|
||||
|
||||
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
|
||||
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
|
||||
|
||||
## Join the community
|
||||
|
||||
Join our community of developers creating universal apps.
|
||||
|
||||
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
|
||||
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
|
||||
72
mobile/app/app.json
Normal file
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "X-Financial",
|
||||
"slug": "x-financial-mobile",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "xfinancial",
|
||||
"userInterfaceStyle": "light",
|
||||
"ios": {
|
||||
"icon": "./assets/expo.icon"
|
||||
},
|
||||
"android": {
|
||||
"package": "com.xfinancial.mobile",
|
||||
"permissions": [
|
||||
"CAMERA",
|
||||
"RECORD_AUDIO",
|
||||
"READ_MEDIA_IMAGES"
|
||||
],
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#EFFCF6",
|
||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||
},
|
||||
"predictiveBackGestureEnabled": true
|
||||
},
|
||||
"web": {
|
||||
"output": "static",
|
||||
"favicon": "./assets/images/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-camera",
|
||||
{
|
||||
"cameraPermission": "X-Financial 需要使用相机拍摄和识别报销票据。"
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-image-picker",
|
||||
{
|
||||
"photosPermission": "X-Financial 需要读取相册中的票据图片用于报销识别。"
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"backgroundColor": "#059669",
|
||||
"android": {
|
||||
"image": "./assets/images/splash-icon.png",
|
||||
"imageWidth": 76
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-audio",
|
||||
{
|
||||
"microphonePermission": "X-Financial 需要使用麦克风进行语音输入。"
|
||||
}
|
||||
],
|
||||
"expo-secure-store"
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
"reactCompiler": true
|
||||
},
|
||||
"extra": {
|
||||
"apiBaseUrl": "http://10.0.2.2:8000/api/v1"
|
||||
}
|
||||
}
|
||||
}
|
||||
3
mobile/app/assets/expo.icon/Assets/expo-symbol 2.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="652" height="606" viewBox="0 0 652 606" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M353.554 0H298.446C273.006 0 249.684 14.6347 237.962 37.9539L4.37994 502.646C-1.04325 513.435 -1.45067 526.178 3.2716 537.313L22.6123 582.918C34.6475 611.297 72.5404 614.156 88.4414 587.885L309.863 222.063C313.34 216.317 319.439 212.826 326 212.826C332.561 212.826 338.659 216.317 342.137 222.063L563.559 587.885C579.46 614.156 617.352 611.297 629.388 582.918L648.728 537.313C653.451 526.178 653.043 513.435 647.62 502.646L414.038 37.9539C402.316 14.6347 378.994 0 353.554 0Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 608 B |
BIN
mobile/app/assets/expo.icon/Assets/grid.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
40
mobile/app/assets/expo.icon/icon.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"fill" : {
|
||||
"automatic-gradient" : "extended-srgb:0.00000,0.47843,1.00000,1.00000"
|
||||
},
|
||||
"groups" : [
|
||||
{
|
||||
"layers" : [
|
||||
{
|
||||
"image-name" : "expo-symbol 2.svg",
|
||||
"name" : "expo-symbol 2",
|
||||
"position" : {
|
||||
"scale" : 1,
|
||||
"translation-in-points" : [
|
||||
1.1008400065293245e-05,
|
||||
-16.046875
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"image-name" : "grid.png",
|
||||
"name" : "grid"
|
||||
}
|
||||
],
|
||||
"shadow" : {
|
||||
"kind" : "neutral",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms" : {
|
||||
"circles" : [
|
||||
"watchOS"
|
||||
],
|
||||
"squares" : "shared"
|
||||
}
|
||||
}
|
||||
BIN
mobile/app/assets/images/android-icon-background.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
mobile/app/assets/images/android-icon-foreground.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
mobile/app/assets/images/android-icon-monochrome.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
mobile/app/assets/images/expo-badge-white.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
mobile/app/assets/images/expo-badge.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
mobile/app/assets/images/expo-logo.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
mobile/app/assets/images/favicon.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
mobile/app/assets/images/icon.png
Normal file
|
After Width: | Height: | Size: 780 KiB |
BIN
mobile/app/assets/images/logo-glow.png
Normal file
|
After Width: | Height: | Size: 324 KiB |
BIN
mobile/app/assets/images/react-logo.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
mobile/app/assets/images/react-logo@2x.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
mobile/app/assets/images/react-logo@3x.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
mobile/app/assets/images/splash-icon.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
mobile/app/assets/images/tabIcons/explore.png
Normal file
|
After Width: | Height: | Size: 215 B |
BIN
mobile/app/assets/images/tabIcons/explore@2x.png
Normal file
|
After Width: | Height: | Size: 347 B |
BIN
mobile/app/assets/images/tabIcons/explore@3x.png
Normal file
|
After Width: | Height: | Size: 468 B |
BIN
mobile/app/assets/images/tabIcons/home.png
Normal file
|
After Width: | Height: | Size: 253 B |
BIN
mobile/app/assets/images/tabIcons/home@2x.png
Normal file
|
After Width: | Height: | Size: 343 B |
BIN
mobile/app/assets/images/tabIcons/home@3x.png
Normal file
|
After Width: | Height: | Size: 479 B |
BIN
mobile/app/assets/images/tutorial-web.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
10
mobile/app/eslint.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
// https://docs.expo.dev/guides/using-eslint/
|
||||
const { defineConfig } = require('eslint/config');
|
||||
const expoConfig = require("eslint-config-expo/flat");
|
||||
|
||||
module.exports = defineConfig([
|
||||
expoConfig,
|
||||
{
|
||||
ignores: ["dist/*"],
|
||||
}
|
||||
]);
|
||||
13071
mobile/app/package-lock.json
generated
Normal file
57
mobile/app/package.json
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "x-financial-mobile",
|
||||
"main": "expo-router/entry",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"android": "expo start --android",
|
||||
"android:clear": "expo start --android --clear",
|
||||
"android:clear": "expo start --android --clear",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"lint": "expo lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.1.1",
|
||||
"@react-navigation/bottom-tabs": "^7.15.5",
|
||||
"@react-navigation/elements": "^2.9.10",
|
||||
"@react-navigation/native": "^7.1.33",
|
||||
"@tanstack/react-query": "^5.100.11",
|
||||
"expo": "~55.0.26",
|
||||
"expo-audio": "~55.0.14",
|
||||
"expo-camera": "~55.0.19",
|
||||
"expo-constants": "~55.0.16",
|
||||
"expo-device": "~55.0.17",
|
||||
"expo-file-system": "~55.0.22",
|
||||
"expo-font": "~55.0.8",
|
||||
"expo-glass-effect": "~55.0.11",
|
||||
"expo-image": "~55.0.11",
|
||||
"expo-image-picker": "~55.0.20",
|
||||
"expo-linking": "~55.0.15",
|
||||
"expo-router": "~55.0.16",
|
||||
"expo-secure-store": "~55.0.14",
|
||||
"expo-splash-screen": "~55.0.21",
|
||||
"expo-status-bar": "~55.0.6",
|
||||
"expo-symbols": "~55.0.9",
|
||||
"expo-system-ui": "~55.0.18",
|
||||
"expo-web-browser": "~55.0.16",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-native": "0.83.6",
|
||||
"react-native-gesture-handler": "~2.30.0",
|
||||
"react-native-reanimated": "4.2.1",
|
||||
"react-native-safe-area-context": "~5.6.2",
|
||||
"react-native-screens": "~4.23.0",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-worklets": "0.7.4",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.2.2",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-expo": "~55.0.1",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
114
mobile/app/scripts/reset-project.js
Normal file
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* This script is used to reset the project to a blank state.
|
||||
* It deletes or moves the /src and /scripts directories to /example based on user input and creates a new /src/app directory with an index.tsx and _layout.tsx file.
|
||||
* You can remove the `reset-project` script from package.json and safely delete this file after running it.
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const readline = require("readline");
|
||||
|
||||
const root = process.cwd();
|
||||
const oldDirs = ["src", "scripts"];
|
||||
const exampleDir = "example";
|
||||
const newAppDir = "src/app";
|
||||
const exampleDirPath = path.join(root, exampleDir);
|
||||
|
||||
const indexContent = `import { Text, View, StyleSheet } from "react-native";
|
||||
|
||||
export default function Index() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text>Edit src/app/index.tsx to edit this screen.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
});
|
||||
`;
|
||||
|
||||
const layoutContent = `import { Stack } from "expo-router";
|
||||
|
||||
export default function RootLayout() {
|
||||
return <Stack />;
|
||||
}
|
||||
`;
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
const moveDirectories = async (userInput) => {
|
||||
try {
|
||||
if (userInput === "y") {
|
||||
// Create the app-example directory
|
||||
await fs.promises.mkdir(exampleDirPath, { recursive: true });
|
||||
console.log(`📁 /${exampleDir} directory created.`);
|
||||
}
|
||||
|
||||
// Move old directories to new app-example directory or delete them
|
||||
for (const dir of oldDirs) {
|
||||
const oldDirPath = path.join(root, dir);
|
||||
if (fs.existsSync(oldDirPath)) {
|
||||
if (userInput === "y") {
|
||||
const newDirPath = path.join(root, exampleDir, dir);
|
||||
await fs.promises.rename(oldDirPath, newDirPath);
|
||||
console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`);
|
||||
} else {
|
||||
await fs.promises.rm(oldDirPath, { recursive: true, force: true });
|
||||
console.log(`❌ /${dir} deleted.`);
|
||||
}
|
||||
} else {
|
||||
console.log(`➡️ /${dir} does not exist, skipping.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create new /src/app directory
|
||||
const newAppDirPath = path.join(root, newAppDir);
|
||||
await fs.promises.mkdir(newAppDirPath, { recursive: true });
|
||||
console.log("\n📁 New /src/app directory created.");
|
||||
|
||||
// Create index.tsx
|
||||
const indexPath = path.join(newAppDirPath, "index.tsx");
|
||||
await fs.promises.writeFile(indexPath, indexContent);
|
||||
console.log("📄 src/app/index.tsx created.");
|
||||
|
||||
// Create _layout.tsx
|
||||
const layoutPath = path.join(newAppDirPath, "_layout.tsx");
|
||||
await fs.promises.writeFile(layoutPath, layoutContent);
|
||||
console.log("📄 src/app/_layout.tsx created.");
|
||||
|
||||
console.log("\n✅ Project reset complete. Next steps:");
|
||||
console.log(
|
||||
`1. Run \`npx expo start\` to start a development server.\n2. Edit src/app/index.tsx to edit the main screen.\n3. Put all your application code in /src, only screens and layout files should be in /src/app.${
|
||||
userInput === "y"
|
||||
? `\n4. Delete the /${exampleDir} directory when you're done referencing it.`
|
||||
: ""
|
||||
}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`❌ Error during script execution: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
rl.question(
|
||||
"Do you want to move existing files to /example instead of deleting them? (Y/n): ",
|
||||
(answer) => {
|
||||
const userInput = answer.trim().toLowerCase() || "y";
|
||||
if (userInput === "y" || userInput === "n") {
|
||||
moveDirectories(userInput).finally(() => rl.close());
|
||||
} else {
|
||||
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
);
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1 @@
|
||||
export { useColorScheme } from 'react-native';
|
||||
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
@@ -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
@@ -0,0 +1,10 @@
|
||||
# Platform Layer
|
||||
|
||||
平台能力统一放在本目录,页面不直接绑定具体 Expo/原生库。
|
||||
|
||||
- `camera`:相机、相册、票据采集和本地预处理。
|
||||
- `voice`:录音、语音转写、麦克风权限。
|
||||
- `upload`:附件上传、进度、重试、临时附件引用。
|
||||
- `permissions`:Android / iOS 权限文案和降级策略。
|
||||
|
||||
相机与语音都先产生用户可确认的中间结果,不直接触发草稿持久化或提交审批。
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,4 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>;
|
||||
export default classes;
|
||||
}
|
||||
20
mobile/app/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"@/assets/*": [
|
||||
"./assets/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".expo/types/**/*.ts",
|
||||
"expo-env.d.ts"
|
||||
]
|
||||
}
|
||||