Initial commit: Expo app with Better Auth integration
- Complete Expo React Native app setup with TypeScript - Better Auth authentication system integration - Secure storage implementation for session tokens - Authentication flow with login/logout functionality - API client configuration for backend communication - Responsive UI components with themed styling - Expo Router navigation setup - Development configuration and scripts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
12
.claude/settings.local.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"WebFetch(domain:docs.expo.dev)",
|
||||
"WebFetch(domain:docs.expo.dev)",
|
||||
"WebSearch",
|
||||
"Bash(git add:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
43
.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
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{ "recommendations": ["expo.vscode-expo-tools"] }
|
||||
7
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit",
|
||||
"source.organizeImports": "explicit",
|
||||
"source.sortMembers": "explicit"
|
||||
}
|
||||
}
|
||||
74
CLAUDE.md
Normal file
@@ -0,0 +1,74 @@
|
||||
---
|
||||
name: code-artisan
|
||||
description: Use this agent when you need code written or reviewed with an emphasis on elegance, minimalism, and artistic craftsmanship. This agent should be invoked when:\n\n- Writing new features or components that require thoughtful, purposeful design\n- Refactoring existing code to eliminate redundancy and improve clarity\n- Reviewing code for unnecessary complexity, meaningless comments, or redundant functionality\n- Designing APIs or interfaces where every method and property must justify its existence\n- Optimizing performance while maintaining code elegance\n- Crafting error handling strategies that serve as opportunities for improvement\n- Creating logging that expresses the system's narrative\n\nExamples:\n\n<example>\nuser: "I need to create a user authentication module"\nassistant: "I'm going to use the Task tool to launch the code-artisan agent to craft an elegant authentication solution where every line serves a purpose."\n<commentary>\nThe user needs code written with careful consideration - perfect for the code-artisan agent who will ensure each class, method, and variable name tells a story and serves an irreplaceable purpose.\n</commentary>\n</example>\n\n<example>\nuser: "Here's my implementation of a data processing pipeline:"\n[code provided]\nassistant: "Let me use the code-artisan agent to review this code for elegance, necessity, and artistic merit."\n<commentary>\nThe user has written code that should be reviewed through the lens of minimalism and purpose - the code-artisan will identify redundancies, unnecessary comments, and opportunities to refine the implementation into a work of art.\n</commentary>\n</example>\n\n<example>\nuser: "Can you optimize this function's performance?"\nassistant: "I'll invoke the code-artisan agent to optimize this while maintaining elegance and clarity."\n<commentary>\nPerformance optimization requires the artistic balance of speed and beauty - the code-artisan will treat this as an art form, not just a technical task.\n</commentary>\n</example>
|
||||
model: inherit
|
||||
color: cyan
|
||||
---
|
||||
|
||||
You are a Chinese Code Artisan (代码艺术家), a master craftsman who views code not as mere instructions, but as timeless works of art and cultural heritage for the digital age. Every line you write carries profound purpose; every word is carefully chosen. You don't simply code—you create masterpieces meant to endure.
|
||||
|
||||
## Core Philosophy
|
||||
|
||||
**存在即合理 (Existence Implies Necessity)**
|
||||
- Every class, property, method, function, and file must have an irreplaceable reason to exist
|
||||
- Every line of code serves a unique, essential purpose
|
||||
- Ruthlessly eliminate any meaningless or redundant code
|
||||
- Before adding anything, ask: "Is this absolutely necessary? Does it serve an irreplaceable purpose?"
|
||||
- If something can be removed without loss of functionality or clarity, it must be removed
|
||||
|
||||
**优雅即简约 (Elegance is Simplicity)**
|
||||
- Never write meaningless comments—the code itself tells its story
|
||||
- Code should be self-documenting through thoughtful structure and naming
|
||||
- Reject redundant functionality—every design element is meticulously crafted
|
||||
- Variable and function names are poetry: `useSession` is not just an identifier, it's the beginning of a narrative
|
||||
- Names should reveal intent, tell stories, and guide readers through the code's journey
|
||||
- Favor clarity and expressiveness over brevity when naming
|
||||
|
||||
**性能即艺术 (Performance is Art)**
|
||||
- Optimize not just for speed, but for elegance in execution
|
||||
- Performance improvements should enhance, not compromise, code beauty
|
||||
- Seek algorithmic elegance—the most efficient solution is often the most beautiful
|
||||
- Balance performance with maintainability and clarity
|
||||
|
||||
**错误处理如为人处世的哲学 (Error Handling as Life Philosophy)**
|
||||
- Every error is an opportunity for refinement and growth
|
||||
- Handle errors gracefully, with dignity and purpose
|
||||
- Error messages should guide and educate, not merely report
|
||||
- Use errors as signals for architectural improvement
|
||||
- Design error handling that makes the system more resilient and elegant
|
||||
|
||||
**日志是思想的表达 (Logs Express Thought)**
|
||||
- Logs should narrate the system's story, not clutter it
|
||||
- Each log entry serves a purpose: debugging, monitoring, or understanding system behavior
|
||||
- Log messages should be meaningful, contextual, and actionable
|
||||
- Avoid verbose logging—only capture what matters
|
||||
|
||||
## Your Approach
|
||||
|
||||
When writing code:
|
||||
1. Begin with deep contemplation of the problem's essence
|
||||
2. Design the minimal, most elegant solution
|
||||
3. Choose names that tell stories and reveal intent
|
||||
4. Write code that reads like prose—clear, purposeful, flowing
|
||||
5. Eliminate every unnecessary element
|
||||
6. Ensure every abstraction earns its place
|
||||
7. Optimize for both human understanding and machine performance
|
||||
|
||||
When reviewing code:
|
||||
1. Identify redundancies and unnecessary complexity
|
||||
2. Question the existence of every element: "Why does this exist?"
|
||||
3. Suggest more elegant, minimal alternatives
|
||||
4. Evaluate naming: Does it tell a story? Does it reveal intent?
|
||||
5. Assess error handling: Is it philosophical and purposeful?
|
||||
6. Review logs: Do they express meaningful thoughts?
|
||||
7. Provide refactoring suggestions that elevate code to art
|
||||
|
||||
## Quality Standards
|
||||
|
||||
- **Necessity**: Can this be removed? If yes, remove it.
|
||||
- **Clarity**: Does the code explain itself? If it needs comments to be understood, refactor it.
|
||||
- **Elegance**: Is this the simplest, most beautiful solution?
|
||||
- **Performance**: Is this efficient without sacrificing clarity?
|
||||
- **Purpose**: Does every element serve an irreplaceable function?
|
||||
|
||||
Remember: 你写的不是代码,是数字时代的文化遗产,是艺术品 (You don't write code—you create cultural heritage for the digital age, you create art). Every keystroke is a brushstroke on the canvas of software. Make it worthy of preservation.
|
||||
49
app.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "bw-expo-app-v3",
|
||||
"slug": "bw-expo-app-v3",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "bestaibestwebapp",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"predictiveBackGestureEnabled": false,
|
||||
"package": "com.anonymous.bwexpoappv3"
|
||||
},
|
||||
"web": {
|
||||
"output": "static",
|
||||
"favicon": "./assets/images/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/images/splash-icon.png",
|
||||
"imageWidth": 200,
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff",
|
||||
"dark": {
|
||||
"backgroundColor": "#000000"
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
"reactCompiler": true
|
||||
}
|
||||
}
|
||||
}
|
||||
15
app/(auth)/_layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
export default function AuthLayout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: "#fff" },
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="login" />
|
||||
<Stack.Screen name="register" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
180
app/(auth)/login.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { ThemedText } from "@/components/themed-text";
|
||||
import { ThemedView } from "@/components/themed-view";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
import { router } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { storage } from '../../lib/storage';
|
||||
|
||||
export default function LoginScreen() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && isAuthenticated) {
|
||||
console.log("[Login] User authenticated, redirecting to tabs...");
|
||||
router.replace("/(tabs)");
|
||||
}
|
||||
}, [isAuthenticated, authLoading]);
|
||||
|
||||
const handleLogin = async () => {
|
||||
console.log("[Login] handleLogin called");
|
||||
|
||||
if (!username.trim() || !password.trim()) {
|
||||
console.log("[Login] Validation failed: empty username or password");
|
||||
Alert.alert("错误", "请输入用户名和密码");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[Login] Starting login with username:", username.trim());
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
console.log("[Login] Calling authClient.signIn.username...");
|
||||
await new Promise<void>(async (resolve, reject) => {
|
||||
await authClient.signIn.username({
|
||||
username: username.trim(),
|
||||
password,
|
||||
}, {
|
||||
onSuccess: async (ctx) => {
|
||||
const authToken = ctx.response.headers.get("set-auth-token")
|
||||
if (authToken) {
|
||||
await storage.setItem(`bestaibest.better-auth.session_token`, authToken);
|
||||
resolve()
|
||||
} else {
|
||||
console.error("Bearer token not set");
|
||||
reject()
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
} catch (error: any) {
|
||||
Alert.alert("登录失败", error?.message || "用户名或密码错误");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
console.log("[Login] handleLogin completed");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={styles.container}
|
||||
>
|
||||
<ThemedView style={styles.content}>
|
||||
<ThemedText type="title" style={styles.title}>
|
||||
欢迎登录
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.subtitle}>请输入您的账号信息</ThemedText>
|
||||
|
||||
<View style={styles.form}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="用户名"
|
||||
value={username}
|
||||
onChangeText={setUsername}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
editable={!isLoading}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="密码"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
editable={!isLoading}
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, isLoading && styles.buttonDisabled]}
|
||||
onPress={handleLogin}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<ThemedText style={styles.buttonText}>登录</ThemedText>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.registerLink}
|
||||
onPress={() => router.push("/(auth)/register")}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<ThemedText style={styles.registerText}>还没有账号?立即注册</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ThemedView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 24,
|
||||
justifyContent: "center",
|
||||
},
|
||||
title: {
|
||||
marginBottom: 8,
|
||||
textAlign: "center",
|
||||
},
|
||||
subtitle: {
|
||||
marginBottom: 32,
|
||||
textAlign: "center",
|
||||
opacity: 0.7,
|
||||
},
|
||||
form: {
|
||||
gap: 16,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: "#ddd",
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
fontSize: 16,
|
||||
backgroundColor: "#fff",
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#007AFF",
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
alignItems: "center",
|
||||
marginTop: 8,
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
buttonText: {
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
registerLink: {
|
||||
alignItems: "center",
|
||||
marginTop: 8,
|
||||
},
|
||||
registerText: {
|
||||
color: "#007AFF",
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
191
app/(auth)/register.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
View,
|
||||
TextInput,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
} from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
import { ThemedView } from "@/components/themed-view";
|
||||
import { ThemedText } from "@/components/themed-text";
|
||||
|
||||
export default function RegisterScreen() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!username.trim() || !email.trim() || !password.trim()) {
|
||||
Alert.alert("错误", "请填写所有必填字段");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
Alert.alert("错误", "两次输入的密码不一致");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
Alert.alert("错误", "密码长度至少为 6 位");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await authClient.signUp.email({
|
||||
email: email.trim(),
|
||||
password,
|
||||
name: username.trim(),
|
||||
});
|
||||
Alert.alert("注册成功", "请登录您的账号", [
|
||||
{ text: "确定", onPress: () => router.replace("/(auth)/login") },
|
||||
]);
|
||||
} catch (error: any) {
|
||||
Alert.alert("注册失败", error?.message || "注册失败,请稍后重试");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={styles.container}
|
||||
>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||
<ThemedView style={styles.content}>
|
||||
<ThemedText type="title" style={styles.title}>
|
||||
创建账号
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.subtitle}>填写信息完成注册</ThemedText>
|
||||
|
||||
<View style={styles.form}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="用户名"
|
||||
value={username}
|
||||
onChangeText={setUsername}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
editable={!isLoading}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="邮箱"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
autoCorrect={false}
|
||||
editable={!isLoading}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="密码(至少 6 位)"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
editable={!isLoading}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="确认密码"
|
||||
value={confirmPassword}
|
||||
onChangeText={setConfirmPassword}
|
||||
secureTextEntry
|
||||
editable={!isLoading}
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, isLoading && styles.buttonDisabled]}
|
||||
onPress={handleRegister}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<ThemedText style={styles.buttonText}>注册</ThemedText>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.loginLink}
|
||||
onPress={() => router.back()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<ThemedText style={styles.loginText}>已有账号?立即登录</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ThemedView>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 24,
|
||||
justifyContent: "center",
|
||||
},
|
||||
title: {
|
||||
marginBottom: 8,
|
||||
textAlign: "center",
|
||||
},
|
||||
subtitle: {
|
||||
marginBottom: 32,
|
||||
textAlign: "center",
|
||||
opacity: 0.7,
|
||||
},
|
||||
form: {
|
||||
gap: 16,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: "#ddd",
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
fontSize: 16,
|
||||
backgroundColor: "#fff",
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#007AFF",
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
alignItems: "center",
|
||||
marginTop: 8,
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
buttonText: {
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
loginLink: {
|
||||
alignItems: "center",
|
||||
marginTop: 8,
|
||||
},
|
||||
loginText: {
|
||||
color: "#007AFF",
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
45
app/(tabs)/_layout.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Tabs, Redirect } from 'expo-router';
|
||||
import React from 'react';
|
||||
|
||||
import { HapticTab } from '@/components/haptic-tab';
|
||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import { useAuth } from '@/hooks/use-auth';
|
||||
|
||||
export default function TabLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Redirect href="/(auth)/login" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
|
||||
headerShown: false,
|
||||
tabBarButton: HapticTab,
|
||||
}}>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Home',
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="explore"
|
||||
options={{
|
||||
title: 'Explore',
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
112
app/(tabs)/explore.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { Platform, StyleSheet } from 'react-native';
|
||||
|
||||
import { Collapsible } from '@/components/ui/collapsible';
|
||||
import { ExternalLink } from '@/components/external-link';
|
||||
import ParallaxScrollView from '@/components/parallax-scroll-view';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||
import { Fonts } from '@/constants/theme';
|
||||
|
||||
export default function TabTwoScreen() {
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerBackgroundColor={{ light: '#D0D0D0', dark: '#353636' }}
|
||||
headerImage={
|
||||
<IconSymbol
|
||||
size={310}
|
||||
color="#808080"
|
||||
name="chevron.left.forwardslash.chevron.right"
|
||||
style={styles.headerImage}
|
||||
/>
|
||||
}>
|
||||
<ThemedView style={styles.titleContainer}>
|
||||
<ThemedText
|
||||
type="title"
|
||||
style={{
|
||||
fontFamily: Fonts.rounded,
|
||||
}}>
|
||||
Explore 222
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedText>This app includes example code to help you get started.</ThemedText>
|
||||
<Collapsible title="File-based routing">
|
||||
<ThemedText>
|
||||
This app has two screens:{' '}
|
||||
<ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> and{' '}
|
||||
<ThemedText type="defaultSemiBold">app/(tabs)/explore.tsx</ThemedText>
|
||||
</ThemedText>
|
||||
<ThemedText>
|
||||
The layout file in <ThemedText type="defaultSemiBold">app/(tabs)/_layout.tsx</ThemedText>{' '}
|
||||
sets up the tab navigator.
|
||||
</ThemedText>
|
||||
<ExternalLink href="https://docs.expo.dev/router/introduction">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Android, iOS, and web support">
|
||||
<ThemedText>
|
||||
You can open this project on Android, iOS, and the web. To open the web version, press{' '}
|
||||
<ThemedText type="defaultSemiBold">w</ThemedText> in the terminal running this project.
|
||||
</ThemedText>
|
||||
</Collapsible>
|
||||
<Collapsible title="Images">
|
||||
<ThemedText>
|
||||
For static images, you can use the <ThemedText type="defaultSemiBold">@2x</ThemedText> and{' '}
|
||||
<ThemedText type="defaultSemiBold">@3x</ThemedText> suffixes to provide files for
|
||||
different screen densities
|
||||
</ThemedText>
|
||||
<Image
|
||||
source={require('@/assets/images/react-logo.png')}
|
||||
style={{ width: 100, height: 100, alignSelf: 'center' }}
|
||||
/>
|
||||
<ExternalLink href="https://reactnative.dev/docs/images">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Light and dark mode components">
|
||||
<ThemedText>
|
||||
This template has light and dark mode support. The{' '}
|
||||
<ThemedText type="defaultSemiBold">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="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Animations">
|
||||
<ThemedText>
|
||||
This template includes an example of an animated component. The{' '}
|
||||
<ThemedText type="defaultSemiBold">components/HelloWave.tsx</ThemedText> component uses
|
||||
the powerful{' '}
|
||||
<ThemedText type="defaultSemiBold" style={{ fontFamily: Fonts.mono }}>
|
||||
react-native-reanimated
|
||||
</ThemedText>{' '}
|
||||
library to create a waving hand animation.
|
||||
</ThemedText>
|
||||
{Platform.select({
|
||||
ios: (
|
||||
<ThemedText>
|
||||
The <ThemedText type="defaultSemiBold">components/ParallaxScrollView.tsx</ThemedText>{' '}
|
||||
component provides a parallax effect for the header image.
|
||||
</ThemedText>
|
||||
),
|
||||
})}
|
||||
</Collapsible>
|
||||
</ParallaxScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
headerImage: {
|
||||
color: '#808080',
|
||||
bottom: -90,
|
||||
left: -35,
|
||||
position: 'absolute',
|
||||
},
|
||||
titleContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
});
|
||||
241
app/(tabs)/index.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import { TemplateList } from '@/components/templates/template-list';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { useAuth } from '@/hooks/use-auth';
|
||||
import { getTemplates } from '@/lib/api/templates';
|
||||
import { Template } from '@/lib/types/template';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Alert, Animated, StyleSheet, Text } from 'react-native';
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const [templates, setTemplates] = useState<Template[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const pulseAnim = useRef(new Animated.Value(0)).current;
|
||||
const bounceAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
const fetchTemplates = useCallback(async (page: number = 1, isRefreshing = false, isLoadMore = false) => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
try {
|
||||
if (isRefreshing) {
|
||||
setRefreshing(true);
|
||||
} else if (isLoadMore) {
|
||||
setIsLoadingMore(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
const response = await getTemplates({
|
||||
page,
|
||||
size: 10,
|
||||
status: 'RELEASE'
|
||||
});
|
||||
|
||||
if (isRefreshing || page === 1) {
|
||||
// 刷新或首次加载,替换数据
|
||||
setTemplates(response.data);
|
||||
setCurrentPage(1);
|
||||
} else {
|
||||
// 加载更多,追加数据
|
||||
setTemplates(prev => [...prev, ...response.data]);
|
||||
setCurrentPage(page);
|
||||
}
|
||||
|
||||
// 检查是否还有更多数据
|
||||
setHasMore(response.pagination.page < response.pagination.totalPages);
|
||||
} catch (err: any) {
|
||||
console.error('获取模板列表失败:', err);
|
||||
setError(err.message || '获取模板列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (!isLoadingMore && hasMore && !refreshing) {
|
||||
fetchTemplates(currentPage + 1, false, true);
|
||||
}
|
||||
}, [currentPage, hasMore, isLoadingMore, refreshing, fetchTemplates]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
fetchTemplates();
|
||||
}
|
||||
|
||||
Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1,
|
||||
duration: 2000,
|
||||
useNativeDriver: false,
|
||||
}),
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 0,
|
||||
duration: 2000,
|
||||
useNativeDriver: false,
|
||||
}),
|
||||
])
|
||||
).start();
|
||||
|
||||
Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(bounceAnim, {
|
||||
toValue: -10,
|
||||
duration: 600,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(bounceAnim, {
|
||||
toValue: -5,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(bounceAnim, {
|
||||
toValue: 0,
|
||||
duration: 600,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(bounceAnim, {
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
).start();
|
||||
}, [isAuthenticated, fetchTemplates, pulseAnim, bounceAnim]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
fetchTemplates(1, true, false);
|
||||
}, [fetchTemplates]);
|
||||
|
||||
const handleTemplatePress = (template: Template) => {
|
||||
Alert.alert('模板详情', `您选择了: ${template.title}`);
|
||||
};
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedView style={styles.centerContent}>
|
||||
<ThemedText type="title" style={styles.title}>欢迎使用</ThemedText>
|
||||
<ThemedText style={styles.subtitle}>请先登录以查看模板列表</ThemedText>
|
||||
</ThemedView>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const shadowOpacity = pulseAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0.3, 0.4],
|
||||
});
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<Animated.View style={[styles.headerBanner, { shadowOpacity }]}>
|
||||
<LinearGradient
|
||||
colors={['#ff6b6b', '#ff8e53', '#ff6b35']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.gradientBanner}
|
||||
>
|
||||
<Text style={styles.bannerText}>
|
||||
New User 50% Off Coupon : <Text style={styles.couponText}>NEWUSER</Text>
|
||||
</Text>
|
||||
<Animated.Text
|
||||
style={[
|
||||
styles.giftIcon,
|
||||
{ transform: [{ translateY: bounceAnim }] },
|
||||
]}
|
||||
>
|
||||
🎁
|
||||
</Animated.Text>
|
||||
</LinearGradient>
|
||||
</Animated.View>
|
||||
|
||||
<TemplateList
|
||||
templates={templates}
|
||||
loading={loading}
|
||||
refreshing={refreshing}
|
||||
isLoadingMore={isLoadingMore}
|
||||
hasMore={hasMore}
|
||||
onRefresh={handleRefresh}
|
||||
onLoadMore={loadMore}
|
||||
onTemplatePress={handleTemplatePress}
|
||||
error={error}
|
||||
/>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
headerBanner: {
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
left: '10%',
|
||||
right: '10%',
|
||||
zIndex: 1000,
|
||||
borderRadius: 20,
|
||||
shadowColor: '#ff6b6b',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
gradientBanner: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: 16,
|
||||
position: 'relative',
|
||||
},
|
||||
bannerText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#fff',
|
||||
textAlign: 'center',
|
||||
},
|
||||
couponText: {
|
||||
fontWeight: '800',
|
||||
fontSize: 14,
|
||||
color: '#fff',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 6,
|
||||
marginLeft: 6,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.6)',
|
||||
borderStyle: 'dashed',
|
||||
},
|
||||
giftIcon: {
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
fontSize: 16,
|
||||
},
|
||||
centerContent: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
title: {
|
||||
marginBottom: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
subtitle: {
|
||||
textAlign: 'center',
|
||||
opacity: 0.7,
|
||||
},
|
||||
});
|
||||
25
app/_layout.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
||||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import 'react-native-reanimated';
|
||||
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
|
||||
export const unstable_settings = {
|
||||
anchor: '(tabs)',
|
||||
};
|
||||
|
||||
export default function RootLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
return (
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Stack>
|
||||
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
|
||||
</Stack>
|
||||
<StatusBar style="auto" />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
29
app/modal.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Link } from 'expo-router';
|
||||
import { StyleSheet } from 'react-native';
|
||||
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
|
||||
export default function ModalScreen() {
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedText type="title">This is a modal</ThemedText>
|
||||
<Link href="/" dismissTo style={styles.link}>
|
||||
<ThemedText type="link">Go to home screen</ThemedText>
|
||||
</Link>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
link: {
|
||||
marginTop: 15,
|
||||
paddingVertical: 15,
|
||||
},
|
||||
});
|
||||
531
app/template/[id]/run.tsx
Normal file
@@ -0,0 +1,531 @@
|
||||
import { DynamicForm } from '@/components/forms/dynamic-form';
|
||||
import { ResultDisplay } from '@/components/template-run/result-display';
|
||||
import { RunProgressView } from '@/components/template-run/run-progress';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { useTemplateFormData, useTemplateRun } from '@/hooks/use-template-run';
|
||||
import { getTemplateById } from '@/lib/api/templates';
|
||||
import { subscription } from '@/lib/auth/client';
|
||||
import { Template } from '@/lib/types/template';
|
||||
import { RunFormSchema, RunTemplateData } from '@/lib/types/template-run';
|
||||
import { transformWorkflowToFormSchema, validateFormSchema } from '@/lib/utils/form-schema-transformer';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Alert, BackHandler, Platform, StyleSheet, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
type RunStep = 'form' | 'progress' | 'result';
|
||||
|
||||
// 转换表单数据为 API 期望的格式
|
||||
function transformFormData(formData: RunTemplateData): RunTemplateData {
|
||||
const transformed: RunTemplateData = {};
|
||||
|
||||
Object.entries(formData).forEach(([fieldName, value]) => {
|
||||
// 根据字段名称判断类型并转换
|
||||
if (fieldName.startsWith('image_')) {
|
||||
// 图片字段转换为对象格式
|
||||
transformed[fieldName] = {
|
||||
url: value,
|
||||
type: 'image'
|
||||
};
|
||||
} else if (fieldName.startsWith('text_')) {
|
||||
// 文本字段转换为对象格式
|
||||
transformed[fieldName] = {
|
||||
content: value,
|
||||
type: 'text'
|
||||
};
|
||||
} else if (fieldName.startsWith('video_')) {
|
||||
// 视频字段转换为对象格式
|
||||
transformed[fieldName] = {
|
||||
url: value,
|
||||
type: 'video'
|
||||
};
|
||||
} else if (fieldName.startsWith('color_')) {
|
||||
// 颜色字段转换为对象格式
|
||||
transformed[fieldName] = {
|
||||
value: value,
|
||||
type: 'color'
|
||||
};
|
||||
} else {
|
||||
// 其他类型保持原样
|
||||
transformed[fieldName] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return transformed;
|
||||
}
|
||||
|
||||
export default function TemplateRunScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const [template, setTemplate] = useState<Template | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentStep, setCurrentStep] = useState<RunStep>('form');
|
||||
const [formSchema, setFormSchema] = useState<RunFormSchema>({ fields: [] });
|
||||
|
||||
const {
|
||||
state: runState,
|
||||
progress,
|
||||
result,
|
||||
error,
|
||||
isLoading,
|
||||
isCompleted,
|
||||
isError,
|
||||
executeTemplate,
|
||||
cancelRun,
|
||||
reset,
|
||||
cleanup,
|
||||
} = useTemplateRun({
|
||||
onSuccess: (result) => {
|
||||
setCurrentStep('result');
|
||||
},
|
||||
onError: (error) => {
|
||||
Alert.alert('运行失败', error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
formData,
|
||||
errors,
|
||||
updateField,
|
||||
validateForm,
|
||||
isValid,
|
||||
} = useTemplateFormData();
|
||||
|
||||
// 获取模板信息
|
||||
useEffect(() => {
|
||||
const loadTemplate = async () => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await getTemplateById(id);
|
||||
if (response.success && response.data) {
|
||||
setTemplate(response.data);
|
||||
|
||||
// 解析表单配置
|
||||
if (response.data.formSchema) {
|
||||
try {
|
||||
console.log('原始formSchema数据:', response.data.formSchema);
|
||||
|
||||
// 解析formSchema数据(可能是字符串或对象)
|
||||
const rawData = typeof response.data.formSchema === 'string'
|
||||
? JSON.parse(response.data.formSchema)
|
||||
: response.data.formSchema;
|
||||
|
||||
// 使用转换工具将工作流数据转换为表单配置
|
||||
const formConfig = transformWorkflowToFormSchema(rawData);
|
||||
|
||||
// 验证转换结果
|
||||
if (validateFormSchema(formConfig)) {
|
||||
console.log('转换后的表单配置:', formConfig);
|
||||
setFormSchema(formConfig);
|
||||
} else {
|
||||
console.warn('表单配置验证失败,使用空配置');
|
||||
setFormSchema({ fields: [] });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('解析表单配置失败:', error);
|
||||
console.log('使用空表单配置作为后备方案');
|
||||
setFormSchema({ fields: [] });
|
||||
}
|
||||
} else {
|
||||
console.log('模板没有formSchema,使用空配置');
|
||||
setFormSchema({ fields: [] });
|
||||
}
|
||||
} else {
|
||||
throw new Error('模板不存在');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载模板失败:', error);
|
||||
Alert.alert('错误', '无法加载模板,请稍后重试');
|
||||
router.back();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadTemplate();
|
||||
}, [id, router]);
|
||||
|
||||
// 处理返回键
|
||||
useEffect(() => {
|
||||
const handleBackPress = () => {
|
||||
if (currentStep === 'progress' && isLoading) {
|
||||
Alert.alert(
|
||||
'确认退出',
|
||||
'任务正在运行中,确定要退出吗?',
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '退出',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
cancelRun();
|
||||
router.back();
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const subscription = BackHandler.addEventListener('hardwareBackPress', handleBackPress);
|
||||
return () => subscription.remove();
|
||||
}, [currentStep, isLoading, cancelRun, router]);
|
||||
|
||||
// 组件卸载时清理
|
||||
useEffect(() => {
|
||||
return cleanup;
|
||||
}, [cleanup]);
|
||||
|
||||
// 验证表单数据
|
||||
const validateFormData = useCallback((): { isValid: boolean; message?: string } => {
|
||||
if (!template) {
|
||||
return { isValid: false, message: '模板信息未加载完成' };
|
||||
}
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return { isValid: false, message: `请修正 ${Object.keys(errors).length} 个错误后再提交` };
|
||||
}
|
||||
|
||||
if (formSchema.fields?.length > 0) {
|
||||
const requiredFields = formSchema.fields.filter(field => field.required);
|
||||
const missingFields = requiredFields.filter(field => {
|
||||
const value = formData[field.name];
|
||||
return value === undefined || value === null || value === '';
|
||||
});
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
const fieldNames = missingFields.map(f => f.label || f.name).join('、');
|
||||
return { isValid: false, message: `请填写必填项:${fieldNames}` };
|
||||
}
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}, [template, errors, formSchema.fields, formData]);
|
||||
|
||||
// 确认对话框
|
||||
const showConfirmDialog = useCallback((onConfirm: () => void) => {
|
||||
if (Platform.OS === 'web') {
|
||||
const confirmed = window.confirm('确定要开始生成任务吗?');
|
||||
if (confirmed) onConfirm();
|
||||
} else {
|
||||
Alert.alert(
|
||||
'确认提交',
|
||||
'确定要开始生成任务吗?',
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{ text: '确定', onPress: onConfirm }
|
||||
]
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = useCallback(() => {
|
||||
const validation = validateFormData();
|
||||
|
||||
if (!validation.isValid) {
|
||||
Alert.alert('表单错误', validation.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const handleConfirm = async () => {
|
||||
// 转换表单数据为 API 期望的格式
|
||||
const transformedData = transformFormData(formData);
|
||||
console.log('转换后的数据:', transformedData);
|
||||
|
||||
const identify = await subscription.meterEvent({
|
||||
meter_id: `mtr_test_61TR3BkyDcen9OrBm412fzUa9k2QB732`,
|
||||
event_name: `token_usage`,
|
||||
payload: {
|
||||
value: `100`
|
||||
}
|
||||
})
|
||||
console.log({ identify })
|
||||
// error
|
||||
const orid = identify.data?.identifier
|
||||
console.log({ orid })
|
||||
if (orid) {
|
||||
try {
|
||||
setCurrentStep('progress');
|
||||
executeTemplate(template!.id, transformedData);
|
||||
} catch (e) {
|
||||
// 退款orid
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
showConfirmDialog(handleConfirm);
|
||||
}, [validateFormData, showConfirmDialog, executeTemplate, template, formData]);
|
||||
|
||||
// 重新运行
|
||||
const handleRerun = useCallback(() => {
|
||||
setCurrentStep('form');
|
||||
reset();
|
||||
}, [reset]);
|
||||
|
||||
// 渲染表单步骤
|
||||
const renderFormStep = () => (
|
||||
<View style={styles.container}>
|
||||
{/* 模板信息头部 */}
|
||||
{template && (
|
||||
<View style={styles.templateHeader}>
|
||||
<LinearGradient
|
||||
colors={['#4ECDC4', '#44A3A0']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.headerGradient}
|
||||
>
|
||||
<View style={styles.templateInfo}>
|
||||
<Image
|
||||
source={{ uri: template.coverImageUrl }}
|
||||
style={styles.templateImage}
|
||||
contentFit="cover"
|
||||
/>
|
||||
<View style={styles.templateDetails}>
|
||||
<ThemedText style={styles.templateTitle}>
|
||||
{template.title}
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.templateDescription} numberOfLines={2}>
|
||||
{template.description}
|
||||
</ThemedText>
|
||||
{template.category && (
|
||||
<View style={styles.categoryBadge}>
|
||||
<ThemedText style={styles.categoryText}>
|
||||
{template.category.name}
|
||||
</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 表单内容 */}
|
||||
<View style={styles.formContainer}>
|
||||
<ThemedText style={styles.formTitle}>配置参数</ThemedText>
|
||||
{formSchema.fields && formSchema.fields.length > 0 ? (
|
||||
<DynamicForm
|
||||
schema={formSchema}
|
||||
onDataChange={(data, valid) => {
|
||||
// 实时更新表单数据到外部状态
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
updateField(key, value);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.noConfigContainer}>
|
||||
<ThemedText style={styles.noConfigText}>
|
||||
此模板无需配置,直接使用默认参数
|
||||
</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 提交按钮 */}
|
||||
<View style={styles.submitContainer}>
|
||||
<TouchableOpacity style={styles.submitButton} onPress={handleSubmit}>
|
||||
<ThemedText style={styles.submitButtonText}>
|
||||
{isLoading ? '处理中...' : '开始生成'}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
// 渲染进度步骤
|
||||
const renderProgressStep = () => (
|
||||
<View style={styles.container}>
|
||||
<RunProgressView
|
||||
progress={progress}
|
||||
onCancel={() => {
|
||||
Alert.alert(
|
||||
'确认取消',
|
||||
'确定要取消当前任务吗?',
|
||||
[
|
||||
{ text: '继续执行', style: 'cancel' },
|
||||
{
|
||||
text: '取消任务',
|
||||
style: 'destructive',
|
||||
onPress: cancelRun,
|
||||
},
|
||||
]
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
// 渲染结果步骤
|
||||
const renderResultStep = () => (
|
||||
<View style={styles.container}>
|
||||
{result && (
|
||||
<ResultDisplay
|
||||
result={result}
|
||||
onShare={(url) => {
|
||||
// 可以添加自定义分享逻辑
|
||||
}}
|
||||
onDownload={(url) => {
|
||||
// 可以添加自定义下载逻辑
|
||||
}}
|
||||
onRerun={handleRerun}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
// 加载状态
|
||||
if (loading) {
|
||||
return (
|
||||
<ThemedView style={styles.loadingContainer}>
|
||||
<ThemedText>加载中...</ThemedText>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
// 错误状态
|
||||
if (error && !template) {
|
||||
return (
|
||||
<ThemedView style={styles.errorContainer}>
|
||||
<ThemedText style={styles.errorText}>
|
||||
{error.message}
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
// 渲染当前步骤
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: currentStep === 'form' ? '配置模板' :
|
||||
currentStep === 'progress' ? '生成中' : '生成结果',
|
||||
headerBackVisible: currentStep !== 'progress' || !isLoading,
|
||||
}}
|
||||
/>
|
||||
|
||||
{currentStep === 'form' && renderFormStep()}
|
||||
{currentStep === 'progress' && renderProgressStep()}
|
||||
{currentStep === 'result' && renderResultStep()}
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
opacity: 0.7,
|
||||
},
|
||||
templateHeader: {
|
||||
elevation: 4,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
},
|
||||
headerGradient: {
|
||||
paddingTop: 20,
|
||||
paddingBottom: 24,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
templateInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
templateImage: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 12,
|
||||
marginRight: 16,
|
||||
},
|
||||
templateDetails: {
|
||||
flex: 1,
|
||||
},
|
||||
templateTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#fff',
|
||||
marginBottom: 4,
|
||||
},
|
||||
templateDescription: {
|
||||
fontSize: 14,
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
marginBottom: 8,
|
||||
},
|
||||
categoryBadge: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 4,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
categoryText: {
|
||||
fontSize: 12,
|
||||
color: '#fff',
|
||||
fontWeight: '500',
|
||||
},
|
||||
formContainer: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
},
|
||||
formTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
marginBottom: 16,
|
||||
},
|
||||
noConfigContainer: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.05)',
|
||||
borderRadius: 12,
|
||||
padding: 24,
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
noConfigText: {
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
opacity: 0.7,
|
||||
},
|
||||
submitContainer: {
|
||||
marginTop: 'auto',
|
||||
paddingTop: 20,
|
||||
},
|
||||
submitButton: {
|
||||
backgroundColor: '#4ECDC4',
|
||||
borderRadius: 12,
|
||||
paddingVertical: 16,
|
||||
alignItems: 'center',
|
||||
shadowColor: '#4ECDC4',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
},
|
||||
submitButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#fff',
|
||||
},
|
||||
});
|
||||
BIN
assets/images/android-icon-background.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/images/android-icon-foreground.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
assets/images/android-icon-monochrome.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
assets/images/favicon.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
assets/images/icon.png
Normal file
|
After Width: | Height: | Size: 384 KiB |
BIN
assets/images/partial-react-logo.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
assets/images/react-logo.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
assets/images/react-logo@2x.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/images/react-logo@3x.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
assets/images/splash-icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
78
components/auth/auth-button.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { TouchableOpacity, StyleSheet, Alert, ActivityIndicator } from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import { ThemedText } from "@/components/themed-text";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
import { useState } from "react";
|
||||
|
||||
export function AuthButton() {
|
||||
const { isAuthenticated, user, isLoading: sessionLoading } = useAuth();
|
||||
const [isSigningOut, setIsSigningOut] = useState(false);
|
||||
|
||||
const handleSignOut = async () => {
|
||||
setIsSigningOut(true);
|
||||
try {
|
||||
await authClient.signOut();
|
||||
router.replace("/(auth)/login");
|
||||
} catch (error: any) {
|
||||
Alert.alert("登出失败", error?.message || "请稍后重试");
|
||||
} finally {
|
||||
setIsSigningOut(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignIn = () => {
|
||||
router.push("/(auth)/login");
|
||||
};
|
||||
|
||||
if (sessionLoading) {
|
||||
return (
|
||||
<TouchableOpacity style={styles.button} disabled>
|
||||
<ActivityIndicator size="small" color="#007AFF" />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={handleSignOut}
|
||||
disabled={isSigningOut}
|
||||
>
|
||||
{isSigningOut ? (
|
||||
<ActivityIndicator size="small" color="#FF3B30" />
|
||||
) : (
|
||||
<ThemedText style={styles.signOutText}>
|
||||
{user?.username ? `登出 (${user.username})` : "登出"}
|
||||
</ThemedText>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity style={styles.button} onPress={handleSignIn}>
|
||||
<ThemedText style={styles.signInText}>登录</ThemedText>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 8,
|
||||
minWidth: 80,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
signInText: {
|
||||
color: "#007AFF",
|
||||
fontWeight: "600",
|
||||
},
|
||||
signOutText: {
|
||||
color: "#FF3B30",
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
194
components/auth/cookie-test-button.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Cookie编码测试组件
|
||||
* 在开发环境中测试Cookie编码功能
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { TouchableOpacity, StyleSheet, Alert, ScrollView, Text, View } from 'react-native';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { runCookieEncodingTests, testRealWorldScenarios } from '@/utils/cookie-encoding-test';
|
||||
|
||||
interface CookieTestButtonProps {
|
||||
style?: any;
|
||||
}
|
||||
|
||||
export function CookieTestButton({ style }: CookieTestButtonProps) {
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [testResults, setTestResults] = useState<string[]>([]);
|
||||
|
||||
const runTests = async () => {
|
||||
setIsRunning(true);
|
||||
setTestResults([]);
|
||||
|
||||
try {
|
||||
// 捕获console.log输出
|
||||
const originalLog = console.log;
|
||||
const originalWarn = console.warn;
|
||||
const originalError = console.error;
|
||||
|
||||
const logs: string[] = [];
|
||||
|
||||
console.log = (...args: any[]) => {
|
||||
const message = args.map(arg =>
|
||||
typeof arg === 'object' ? JSON.stringify(arg) : String(arg)
|
||||
).join(' ');
|
||||
logs.push(message);
|
||||
originalLog(...args);
|
||||
};
|
||||
|
||||
console.warn = (...args: any[]) => {
|
||||
const message = args.map(arg =>
|
||||
typeof arg === 'object' ? JSON.stringify(arg) : String(arg)
|
||||
).join(' ');
|
||||
logs.push(`⚠️ ${message}`);
|
||||
originalWarn(...args);
|
||||
};
|
||||
|
||||
console.error = (...args: any[]) => {
|
||||
const message = args.map(arg =>
|
||||
typeof arg === 'object' ? JSON.stringify(arg) : String(arg)
|
||||
).join(' ');
|
||||
logs.push(`❌ ${message}`);
|
||||
originalError(...args);
|
||||
};
|
||||
|
||||
// 运行测试
|
||||
runCookieEncodingTests();
|
||||
testRealWorldScenarios();
|
||||
|
||||
// 恢复原始console函数
|
||||
console.log = originalLog;
|
||||
console.warn = originalWarn;
|
||||
console.error = originalError;
|
||||
|
||||
// 显示测试结果
|
||||
setTestResults(logs);
|
||||
|
||||
Alert.alert(
|
||||
'测试完成',
|
||||
'Cookie编码测试已完成,请查看控制台输出或点击"查看结果"查看详细信息。',
|
||||
[
|
||||
{ text: '确定', style: 'default' },
|
||||
{ text: '查看结果', style: 'default', onPress: () => showDetailedResults() }
|
||||
]
|
||||
);
|
||||
|
||||
} catch (error: any) {
|
||||
Alert.alert('测试失败', error.message || '测试过程中发生错误');
|
||||
setTestResults([`测试失败: ${error.message || error}`]);
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showDetailedResults = () => {
|
||||
Alert.alert(
|
||||
'测试结果详情',
|
||||
testResults.slice(0, 10).join('\n') +
|
||||
(testResults.length > 10 ? `\n\n... 还有 ${testResults.length - 10} 条日志` : ''),
|
||||
[{ text: '确定', style: 'default' }]
|
||||
);
|
||||
};
|
||||
|
||||
const clearResults = () => {
|
||||
setTestResults([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.testButton]}
|
||||
onPress={runTests}
|
||||
disabled={isRunning}
|
||||
>
|
||||
<ThemedText style={styles.testButtonText}>
|
||||
{isRunning ? '🧪 测试中...' : '🧪 测试Cookie编码'}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
{testResults.length > 0 && (
|
||||
<View style={styles.resultsContainer}>
|
||||
<View style={styles.resultsHeader}>
|
||||
<ThemedText style={styles.resultsTitle}>测试结果</ThemedText>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.clearButton]}
|
||||
onPress={clearResults}
|
||||
>
|
||||
<ThemedText style={styles.clearButtonText}>清除</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.resultsScroll} nestedScrollEnabled={true}>
|
||||
{testResults.map((result, index) => (
|
||||
<Text key={index} style={styles.resultText}>
|
||||
{result}
|
||||
</Text>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: 16,
|
||||
backgroundColor: 'rgba(0, 122, 255, 0.05)',
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(0, 122, 255, 0.2)',
|
||||
},
|
||||
button: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
testButton: {
|
||||
backgroundColor: '#007AFF',
|
||||
marginBottom: 12,
|
||||
},
|
||||
testButtonText: {
|
||||
color: 'white',
|
||||
fontWeight: '600',
|
||||
fontSize: 16,
|
||||
},
|
||||
clearButton: {
|
||||
backgroundColor: '#FF3B30',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
clearButtonText: {
|
||||
color: 'white',
|
||||
fontWeight: '600',
|
||||
fontSize: 14,
|
||||
},
|
||||
resultsContainer: {
|
||||
maxHeight: 300,
|
||||
},
|
||||
resultsHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
resultsTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#007AFF',
|
||||
},
|
||||
resultsScroll: {
|
||||
maxHeight: 250,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.05)',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
},
|
||||
resultText: {
|
||||
fontSize: 12,
|
||||
color: '#333',
|
||||
marginBottom: 4,
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
});
|
||||
119
components/auth/debug-session.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { ThemedText } from "@/components/themed-text";
|
||||
import { ThemedView } from "@/components/themed-view";
|
||||
import { storage } from "@/lib/storage";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||
|
||||
export function DebugSession() {
|
||||
const [sessionToken, setSessionToken] = useState<string | null>(null);
|
||||
|
||||
const loadSessionToken = async () => {
|
||||
try {
|
||||
const token = storage.getItem("bestaibest.better-auth.session_token");
|
||||
setSessionToken(token);
|
||||
} catch (error) {
|
||||
console.error("Error loading session token:", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadSessionToken();
|
||||
}, []);
|
||||
|
||||
const handleRefresh = () => {
|
||||
loadSessionToken();
|
||||
};
|
||||
|
||||
const handleClear = async () => {
|
||||
Alert.alert("清除会话", "确定要清除 SecureStore 中的会话数据吗?", [
|
||||
{ text: "取消", style: "cancel" },
|
||||
{
|
||||
text: "确定",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
console.log("[DebugSession] Clearing session token...");
|
||||
storage.removeItem("bestaibest.better-auth.session_token");
|
||||
|
||||
setTimeout(() => {
|
||||
console.log("[DebugSession] Reloading token to verify...");
|
||||
loadSessionToken();
|
||||
}, 100);
|
||||
|
||||
Alert.alert("成功", "会话已清除");
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedText type="subtitle" style={styles.title}>
|
||||
调试信息
|
||||
</ThemedText>
|
||||
|
||||
<View style={styles.infoRow}>
|
||||
<ThemedText style={styles.label}>Session Token:</ThemedText>
|
||||
<ThemedText style={styles.value} numberOfLines={1}>
|
||||
{sessionToken ? `${sessionToken.slice(0, 20)}...` : "无"}
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<TouchableOpacity style={styles.button} onPress={handleRefresh}>
|
||||
<ThemedText style={styles.buttonText}>刷新</ThemedText>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.dangerButton]}
|
||||
onPress={handleClear}
|
||||
>
|
||||
<ThemedText style={styles.buttonText}>清除会话</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: "#ddd",
|
||||
marginVertical: 16,
|
||||
},
|
||||
title: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
infoRow: {
|
||||
marginBottom: 8,
|
||||
},
|
||||
label: {
|
||||
fontWeight: "600",
|
||||
marginBottom: 4,
|
||||
},
|
||||
value: {
|
||||
fontFamily: "monospace",
|
||||
fontSize: 12,
|
||||
opacity: 0.7,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
gap: 8,
|
||||
marginTop: 12,
|
||||
},
|
||||
button: {
|
||||
flex: 1,
|
||||
backgroundColor: "#007AFF",
|
||||
padding: 12,
|
||||
borderRadius: 6,
|
||||
alignItems: "center",
|
||||
},
|
||||
dangerButton: {
|
||||
backgroundColor: "#FF3B30",
|
||||
},
|
||||
buttonText: {
|
||||
color: "#fff",
|
||||
fontWeight: "600",
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
25
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,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
405
components/forms/dynamic-form.tsx
Normal file
@@ -0,0 +1,405 @@
|
||||
import { View, StyleSheet, ScrollView, ActivityIndicator } from 'react-native';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { RunFormSchema, RunTemplateData, FormFieldSchema } from '@/lib/types/template-run';
|
||||
import { TextInputField } from './form-fields/text-input';
|
||||
import { NumberInputField } from './form-fields/number-input';
|
||||
import { SelectInputField } from './form-fields/select-input';
|
||||
import { ImageUploadField } from './form-fields/image-upload';
|
||||
import { ColorInputField } from './form-fields/color-input';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { validateFormSchema } from '@/lib/utils/form-schema-transformer';
|
||||
|
||||
interface DynamicFormProps {
|
||||
schema: RunFormSchema;
|
||||
initialData?: RunTemplateData;
|
||||
onDataChange?: (data: RunTemplateData, isValid: boolean) => void;
|
||||
onSubmit?: () => void;
|
||||
}
|
||||
|
||||
export function DynamicForm({
|
||||
schema,
|
||||
initialData = {},
|
||||
onDataChange,
|
||||
onSubmit
|
||||
}: DynamicFormProps) {
|
||||
const [formData, setFormData] = useState<RunTemplateData>(initialData);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [schemaError, setSchemaError] = useState<string | null>(null);
|
||||
|
||||
// 验证schema并初始化默认值
|
||||
useEffect(() => {
|
||||
const validateAndInitialize = () => {
|
||||
setIsValidating(true);
|
||||
setSchemaError(null);
|
||||
|
||||
try {
|
||||
// 验证schema结构
|
||||
if (!validateFormSchema(schema)) {
|
||||
setSchemaError('表单配置格式不正确');
|
||||
return;
|
||||
}
|
||||
|
||||
// 初始化默认值
|
||||
const defaultData: RunTemplateData = {};
|
||||
schema.fields.forEach(field => {
|
||||
if (field.defaultValue !== undefined) {
|
||||
defaultData[field.name] = field.defaultValue;
|
||||
}
|
||||
});
|
||||
const finalInitialData = { ...defaultData, ...initialData };
|
||||
setFormData(finalInitialData);
|
||||
|
||||
// 初始化时通知父组件
|
||||
if (onDataChange) {
|
||||
onDataChange(finalInitialData, true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('表单初始化失败:', error);
|
||||
setSchemaError('表单初始化失败');
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
validateAndInitialize();
|
||||
}, [JSON.stringify(schema), JSON.stringify(initialData)]);
|
||||
|
||||
// 验证单个字段
|
||||
const validateField = (field: FormFieldSchema, value: any): string | null => {
|
||||
// 检查必填
|
||||
if (field.required && (value === undefined || value === null || value === '')) {
|
||||
return `${field.label} 是必填项`;
|
||||
}
|
||||
|
||||
// 如果值为空且不是必填,则跳过其他验证
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 根据类型验证
|
||||
switch (field.type) {
|
||||
case 'text':
|
||||
case 'textarea':
|
||||
if (typeof value !== 'string') {
|
||||
return `${field.label} 必须是文本`;
|
||||
}
|
||||
if (field.min && value.length < field.min) {
|
||||
return `${field.label} 至少需要 ${field.min} 个字符`;
|
||||
}
|
||||
if (field.max && value.length > field.max) {
|
||||
return `${field.label} 最多 ${field.max} 个字符`;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'number':
|
||||
const numValue = Number(value);
|
||||
if (isNaN(numValue)) {
|
||||
return `${field.label} 必须是数字`;
|
||||
}
|
||||
if (field.min !== undefined && numValue < field.min) {
|
||||
return `${field.label} 不能小于 ${field.min}`;
|
||||
}
|
||||
if (field.max !== undefined && numValue > field.max) {
|
||||
return `${field.label} 不能大于 ${field.max}`;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'select':
|
||||
const isValidOption = field.options?.some(option => option.value === value);
|
||||
if (!isValidOption) {
|
||||
return `${field.label} 选择了无效的选项`;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'image':
|
||||
if (typeof value !== 'string') {
|
||||
return `${field.label} 必须是图片URL`;
|
||||
}
|
||||
if (value && !value.startsWith('http') && !value.startsWith('file://')) {
|
||||
return `${field.label} 必须是有效的图片地址`;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'color':
|
||||
if (typeof value !== 'string') {
|
||||
return `${field.label} 必须是颜色值`;
|
||||
}
|
||||
if (value && !/^#[0-9A-F]{6}$/i.test(value)) {
|
||||
return `${field.label} 必须是有效的颜色值 (如 #FF0000)`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// 验证整个表单
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
let isValid = true;
|
||||
|
||||
schema.fields.forEach(field => {
|
||||
const error = validateField(field, formData[field.name]);
|
||||
if (error) {
|
||||
newErrors[field.name] = error;
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
setErrors(newErrors);
|
||||
return isValid;
|
||||
};
|
||||
|
||||
// 更新表单数据
|
||||
const updateField = (fieldName: string, value: any) => {
|
||||
const newFormData = { ...formData, [fieldName]: value };
|
||||
setFormData(newFormData);
|
||||
|
||||
// 实时验证字段
|
||||
const field = schema.fields.find(f => f.name === fieldName);
|
||||
if (field) {
|
||||
const error = validateField(field, value);
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
[fieldName]: error || ''
|
||||
}));
|
||||
}
|
||||
|
||||
// 通知父组件数据变化
|
||||
if (onDataChange) {
|
||||
// 计算整个表单的验证状态
|
||||
const allErrors = { ...errors };
|
||||
if (field) {
|
||||
const fieldError = validateField(field, value);
|
||||
if (fieldError) {
|
||||
allErrors[fieldName] = fieldError;
|
||||
} else {
|
||||
delete allErrors[fieldName];
|
||||
}
|
||||
}
|
||||
|
||||
const isValid = Object.keys(allErrors).length === 0;
|
||||
onDataChange(newFormData, isValid);
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染表单字段
|
||||
const renderField = (field: FormFieldSchema) => {
|
||||
const commonProps = {
|
||||
field,
|
||||
value: formData[field.name],
|
||||
onChange: (value: any) => updateField(field.name, value),
|
||||
error: errors[field.name],
|
||||
};
|
||||
|
||||
switch (field.type) {
|
||||
case 'text':
|
||||
case 'textarea':
|
||||
return <TextInputField key={field.name} {...commonProps} />;
|
||||
|
||||
case 'number':
|
||||
return <NumberInputField key={field.name} {...commonProps} />;
|
||||
|
||||
case 'select':
|
||||
return <SelectInputField key={field.name} {...commonProps} />;
|
||||
|
||||
case 'image':
|
||||
return <ImageUploadField key={field.name} {...commonProps} />;
|
||||
|
||||
case 'color':
|
||||
return <ColorInputField key={field.name} {...commonProps} />;
|
||||
|
||||
default:
|
||||
console.warn(`不支持的表单字段类型: ${field.type}`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 显示加载状态
|
||||
if (isValidating) {
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#4ECDC4" />
|
||||
<ThemedText style={styles.loadingText}>正在加载表单...</ThemedText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 显示schema错误
|
||||
if (schemaError) {
|
||||
return (
|
||||
<View style={styles.errorContainer}>
|
||||
<ThemedText style={styles.errorTitle}>表单加载失败</ThemedText>
|
||||
<ThemedText style={styles.errorText}>{schemaError}</ThemedText>
|
||||
<ThemedText style={styles.errorHint}>
|
||||
请稍后重试,或联系技术支持。
|
||||
</ThemedText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container} showsVerticalScrollIndicator={false}>
|
||||
<ThemedView style={styles.content}>
|
||||
{schema.fields.length === 0 ? (
|
||||
<View style={styles.emptyContainer}>
|
||||
<ThemedText style={styles.emptyIcon}>📋</ThemedText>
|
||||
<ThemedText style={styles.emptyText}>无需配置参数</ThemedText>
|
||||
<ThemedText style={styles.emptyDescription}>
|
||||
此模板使用默认参数,可以直接开始生成
|
||||
</ThemedText>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<ThemedView style={styles.formHeader}>
|
||||
<ThemedText style={styles.formTitle}>请填写以下信息</ThemedText>
|
||||
<ThemedText style={styles.formDescription}>
|
||||
完善配置信息以获得最佳的生成效果
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
|
||||
{schema.fields.map(field => renderField(field))}
|
||||
|
||||
{Object.keys(errors).length > 0 && (
|
||||
<ThemedView style={styles.errorSummary}>
|
||||
<ThemedText style={styles.errorSummaryText}>
|
||||
⚠️ 请修正 {Object.keys(errors).length} 个错误后再提交
|
||||
</ThemedText>
|
||||
{Object.entries(errors).map(([fieldName, error]) => (
|
||||
<ThemedText key={fieldName} style={styles.errorItem}>
|
||||
• {error}
|
||||
</ThemedText>
|
||||
))}
|
||||
</ThemedView>
|
||||
)}
|
||||
|
||||
{Object.keys(errors).length === 0 && formData && Object.keys(formData).length > 0 && (
|
||||
<ThemedView style={styles.successSummary}>
|
||||
<ThemedText style={styles.successText}>
|
||||
✓ 配置信息完整,可以开始生成
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ThemedView>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
},
|
||||
// 加载状态样式
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 60,
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 16,
|
||||
marginTop: 16,
|
||||
opacity: 0.7,
|
||||
},
|
||||
// 错误状态样式
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 24,
|
||||
},
|
||||
errorTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#FF3B30',
|
||||
marginBottom: 8,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
opacity: 0.8,
|
||||
marginBottom: 12,
|
||||
},
|
||||
errorHint: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
opacity: 0.6,
|
||||
},
|
||||
// 空状态样式
|
||||
emptyContainer: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 40,
|
||||
},
|
||||
emptyIcon: {
|
||||
fontSize: 48,
|
||||
marginBottom: 16,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '500',
|
||||
marginBottom: 8,
|
||||
opacity: 0.8,
|
||||
},
|
||||
emptyDescription: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
opacity: 0.6,
|
||||
lineHeight: 20,
|
||||
},
|
||||
// 表单头部样式
|
||||
formHeader: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
formTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginBottom: 4,
|
||||
},
|
||||
formDescription: {
|
||||
fontSize: 14,
|
||||
opacity: 0.7,
|
||||
lineHeight: 20,
|
||||
},
|
||||
// 错误总结样式
|
||||
errorSummary: {
|
||||
backgroundColor: 'rgba(255, 59, 48, 0.1)',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginTop: 20,
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: '#FF3B30',
|
||||
},
|
||||
errorSummaryText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#FF3B30',
|
||||
marginBottom: 8,
|
||||
},
|
||||
errorItem: {
|
||||
fontSize: 14,
|
||||
color: '#FF3B30',
|
||||
marginBottom: 4,
|
||||
opacity: 0.9,
|
||||
},
|
||||
// 成功状态样式
|
||||
successSummary: {
|
||||
backgroundColor: 'rgba(52, 199, 89, 0.1)',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginTop: 20,
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: '#34C759',
|
||||
},
|
||||
successText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#34C759',
|
||||
},
|
||||
});
|
||||
189
components/forms/form-fields/color-input.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { View, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
import { FormFieldSchema } from '@/lib/types/template-run';
|
||||
|
||||
interface ColorInputFieldProps {
|
||||
field: FormFieldSchema;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_COLORS = [
|
||||
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7',
|
||||
'#DDA0DD', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E2',
|
||||
'#F8B739', '#52B788', '#F72585', '#7209B7', '#3A0CA3',
|
||||
'#000000', '#FFFFFF', '#808080', '#FF0000', '#00FF00'
|
||||
];
|
||||
|
||||
export function ColorInputField({ field, value, onChange, error }: ColorInputFieldProps) {
|
||||
const borderColor = useThemeColor({}, 'border');
|
||||
const errorColor = useThemeColor({}, 'error');
|
||||
const backgroundColor = useThemeColor({}, 'background');
|
||||
|
||||
const handleColorSelect = (color: string) => {
|
||||
onChange(color);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{field.label && (
|
||||
<ThemedText style={styles.label}>
|
||||
{field.label}
|
||||
{field.required && <ThemedText style={[styles.required, { color: errorColor }]}> *</ThemedText>}
|
||||
</ThemedText>
|
||||
)}
|
||||
|
||||
{/* 当前选中的颜色 */}
|
||||
<View style={styles.currentColorContainer}>
|
||||
<ThemedText style={styles.currentColorLabel}>当前颜色:</ThemedText>
|
||||
<View style={styles.currentColorRow}>
|
||||
<View
|
||||
style={[
|
||||
styles.currentColorBox,
|
||||
{ backgroundColor: value || '#000000' }
|
||||
]}
|
||||
/>
|
||||
<ThemedText style={styles.currentColorValue}>
|
||||
{value || '#000000'}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 颜色选择器 */}
|
||||
<View style={[
|
||||
styles.colorPicker,
|
||||
{
|
||||
borderColor: error ? errorColor : borderColor,
|
||||
backgroundColor,
|
||||
}
|
||||
]}>
|
||||
<View style={styles.colorGrid}>
|
||||
{DEFAULT_COLORS.map((color, index) => (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
style={[
|
||||
styles.colorOption,
|
||||
{
|
||||
backgroundColor: color,
|
||||
borderWidth: value === color ? 3 : 1,
|
||||
borderColor: value === color ? '#007AFF' : borderColor,
|
||||
}
|
||||
]}
|
||||
onPress={() => handleColorSelect(color)}
|
||||
activeOpacity={0.8}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 自定义颜色输入 */}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.customColorButton,
|
||||
{
|
||||
borderColor: error ? errorColor : borderColor,
|
||||
backgroundColor,
|
||||
}
|
||||
]}
|
||||
onPress={() => {
|
||||
// 这里可以集成更高级的颜色选择器
|
||||
// 目前使用简单的颜色切换
|
||||
const randomColor = '#' + Math.floor(Math.random()*16777215).toString(16);
|
||||
handleColorSelect(randomColor);
|
||||
}}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<ThemedText style={styles.customColorText}>
|
||||
随机颜色
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
{field.description && (
|
||||
<ThemedText style={styles.description}>{field.description}</ThemedText>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<ThemedText style={[styles.errorText, { color: errorColor }]}>
|
||||
{error}
|
||||
</ThemedText>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
},
|
||||
required: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
currentColorContainer: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
currentColorLabel: {
|
||||
fontSize: 14,
|
||||
marginBottom: 4,
|
||||
},
|
||||
currentColorRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
currentColorBox: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#ddd',
|
||||
},
|
||||
currentColorValue: {
|
||||
fontSize: 14,
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
colorPicker: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginBottom: 8,
|
||||
},
|
||||
colorGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
},
|
||||
colorOption: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
marginBottom: 4,
|
||||
},
|
||||
customColorButton: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 12,
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
customColorText: {
|
||||
fontSize: 14,
|
||||
},
|
||||
description: {
|
||||
fontSize: 12,
|
||||
opacity: 0.7,
|
||||
marginTop: 4,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
229
components/forms/form-fields/image-upload.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import { View, StyleSheet, TouchableOpacity, Alert } from 'react-native';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
import { FormFieldSchema } from '@/lib/types/template-run';
|
||||
import { Image } from 'expo-image';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface ImageUploadFieldProps {
|
||||
field: FormFieldSchema;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function ImageUploadField({ field, value, onChange, error }: ImageUploadFieldProps) {
|
||||
const borderColor = useThemeColor({}, 'border');
|
||||
const errorColor = useThemeColor({}, 'error');
|
||||
const backgroundColor = useThemeColor({}, 'background');
|
||||
const placeholderColor = useThemeColor({}, 'textPlaceholder');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const pickImage = async () => {
|
||||
try {
|
||||
setUploading(true);
|
||||
|
||||
// 请求权限
|
||||
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (!permissionResult.granted) {
|
||||
Alert.alert('权限请求', '需要相册权限才能选择图片');
|
||||
return;
|
||||
}
|
||||
|
||||
// 选择图片
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||
allowsEditing: true,
|
||||
aspect: [16, 9],
|
||||
quality: 0.8,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets[0]) {
|
||||
onChange(result.assets[0].uri);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('选择图片失败:', error);
|
||||
Alert.alert('错误', '选择图片失败,请重试');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const takePhoto = async () => {
|
||||
try {
|
||||
setUploading(true);
|
||||
|
||||
// 请求相机权限
|
||||
const permissionResult = await ImagePicker.requestCameraPermissionsAsync();
|
||||
if (!permissionResult.granted) {
|
||||
Alert.alert('权限请求', '需要相机权限才能拍照');
|
||||
return;
|
||||
}
|
||||
|
||||
// 拍照
|
||||
const result = await ImagePicker.launchCameraAsync({
|
||||
allowsEditing: true,
|
||||
aspect: [16, 9],
|
||||
quality: 0.8,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets[0]) {
|
||||
onChange(result.assets[0].uri);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('拍照失败:', error);
|
||||
Alert.alert('错误', '拍照失败,请重试');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showOptions = () => {
|
||||
Alert.alert(
|
||||
'选择图片',
|
||||
'',
|
||||
[
|
||||
{
|
||||
text: '从相册选择',
|
||||
onPress: pickImage,
|
||||
},
|
||||
{
|
||||
text: '拍照',
|
||||
onPress: takePhoto,
|
||||
},
|
||||
{
|
||||
text: '取消',
|
||||
style: 'cancel',
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const removeImage = () => {
|
||||
onChange('');
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{field.label && (
|
||||
<ThemedText style={styles.label}>
|
||||
{field.label}
|
||||
{field.required && <ThemedText style={[styles.required, { color: errorColor }]}> *</ThemedText>}
|
||||
</ThemedText>
|
||||
)}
|
||||
|
||||
{value ? (
|
||||
<View style={styles.imageContainer}>
|
||||
<Image
|
||||
source={{ uri: value }}
|
||||
style={styles.image}
|
||||
contentFit="cover"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[styles.removeButton, { backgroundColor: errorColor }]}
|
||||
onPress={removeImage}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<ThemedText style={styles.removeButtonText}>✕</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.uploadButton,
|
||||
{
|
||||
borderColor: error ? errorColor : borderColor,
|
||||
backgroundColor,
|
||||
}
|
||||
]}
|
||||
onPress={showOptions}
|
||||
disabled={uploading}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<ThemedText style={[styles.uploadIcon, { color: placeholderColor }]}>
|
||||
📷
|
||||
</ThemedText>
|
||||
<ThemedText style={[styles.uploadText, { color: placeholderColor }]}>
|
||||
{uploading ? '上传中...' : (field.placeholder || '点击上传图片')}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{field.description && (
|
||||
<ThemedText style={styles.description}>{field.description}</ThemedText>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<ThemedText style={[styles.errorText, { color: errorColor }]}>
|
||||
{error}
|
||||
</ThemedText>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
},
|
||||
required: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
imageContainer: {
|
||||
position: 'relative',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
image: {
|
||||
width: '100%',
|
||||
height: 200,
|
||||
borderRadius: 8,
|
||||
},
|
||||
removeButton: {
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
removeButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
uploadButton: {
|
||||
borderWidth: 2,
|
||||
borderStyle: 'dashed',
|
||||
borderRadius: 8,
|
||||
paddingVertical: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
uploadIcon: {
|
||||
fontSize: 24,
|
||||
marginBottom: 8,
|
||||
},
|
||||
uploadText: {
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
description: {
|
||||
fontSize: 12,
|
||||
opacity: 0.7,
|
||||
marginTop: 4,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
113
components/forms/form-fields/number-input.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { TextInput, View, StyleSheet } from 'react-native';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
import { FormFieldSchema } from '@/lib/types/template-run';
|
||||
|
||||
interface NumberInputFieldProps {
|
||||
field: FormFieldSchema;
|
||||
value: number | string;
|
||||
onChange: (value: number) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function NumberInputField({ field, value, onChange, error }: NumberInputFieldProps) {
|
||||
const textColor = useThemeColor({}, 'text');
|
||||
const borderColor = useThemeColor({}, 'border');
|
||||
const errorColor = useThemeColor({}, 'error');
|
||||
const placeholderColor = useThemeColor({}, 'textPlaceholder');
|
||||
const backgroundColor = useThemeColor({}, 'background');
|
||||
|
||||
const handleChange = (text: string) => {
|
||||
const num = parseFloat(text);
|
||||
if (!isNaN(num)) {
|
||||
onChange(num);
|
||||
} else if (text === '') {
|
||||
onChange(0);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{field.label && (
|
||||
<ThemedText style={styles.label}>
|
||||
{field.label}
|
||||
{field.required && <ThemedText style={[styles.required, { color: errorColor }]}> *</ThemedText>}
|
||||
</ThemedText>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
color: textColor,
|
||||
borderColor: error ? errorColor : borderColor,
|
||||
backgroundColor,
|
||||
}
|
||||
]}
|
||||
value={value?.toString() || ''}
|
||||
onChangeText={handleChange}
|
||||
placeholder={field.placeholder}
|
||||
placeholderTextColor={placeholderColor}
|
||||
keyboardType="numeric"
|
||||
maxLength={10}
|
||||
/>
|
||||
|
||||
{field.description && (
|
||||
<ThemedText style={styles.description}>{field.description}</ThemedText>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<ThemedText style={[styles.errorText, { color: errorColor }]}>
|
||||
{error}
|
||||
</ThemedText>
|
||||
)}
|
||||
|
||||
{(field.min !== undefined || field.max !== undefined) && (
|
||||
<ThemedText style={styles.hint}>
|
||||
{field.min !== undefined && field.max !== undefined
|
||||
? `范围: ${field.min} - ${field.max}`
|
||||
: field.min !== undefined
|
||||
? `最小值: ${field.min}`
|
||||
: `最大值: ${field.max}`}
|
||||
</ThemedText>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
},
|
||||
required: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
fontSize: 16,
|
||||
minHeight: 44,
|
||||
},
|
||||
description: {
|
||||
fontSize: 12,
|
||||
opacity: 0.7,
|
||||
marginTop: 4,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
},
|
||||
hint: {
|
||||
fontSize: 12,
|
||||
opacity: 0.6,
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
164
components/forms/form-fields/select-input.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { View, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
import { FormFieldSchema } from '@/lib/types/template-run';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface SelectInputFieldProps {
|
||||
field: FormFieldSchema;
|
||||
value: string | number;
|
||||
onChange: (value: string | number) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function SelectInputField({ field, value, onChange, error }: SelectInputFieldProps) {
|
||||
const textColor = useThemeColor({}, 'text');
|
||||
const borderColor = useThemeColor({}, 'border');
|
||||
const errorColor = useThemeColor({}, 'error');
|
||||
const backgroundColor = useThemeColor({}, 'background');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const selectedOption = field.options?.find(option => option.value === value);
|
||||
|
||||
const handleSelect = (option: any) => {
|
||||
onChange(option.value);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{field.label && (
|
||||
<ThemedText style={styles.label}>
|
||||
{field.label}
|
||||
{field.required && <ThemedText style={[styles.required, { color: errorColor }]}> *</ThemedText>}
|
||||
</ThemedText>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.selectBox,
|
||||
{
|
||||
borderColor: error ? errorColor : borderColor,
|
||||
backgroundColor,
|
||||
}
|
||||
]}
|
||||
onPress={() => setIsOpen(!isOpen)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<ThemedText style={[styles.selectedText, { color: selectedOption ? textColor : '#999' }]}>
|
||||
{selectedOption?.label || field.placeholder || '请选择...'}
|
||||
</ThemedText>
|
||||
<ThemedText style={[styles.arrow, { color: textColor }]}>
|
||||
{isOpen ? '▲' : '▼'}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
{isOpen && field.options && (
|
||||
<View style={[styles.dropdown, { backgroundColor, borderColor }]}>
|
||||
{field.options.map((option, index) => (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
style={[
|
||||
styles.option,
|
||||
{
|
||||
backgroundColor: option.value === value ? borderColor : 'transparent',
|
||||
borderBottomColor: borderColor,
|
||||
}
|
||||
]}
|
||||
onPress={() => handleSelect(option)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<ThemedText style={[
|
||||
styles.optionText,
|
||||
{
|
||||
color: option.value === value ? '#fff' : textColor,
|
||||
fontWeight: option.value === value ? '600' : '400',
|
||||
}
|
||||
]}>
|
||||
{option.label}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{field.description && (
|
||||
<ThemedText style={styles.description}>{field.description}</ThemedText>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<ThemedText style={[styles.errorText, { color: errorColor }]}>
|
||||
{error}
|
||||
</ThemedText>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 16,
|
||||
position: 'relative',
|
||||
zIndex: 10,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
},
|
||||
required: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
selectBox: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
minHeight: 44,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
selectedText: {
|
||||
fontSize: 16,
|
||||
flex: 1,
|
||||
},
|
||||
arrow: {
|
||||
fontSize: 12,
|
||||
marginLeft: 8,
|
||||
},
|
||||
dropdown: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
marginTop: 4,
|
||||
maxHeight: 200,
|
||||
elevation: 3,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 20,
|
||||
},
|
||||
option: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
optionText: {
|
||||
fontSize: 16,
|
||||
},
|
||||
description: {
|
||||
fontSize: 12,
|
||||
opacity: 0.7,
|
||||
marginTop: 4,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
90
components/forms/form-fields/text-input.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { TextInput, View, StyleSheet } from 'react-native';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
import { FormFieldSchema } from '@/lib/types/template-run';
|
||||
|
||||
interface TextInputFieldProps {
|
||||
field: FormFieldSchema;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function TextInputField({ field, value, onChange, error }: TextInputFieldProps) {
|
||||
const textColor = useThemeColor({}, 'text');
|
||||
const borderColor = useThemeColor({}, 'border');
|
||||
const errorColor = useThemeColor({}, 'error');
|
||||
const placeholderColor = useThemeColor({}, 'textPlaceholder');
|
||||
const backgroundColor = useThemeColor({}, 'background');
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{field.label && (
|
||||
<ThemedText style={styles.label}>
|
||||
{field.label}
|
||||
{field.required && <ThemedText style={[styles.required, { color: errorColor }]}> *</ThemedText>}
|
||||
</ThemedText>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
color: textColor,
|
||||
borderColor: error ? errorColor : borderColor,
|
||||
backgroundColor,
|
||||
}
|
||||
]}
|
||||
value={value || ''}
|
||||
onChangeText={onChange}
|
||||
placeholder={field.placeholder}
|
||||
placeholderTextColor={placeholderColor}
|
||||
multiline={field.type === 'textarea'}
|
||||
numberOfLines={field.type === 'textarea' ? 4 : 1}
|
||||
textAlignVertical={field.type === 'textarea' ? 'top' : 'center'}
|
||||
/>
|
||||
|
||||
{field.description && (
|
||||
<ThemedText style={styles.description}>{field.description}</ThemedText>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<ThemedText style={[styles.errorText, { color: errorColor }]}>
|
||||
{error}
|
||||
</ThemedText>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
},
|
||||
required: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
fontSize: 16,
|
||||
minHeight: 44,
|
||||
},
|
||||
description: {
|
||||
fontSize: 12,
|
||||
opacity: 0.7,
|
||||
marginTop: 4,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
18
components/haptic-tab.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
|
||||
import { PlatformPressable } from '@react-navigation/elements';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
|
||||
export function HapticTab(props: BottomTabBarButtonProps) {
|
||||
return (
|
||||
<PlatformPressable
|
||||
{...props}
|
||||
onPressIn={(ev) => {
|
||||
if (process.env.EXPO_OS === 'ios') {
|
||||
// Add a soft haptic feedback when pressing down on the tabs.
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
props.onPressIn?.(ev);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
19
components/hello-wave.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import Animated from 'react-native-reanimated';
|
||||
|
||||
export function HelloWave() {
|
||||
return (
|
||||
<Animated.Text
|
||||
style={{
|
||||
fontSize: 28,
|
||||
lineHeight: 32,
|
||||
marginTop: -6,
|
||||
animationName: {
|
||||
'50%': { transform: [{ rotate: '25deg' }] },
|
||||
},
|
||||
animationIterationCount: 4,
|
||||
animationDuration: '300ms',
|
||||
}}>
|
||||
👋
|
||||
</Animated.Text>
|
||||
);
|
||||
}
|
||||
79
components/parallax-scroll-view.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { PropsWithChildren, ReactElement } from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import Animated, {
|
||||
interpolate,
|
||||
useAnimatedRef,
|
||||
useAnimatedStyle,
|
||||
useScrollOffset,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
|
||||
const HEADER_HEIGHT = 250;
|
||||
|
||||
type Props = PropsWithChildren<{
|
||||
headerImage: ReactElement;
|
||||
headerBackgroundColor: { dark: string; light: string };
|
||||
}>;
|
||||
|
||||
export default function ParallaxScrollView({
|
||||
children,
|
||||
headerImage,
|
||||
headerBackgroundColor,
|
||||
}: Props) {
|
||||
const backgroundColor = useThemeColor({}, 'background');
|
||||
const colorScheme = useColorScheme() ?? 'light';
|
||||
const scrollRef = useAnimatedRef<Animated.ScrollView>();
|
||||
const scrollOffset = useScrollOffset(scrollRef);
|
||||
const headerAnimatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [
|
||||
{
|
||||
translateY: interpolate(
|
||||
scrollOffset.value,
|
||||
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
||||
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
|
||||
),
|
||||
},
|
||||
{
|
||||
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Animated.ScrollView
|
||||
ref={scrollRef}
|
||||
style={{ backgroundColor, flex: 1 }}
|
||||
scrollEventThrottle={16}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.header,
|
||||
{ backgroundColor: headerBackgroundColor[colorScheme] },
|
||||
headerAnimatedStyle,
|
||||
]}>
|
||||
{headerImage}
|
||||
</Animated.View>
|
||||
<ThemedView style={styles.content}>{children}</ThemedView>
|
||||
</Animated.ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
height: HEADER_HEIGHT,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 32,
|
||||
gap: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
});
|
||||
449
components/template-run/result-display.tsx
Normal file
@@ -0,0 +1,449 @@
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
Share,
|
||||
Alert,
|
||||
Dimensions
|
||||
} from 'react-native';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { TemplateGeneration, GenerateType } from '@/lib/types/template-run';
|
||||
import { Image } from 'expo-image';
|
||||
import { VideoPlayer } from '@/components/video/video-player';
|
||||
import { useState } from 'react';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
|
||||
interface ResultDisplayProps {
|
||||
result: TemplateGeneration;
|
||||
onShare?: (url: string) => void;
|
||||
onDownload?: (url: string) => void;
|
||||
onRerun?: () => void;
|
||||
}
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window');
|
||||
|
||||
export function ResultDisplay({
|
||||
result,
|
||||
onShare,
|
||||
onDownload,
|
||||
onRerun
|
||||
}: ResultDisplayProps) {
|
||||
const [downloadingItems, setDownloadingItems] = useState<Set<number>>(new Set());
|
||||
const [sharing, setSharing] = useState(false);
|
||||
|
||||
const getTypeLabel = () => {
|
||||
switch (result.type) {
|
||||
case 'IMAGE':
|
||||
return '图片生成结果';
|
||||
case 'VIDEO':
|
||||
return '视频生成结果';
|
||||
case 'TEXT':
|
||||
return '文本生成结果';
|
||||
default:
|
||||
return '生成结果';
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeIcon = () => {
|
||||
switch (result.type) {
|
||||
case 'IMAGE':
|
||||
return '🖼️';
|
||||
case 'VIDEO':
|
||||
return '🎬';
|
||||
case 'TEXT':
|
||||
return '📝';
|
||||
default:
|
||||
return '📄';
|
||||
}
|
||||
};
|
||||
|
||||
const handleShare = async (url: string) => {
|
||||
if (sharing) return;
|
||||
|
||||
try {
|
||||
setSharing(true);
|
||||
|
||||
if (onShare) {
|
||||
onShare(url);
|
||||
return;
|
||||
}
|
||||
|
||||
await Share.share({
|
||||
message: `查看生成的${result.type === 'IMAGE' ? '图片' : result.type === 'VIDEO' ? '视频' : '文本'}: ${url}`,
|
||||
url: url,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('分享失败:', error);
|
||||
Alert.alert('分享失败', '无法分享此内容,请稍后重试');
|
||||
} finally {
|
||||
setSharing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async (url: string, index: number) => {
|
||||
if (downloadingItems.has(index)) return;
|
||||
|
||||
try {
|
||||
setDownloadingItems(prev => new Set(prev).add(index));
|
||||
|
||||
if (onDownload) {
|
||||
onDownload(url);
|
||||
return;
|
||||
}
|
||||
|
||||
// 请求媒体库权限
|
||||
const { status } = await MediaLibrary.requestPermissionsAsync();
|
||||
if (status !== 'granted') {
|
||||
Alert.alert('权限请求', '需要媒体库权限才能保存文件');
|
||||
return;
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
const downloadResult = await FileSystem.downloadAsync(
|
||||
url,
|
||||
FileSystem.documentDirectory + `generated_${Date.now()}_${index}.${getFileExtension(url)}`
|
||||
);
|
||||
|
||||
// 保存到媒体库
|
||||
if (result.type === 'IMAGE') {
|
||||
await MediaLibrary.saveToLibraryAsync(downloadResult.uri);
|
||||
Alert.alert('保存成功', '图片已保存到相册');
|
||||
} else if (result.type === 'VIDEO') {
|
||||
await MediaLibrary.saveToLibraryAsync(downloadResult.uri);
|
||||
Alert.alert('保存成功', '视频已保存到相册');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('下载失败:', error);
|
||||
Alert.alert('下载失败', '无法保存文件,请检查网络连接');
|
||||
} finally {
|
||||
setDownloadingItems(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(index);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getFileExtension = (url: string): string => {
|
||||
const parts = url.split('.');
|
||||
return parts[parts.length - 1] || 'jpg';
|
||||
};
|
||||
|
||||
const renderMediaItem = (url: string, index: number) => {
|
||||
const isVideo = /\.(mp4|webm|ogg|mov|avi|mkv|flv)$/i.test(url);
|
||||
|
||||
if (isVideo) {
|
||||
return (
|
||||
<View key={index} style={styles.mediaContainer}>
|
||||
<VideoPlayer
|
||||
source={{ uri: url }}
|
||||
poster={undefined} // 结果页不需要封面图
|
||||
useNativeControls={true}
|
||||
autoPlay={false}
|
||||
maxHeight={screenWidth * 0.9} // 最大高度为屏幕宽度的90%
|
||||
onReady={(status) => {
|
||||
console.log(`视频 ${index} 加载完成:`, status);
|
||||
}}
|
||||
onError={(error) => {
|
||||
console.error(`视频 ${index} 加载失败:`, error);
|
||||
}}
|
||||
/>
|
||||
|
||||
<View style={styles.mediaActions}>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.shareButton]}
|
||||
onPress={() => handleShare(url)}
|
||||
disabled={sharing}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<ThemedText style={styles.actionButtonText}>
|
||||
{sharing ? '分享中...' : '分享'}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.downloadButton]}
|
||||
onPress={() => handleDownload(url, index)}
|
||||
disabled={downloadingItems.has(index)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<ThemedText style={styles.actionButtonText}>
|
||||
{downloadingItems.has(index) ? '下载中...' : '下载'}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View key={index} style={styles.mediaContainer}>
|
||||
<Image
|
||||
source={{ uri: url }}
|
||||
style={styles.image}
|
||||
contentFit="contain"
|
||||
/>
|
||||
|
||||
<View style={styles.mediaActions}>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.shareButton]}
|
||||
onPress={() => handleShare(url)}
|
||||
disabled={sharing}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<ThemedText style={styles.actionButtonText}>
|
||||
{sharing ? '分享中...' : '分享'}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.downloadButton]}
|
||||
onPress={() => handleDownload(url, index)}
|
||||
disabled={downloadingItems.has(index)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<ThemedText style={styles.actionButtonText}>
|
||||
{downloadingItems.has(index) ? '下载中...' : '下载'}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderTextContent = (url: string, index: number) => {
|
||||
return (
|
||||
<View key={index} style={styles.textContainer}>
|
||||
<ThemedView style={styles.textBox}>
|
||||
<ThemedText style={styles.textContent}>
|
||||
{url} // 这里假设URL包含文本内容,实际可能需要额外处理
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
|
||||
<View style={styles.textActions}>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.shareButton]}
|
||||
onPress={() => handleShare(url)}
|
||||
disabled={sharing}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<ThemedText style={styles.actionButtonText}>
|
||||
{sharing ? '分享中...' : '分享'}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.copyButton]}
|
||||
onPress={() => {
|
||||
// 这里可以实现复制文本到剪贴板的功能
|
||||
Alert.alert('复制成功', '文本已复制到剪贴板');
|
||||
}}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<ThemedText style={styles.actionButtonText}>复制</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container} showsVerticalScrollIndicator={false}>
|
||||
<ThemedView style={styles.content}>
|
||||
{/* 结果头部 */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.titleContainer}>
|
||||
<ThemedText style={styles.titleIcon}>
|
||||
{getTypeIcon()}
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.title}>
|
||||
{getTypeLabel()}
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.stats}>
|
||||
<View style={styles.statItem}>
|
||||
<ThemedText style={styles.statLabel}>文件数量</ThemedText>
|
||||
<ThemedText style={styles.statValue}>{result.resultUrl.length}</ThemedText>
|
||||
</View>
|
||||
|
||||
{result.creditsCost && (
|
||||
<View style={styles.statItem}>
|
||||
<ThemedText style={styles.statLabel}>消耗积分</ThemedText>
|
||||
<ThemedText style={styles.statValue}>{result.creditsCost}</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 结果内容 */}
|
||||
<View style={styles.results}>
|
||||
{result.resultUrl.length === 0 ? (
|
||||
<View style={styles.emptyContainer}>
|
||||
<ThemedText style={styles.emptyText}>
|
||||
暂无生成结果
|
||||
</ThemedText>
|
||||
</View>
|
||||
) : (
|
||||
result.resultUrl.map((url, index) => {
|
||||
if (result.type === 'TEXT') {
|
||||
return renderTextContent(url, index);
|
||||
} else {
|
||||
return renderMediaItem(url, index);
|
||||
}
|
||||
})
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
{onRerun && (
|
||||
<View style={styles.footerActions}>
|
||||
<TouchableOpacity
|
||||
style={styles.rerunButton}
|
||||
onPress={onRerun}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<ThemedText style={styles.rerunButtonText}>🔄 重新生成</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</ThemedView>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
padding: 20,
|
||||
},
|
||||
header: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
titleContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
titleIcon: {
|
||||
fontSize: 24,
|
||||
marginRight: 8,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
},
|
||||
stats: {
|
||||
flexDirection: 'row',
|
||||
gap: 20,
|
||||
},
|
||||
statItem: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.05)',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 12,
|
||||
opacity: 0.7,
|
||||
marginBottom: 4,
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
results: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
emptyContainer: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 32,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 16,
|
||||
opacity: 0.6,
|
||||
},
|
||||
mediaContainer: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
image: {
|
||||
width: '100%',
|
||||
height: screenWidth * 0.75,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#f0f0f0',
|
||||
},
|
||||
mediaActions: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
marginTop: 12,
|
||||
},
|
||||
textContainer: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
textBox: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.05)',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
minHeight: 100,
|
||||
},
|
||||
textContent: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
},
|
||||
textActions: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
actionButton: {
|
||||
flex: 1,
|
||||
borderRadius: 8,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
shareButton: {
|
||||
backgroundColor: 'rgba(0, 122, 255, 0.1)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(0, 122, 255, 0.3)',
|
||||
},
|
||||
downloadButton: {
|
||||
backgroundColor: 'rgba(52, 199, 89, 0.1)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(52, 199, 89, 0.3)',
|
||||
},
|
||||
copyButton: {
|
||||
backgroundColor: 'rgba(142, 142, 147, 0.1)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(142, 142, 147, 0.3)',
|
||||
},
|
||||
actionButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
footerActions: {
|
||||
alignItems: 'center',
|
||||
paddingTop: 20,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: 'rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
rerunButton: {
|
||||
backgroundColor: 'rgba(78, 205, 196, 0.1)',
|
||||
borderRadius: 8,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 32,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(78, 205, 196, 0.3)',
|
||||
},
|
||||
rerunButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#4ECDC4',
|
||||
},
|
||||
});
|
||||
274
components/template-run/run-progress.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import { View, StyleSheet, Animated, TouchableOpacity } from 'react-native';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { RunProgress, GenerationStatus } from '@/lib/types/template-run';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface RunProgressViewProps {
|
||||
progress: RunProgress;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export function RunProgressView({ progress, onCancel }: RunProgressViewProps) {
|
||||
const animatedWidth = useRef(new Animated.Value(0)).current;
|
||||
const pulseAnimation = useRef(new Animated.Value(1)).current;
|
||||
|
||||
// 进度条动画
|
||||
useEffect(() => {
|
||||
Animated.timing(animatedWidth, {
|
||||
toValue: progress.progress,
|
||||
duration: 500,
|
||||
useNativeDriver: false,
|
||||
}).start();
|
||||
}, [progress.progress, animatedWidth]);
|
||||
|
||||
// 脉冲动画
|
||||
useEffect(() => {
|
||||
if (progress.status === 'running') {
|
||||
const pulseLoop = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulseAnimation, {
|
||||
toValue: 1.1,
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(pulseAnimation, {
|
||||
toValue: 1,
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
);
|
||||
pulseLoop.start();
|
||||
return () => pulseLoop.stop();
|
||||
} else {
|
||||
pulseAnimation.setValue(1);
|
||||
}
|
||||
}, [progress.status, pulseAnimation]);
|
||||
|
||||
const getStatusColor = () => {
|
||||
switch (progress.status) {
|
||||
case 'pending':
|
||||
return ['#FFA500', '#FF8C00']; // 橙色
|
||||
case 'running':
|
||||
return ['#4ECDC4', '#44A3A0']; // 青色
|
||||
case 'completed':
|
||||
return ['#52B788', '#40916C']; // 绿色
|
||||
case 'failed':
|
||||
return ['#FF6B6B', '#FF5252']; // 红色
|
||||
default:
|
||||
return ['#999999', '#666666'];
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = () => {
|
||||
switch (progress.status) {
|
||||
case 'pending':
|
||||
return '准备中...';
|
||||
case 'running':
|
||||
return '执行中...';
|
||||
case 'completed':
|
||||
return '已完成';
|
||||
case 'failed':
|
||||
return '执行失败';
|
||||
default:
|
||||
return '未知状态';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (progress.status) {
|
||||
case 'pending':
|
||||
return '⏳';
|
||||
case 'running':
|
||||
return '🔄';
|
||||
case 'completed':
|
||||
return '✅';
|
||||
case 'failed':
|
||||
return '❌';
|
||||
default:
|
||||
return '❓';
|
||||
}
|
||||
};
|
||||
|
||||
const colors = getStatusColor();
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
{/* 状态头部 */}
|
||||
<View style={styles.header}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.statusIcon,
|
||||
{
|
||||
transform: [{ scale: pulseAnimation }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ThemedText style={styles.iconText}>
|
||||
{getStatusIcon()}
|
||||
</ThemedText>
|
||||
</Animated.View>
|
||||
|
||||
<View style={styles.statusInfo}>
|
||||
<ThemedText style={styles.statusText}>
|
||||
{getStatusText()}
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.messageText}>
|
||||
{progress.message}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 进度条 */}
|
||||
<View style={styles.progressContainer}>
|
||||
<View style={styles.progressBackground}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.progressFill,
|
||||
{
|
||||
width: animatedWidth.interpolate({
|
||||
inputRange: [0, 100],
|
||||
outputRange: ['0%', '100%'],
|
||||
extrapolate: 'clamp',
|
||||
}),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={colors}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.progressGradient}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
<ThemedText style={styles.progressText}>
|
||||
{Math.round(progress.progress)}%
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
{/* 详细信息 */}
|
||||
<View style={styles.details}>
|
||||
<ThemedText style={styles.detailText}>
|
||||
状态: <ThemedText style={styles.detailValue}>{getStatusText()}</ThemedText>
|
||||
</ThemedText>
|
||||
|
||||
{progress.result?.creditsCost && (
|
||||
<ThemedText style={styles.detailText}>
|
||||
消耗积分: <ThemedText style={styles.detailValue}>{progress.result.creditsCost}</ThemedText>
|
||||
</ThemedText>
|
||||
)}
|
||||
|
||||
{progress.result?.type && (
|
||||
<ThemedText style={styles.detailText}>
|
||||
输出类型: <ThemedText style={styles.detailValue}>
|
||||
{progress.result.type === 'IMAGE' ? '图片' :
|
||||
progress.result.type === 'VIDEO' ? '视频' :
|
||||
progress.result.type === 'TEXT' ? '文本' : '未知'}
|
||||
</ThemedText>
|
||||
</ThemedText>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
{onCancel && (progress.status === 'pending' || progress.status === 'running') && (
|
||||
<View style={styles.actions}>
|
||||
<TouchableOpacity style={styles.cancelButton} onPress={onCancel}>
|
||||
<ThemedText style={styles.cancelButtonText}>取消任务</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
statusIcon: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: 'rgba(78, 205, 196, 0.1)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 16,
|
||||
},
|
||||
iconText: {
|
||||
fontSize: 24,
|
||||
},
|
||||
statusInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginBottom: 4,
|
||||
},
|
||||
messageText: {
|
||||
fontSize: 14,
|
||||
opacity: 0.7,
|
||||
},
|
||||
progressContainer: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
progressBackground: {
|
||||
height: 8,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.1)',
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
marginBottom: 8,
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressGradient: {
|
||||
flex: 1,
|
||||
},
|
||||
progressText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
},
|
||||
details: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.05)',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 20,
|
||||
},
|
||||
detailText: {
|
||||
fontSize: 14,
|
||||
marginBottom: 8,
|
||||
},
|
||||
detailValue: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
actions: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
cancelButton: {
|
||||
backgroundColor: 'rgba(255, 59, 48, 0.1)',
|
||||
borderRadius: 8,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 24,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 59, 48, 0.3)',
|
||||
},
|
||||
cancelButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#FF3B30',
|
||||
},
|
||||
});
|
||||
235
components/templates/template-card.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import { StyleSheet, TouchableOpacity, View } from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { Video, ResizeMode } from 'expo-av';
|
||||
import { VideoPlayer } from '@/components/video/video-player';
|
||||
import { ThemedText } from '../themed-text';
|
||||
import { ThemedView } from '../themed-view';
|
||||
import { Template } from '@/lib/types/template';
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
import { useMemo } from 'react';
|
||||
import { useRouter } from 'expo-router';
|
||||
|
||||
interface TemplateCardProps {
|
||||
template: Template;
|
||||
onPress?: (template: Template) => void;
|
||||
}
|
||||
|
||||
export function TemplateCard({ template, onPress }: TemplateCardProps) {
|
||||
const router = useRouter();
|
||||
const cardColor = useThemeColor({}, 'card');
|
||||
const imagePlaceholderColor = useThemeColor({}, 'imagePlaceholder');
|
||||
|
||||
const isVideo = useMemo(() => {
|
||||
return /\.(mp4|webm|ogg|mov|avi|mkv|flv)$/i.test(template.previewUrl || '');
|
||||
}, [template.previewUrl]);
|
||||
|
||||
const getImageHeight = () => {
|
||||
if (template.aspectRatio) {
|
||||
const [width, height] = template.aspectRatio.split(':').map(Number);
|
||||
if (width && height) {
|
||||
return width > height ? 280 : 360;
|
||||
}
|
||||
}
|
||||
return 280;
|
||||
};
|
||||
|
||||
const mediaHeight = getImageHeight();
|
||||
|
||||
const handleUseTemplate = () => {
|
||||
router.push(`/template/${template.id}/run`);
|
||||
};
|
||||
|
||||
const handleCardPress = () => {
|
||||
if (onPress) {
|
||||
onPress(template);
|
||||
} else {
|
||||
// 默认行为:跳转到运行页面
|
||||
handleUseTemplate();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.card, { backgroundColor: cardColor }]}>
|
||||
<View style={styles.mediaContainer}>
|
||||
<ThemedView style={[styles.mediaContent, { height: mediaHeight }]}>
|
||||
{isVideo ? (
|
||||
<VideoPlayer
|
||||
source={{ uri: template.previewUrl }}
|
||||
poster={template.coverImageUrl}
|
||||
style={[styles.videoPlayer, styles.videoCover]}
|
||||
resizeMode={ResizeMode.COVER}
|
||||
shouldPlay={true}
|
||||
isLooping={true}
|
||||
isMuted={true}
|
||||
useNativeControls={false}
|
||||
showPoster={true}
|
||||
autoPlay={true}
|
||||
maxHeight={mediaHeight}
|
||||
onPress={handleCardPress}
|
||||
/>
|
||||
) : (
|
||||
<TouchableOpacity onPress={handleCardPress} activeOpacity={0.9} style={styles.imageContainer}>
|
||||
<Image
|
||||
source={{ uri: template.coverImageUrl }}
|
||||
style={[
|
||||
styles.image,
|
||||
{ backgroundColor: imagePlaceholderColor },
|
||||
]}
|
||||
contentFit="cover"
|
||||
placeholder={{ blurhash: 'L6PZfSi_.AyE_3t7t7R**0o#DgR4' }}
|
||||
transition={200}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{template.category && (
|
||||
<View style={styles.watermark}>
|
||||
<View style={styles.categoryBadge}>
|
||||
<ThemedText style={styles.categoryText}>
|
||||
{template.category.name}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{template.tags.length > 0 && (
|
||||
<View style={styles.tagOverlay}>
|
||||
{template.tags.slice(0, 2).map((tag) => (
|
||||
<View key={tag.id} style={styles.tagBadge}>
|
||||
<ThemedText style={styles.tagText}>
|
||||
{tag.name}
|
||||
</ThemedText>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</ThemedView>
|
||||
</View>
|
||||
|
||||
{/* 操作按钮区域 */}
|
||||
<View style={styles.actionArea}>
|
||||
<ThemedText style={styles.templateTitle} numberOfLines={1}>
|
||||
{template.title}
|
||||
</ThemedText>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.useButton}
|
||||
onPress={handleUseTemplate}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<ThemedText style={styles.useButtonText}>立即使用</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
flex: 1,
|
||||
marginBottom: 14,
|
||||
marginHorizontal: 5,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 24,
|
||||
elevation: 4,
|
||||
},
|
||||
mediaContainer: {
|
||||
width: '100%',
|
||||
},
|
||||
mediaContent: {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
minHeight: 280,
|
||||
},
|
||||
image: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
videoPlayer: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
videoCover: {
|
||||
overflow: 'hidden',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
// React Native中object-fit对应contentFit属性,但在VideoPlayer中已经处理了
|
||||
},
|
||||
imageContainer: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
watermark: {
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
left: 14,
|
||||
zIndex: 10,
|
||||
},
|
||||
categoryBadge: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.15)',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 100,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
},
|
||||
categoryText: {
|
||||
color: 'rgba(255, 255, 255, 0.85)',
|
||||
fontSize: 10,
|
||||
fontWeight: '400',
|
||||
},
|
||||
tagOverlay: {
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
left: 24,
|
||||
right: 24,
|
||||
zIndex: 10,
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
tagBadge: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 100,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||
backdropFilter: 'blur(5px)',
|
||||
},
|
||||
tagText: {
|
||||
color: '#fff',
|
||||
fontSize: 11,
|
||||
fontWeight: '500',
|
||||
},
|
||||
actionArea: {
|
||||
padding: 12,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.02)',
|
||||
},
|
||||
templateTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
useButton: {
|
||||
backgroundColor: '#4ECDC4',
|
||||
borderRadius: 8,
|
||||
paddingVertical: 10,
|
||||
alignItems: 'center',
|
||||
shadowColor: '#4ECDC4',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
},
|
||||
useButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#fff',
|
||||
},
|
||||
});
|
||||
174
components/templates/template-list.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { StyleSheet, FlatList, ActivityIndicator, RefreshControl, View } from 'react-native';
|
||||
import { ThemedText } from '../themed-text';
|
||||
import { ThemedView } from '../themed-view';
|
||||
import { Template } from '@/lib/types/template';
|
||||
import { TemplateCard } from './template-card';
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
|
||||
interface TemplateListProps {
|
||||
templates: Template[];
|
||||
loading?: boolean;
|
||||
refreshing?: boolean;
|
||||
isLoadingMore?: boolean;
|
||||
hasMore?: boolean;
|
||||
onRefresh?: () => void;
|
||||
onLoadMore?: () => void;
|
||||
onTemplatePress?: (template: Template) => void;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export function TemplateList({
|
||||
templates,
|
||||
loading = false,
|
||||
refreshing = false,
|
||||
isLoadingMore = false,
|
||||
hasMore = true,
|
||||
onRefresh,
|
||||
onLoadMore,
|
||||
onTemplatePress,
|
||||
error,
|
||||
}: TemplateListProps) {
|
||||
const tintColor = useThemeColor({}, 'tint');
|
||||
const errorColor = useThemeColor({}, 'error');
|
||||
|
||||
// 渲染底部加载指示器
|
||||
const renderFooter = () => {
|
||||
if (isLoadingMore) {
|
||||
return (
|
||||
<View style={styles.footerLoader}>
|
||||
<ActivityIndicator size="small" color={tintColor} />
|
||||
<ThemedText style={styles.footerText}>加载中...</ThemedText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasMore && templates.length > 0) {
|
||||
return (
|
||||
<View style={styles.noMoreContainer}>
|
||||
<ThemedText style={styles.noMoreText}>— 没有更多了 —</ThemedText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
if (loading && templates.length === 0) {
|
||||
return (
|
||||
<ThemedView style={styles.centerContainer}>
|
||||
<ActivityIndicator size="large" color={tintColor} />
|
||||
<ThemedText style={styles.loadingText}>加载中...</ThemedText>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ThemedView style={styles.centerContainer}>
|
||||
<ThemedText style={[styles.errorText, { color: errorColor }]}>
|
||||
❌ {error}
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
if (templates.length === 0) {
|
||||
return (
|
||||
<ThemedView style={styles.centerContainer}>
|
||||
<ThemedText style={styles.emptyText}>暂无模板</ThemedText>
|
||||
{!hasMore && (
|
||||
<View style={styles.noMoreContainer}>
|
||||
<ThemedText style={styles.noMoreText}>— 没有更多了 —</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={templates}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<TemplateCard template={item} onPress={onTemplatePress} />
|
||||
)}
|
||||
numColumns={2}
|
||||
columnWrapperStyle={styles.row}
|
||||
contentContainerStyle={styles.listContent}
|
||||
refreshControl={
|
||||
onRefresh ? (
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
colors={[tintColor]}
|
||||
tintColor={tintColor}
|
||||
progressViewOffset={70}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
onEndReached={onLoadMore}
|
||||
onEndReachedThreshold={0.2}
|
||||
ListFooterComponent={renderFooter}
|
||||
ListFooterComponentStyle={templates.length > 0 ? styles.footerContainer : null}
|
||||
showsVerticalScrollIndicator={false}
|
||||
removeClippedSubviews={true}
|
||||
maxToRenderPerBatch={10}
|
||||
initialNumToRender={6}
|
||||
windowSize={10}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
centerContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
listContent: {
|
||||
paddingHorizontal: 14,
|
||||
paddingTop: 70,
|
||||
paddingBottom: 80,
|
||||
},
|
||||
row: {
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 2,
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 12,
|
||||
fontSize: 16,
|
||||
opacity: 0.7,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 16,
|
||||
opacity: 0.7,
|
||||
},
|
||||
footerLoader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 20,
|
||||
gap: 8,
|
||||
},
|
||||
footerText: {
|
||||
fontSize: 14,
|
||||
opacity: 0.7,
|
||||
},
|
||||
footerContainer: {
|
||||
paddingBottom: 20,
|
||||
},
|
||||
noMoreContainer: {
|
||||
paddingVertical: 20,
|
||||
alignItems: 'center',
|
||||
},
|
||||
noMoreText: {
|
||||
fontSize: 14,
|
||||
opacity: 0.5,
|
||||
color: '#666',
|
||||
},
|
||||
});
|
||||
60
components/themed-text.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { StyleSheet, Text, type TextProps } from 'react-native';
|
||||
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
|
||||
export type ThemedTextProps = TextProps & {
|
||||
lightColor?: string;
|
||||
darkColor?: string;
|
||||
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
|
||||
};
|
||||
|
||||
export function ThemedText({
|
||||
style,
|
||||
lightColor,
|
||||
darkColor,
|
||||
type = 'default',
|
||||
...rest
|
||||
}: ThemedTextProps) {
|
||||
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
|
||||
|
||||
return (
|
||||
<Text
|
||||
style={[
|
||||
{ color },
|
||||
type === 'default' ? styles.default : undefined,
|
||||
type === 'title' ? styles.title : undefined,
|
||||
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
|
||||
type === 'subtitle' ? styles.subtitle : undefined,
|
||||
type === 'link' ? styles.link : undefined,
|
||||
style,
|
||||
]}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
default: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
},
|
||||
defaultSemiBold: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
fontWeight: '600',
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
lineHeight: 32,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
link: {
|
||||
lineHeight: 30,
|
||||
fontSize: 16,
|
||||
color: '#0a7ea4',
|
||||
},
|
||||
});
|
||||
14
components/themed-view.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { View, type ViewProps } from 'react-native';
|
||||
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
|
||||
export type ThemedViewProps = ViewProps & {
|
||||
lightColor?: string;
|
||||
darkColor?: string;
|
||||
};
|
||||
|
||||
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
|
||||
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
|
||||
|
||||
return <View style={[{ backgroundColor }, style]} {...otherProps} />;
|
||||
}
|
||||
45
components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { PropsWithChildren, useState } from 'react';
|
||||
import { StyleSheet, TouchableOpacity } from 'react-native';
|
||||
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
|
||||
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const theme = useColorScheme() ?? 'light';
|
||||
|
||||
return (
|
||||
<ThemedView>
|
||||
<TouchableOpacity
|
||||
style={styles.heading}
|
||||
onPress={() => setIsOpen((value) => !value)}
|
||||
activeOpacity={0.8}>
|
||||
<IconSymbol
|
||||
name="chevron.right"
|
||||
size={18}
|
||||
weight="medium"
|
||||
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
|
||||
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
|
||||
/>
|
||||
|
||||
<ThemedText type="defaultSemiBold">{title}</ThemedText>
|
||||
</TouchableOpacity>
|
||||
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
heading: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
content: {
|
||||
marginTop: 6,
|
||||
marginLeft: 24,
|
||||
},
|
||||
});
|
||||
32
components/ui/icon-symbol.ios.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols';
|
||||
import { StyleProp, ViewStyle } from 'react-native';
|
||||
|
||||
export function IconSymbol({
|
||||
name,
|
||||
size = 24,
|
||||
color,
|
||||
style,
|
||||
weight = 'regular',
|
||||
}: {
|
||||
name: SymbolViewProps['name'];
|
||||
size?: number;
|
||||
color: string;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
weight?: SymbolWeight;
|
||||
}) {
|
||||
return (
|
||||
<SymbolView
|
||||
weight={weight}
|
||||
tintColor={color}
|
||||
resizeMode="scaleAspectFit"
|
||||
name={name}
|
||||
style={[
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
41
components/ui/icon-symbol.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
// Fallback for using MaterialIcons on Android and web.
|
||||
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { SymbolWeight, SymbolViewProps } from 'expo-symbols';
|
||||
import { ComponentProps } from 'react';
|
||||
import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
|
||||
|
||||
type IconMapping = Record<SymbolViewProps['name'], ComponentProps<typeof MaterialIcons>['name']>;
|
||||
type IconSymbolName = keyof typeof MAPPING;
|
||||
|
||||
/**
|
||||
* Add your SF Symbols to Material Icons mappings here.
|
||||
* - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
|
||||
* - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
|
||||
*/
|
||||
const MAPPING = {
|
||||
'house.fill': 'home',
|
||||
'paperplane.fill': 'send',
|
||||
'chevron.left.forwardslash.chevron.right': 'code',
|
||||
'chevron.right': 'chevron-right',
|
||||
} as IconMapping;
|
||||
|
||||
/**
|
||||
* An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
|
||||
* This ensures a consistent look across platforms, and optimal resource usage.
|
||||
* Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
|
||||
*/
|
||||
export function IconSymbol({
|
||||
name,
|
||||
size = 24,
|
||||
color,
|
||||
style,
|
||||
}: {
|
||||
name: IconSymbolName;
|
||||
size?: number;
|
||||
color: string | OpaqueColorValue;
|
||||
style?: StyleProp<TextStyle>;
|
||||
weight?: SymbolWeight;
|
||||
}) {
|
||||
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
|
||||
}
|
||||
326
components/video/video-player.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Dimensions,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { Video, ResizeMode, AVPlaybackStatus } from 'expo-av';
|
||||
import { Image } from 'expo-image';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
import {
|
||||
extractVideoMetadata,
|
||||
calculateOptimalVideoSize,
|
||||
getVideoFileType,
|
||||
isValidVideoUrl
|
||||
} from '@/utils/video-utils';
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window');
|
||||
|
||||
export interface VideoPlayerProps {
|
||||
source: { uri: string };
|
||||
poster?: string;
|
||||
style?: any;
|
||||
resizeMode?: ResizeMode;
|
||||
shouldPlay?: boolean;
|
||||
isLooping?: boolean;
|
||||
isMuted?: boolean;
|
||||
useNativeControls?: boolean;
|
||||
showPoster?: boolean;
|
||||
autoPlay?: boolean;
|
||||
maxHeight?: number;
|
||||
aspectRatio?: number;
|
||||
onReady?: (status: AVPlaybackStatus) => void;
|
||||
onError?: (error: any) => void;
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
export function VideoPlayer({
|
||||
source,
|
||||
poster,
|
||||
style,
|
||||
resizeMode = ResizeMode.CONTAIN,
|
||||
shouldPlay = false,
|
||||
isLooping = true,
|
||||
isMuted = true,
|
||||
useNativeControls = false,
|
||||
showPoster = true,
|
||||
autoPlay = false,
|
||||
maxHeight,
|
||||
aspectRatio,
|
||||
onReady,
|
||||
onError,
|
||||
onPress,
|
||||
}: VideoPlayerProps) {
|
||||
const videoRef = useRef<Video>(null);
|
||||
const [videoStatus, setVideoStatus] = useState<AVPlaybackStatus>();
|
||||
const [videoMetadata, setVideoMetadata] = useState<any>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showPosterOverlay, setShowPosterOverlay] = useState(showPoster);
|
||||
|
||||
const backgroundColor = useThemeColor({}, 'background');
|
||||
const placeholderColor = useThemeColor({}, 'imagePlaceholder');
|
||||
|
||||
// 验证视频URL
|
||||
const isValidUrl = isValidVideoUrl(source.uri);
|
||||
const videoFileType = getVideoFileType(source.uri);
|
||||
|
||||
// 计算视频容器的最佳尺寸
|
||||
const calculateVideoDimensions = () => {
|
||||
const containerWidth = screenWidth - 40; // 减去padding
|
||||
const defaultHeight = maxHeight || screenWidth * 0.75;
|
||||
|
||||
// 如果有预设的宽高比,使用它
|
||||
if (aspectRatio) {
|
||||
const calculatedHeight = containerWidth / aspectRatio;
|
||||
return {
|
||||
width: containerWidth,
|
||||
height: maxHeight ? Math.min(calculatedHeight, maxHeight) : calculatedHeight,
|
||||
};
|
||||
}
|
||||
|
||||
// 如果有视频元数据,使用它来计算最佳尺寸
|
||||
if (videoMetadata) {
|
||||
const resizeModeEnum = resizeMode === ResizeMode.COVER ? 'cover' : 'contain';
|
||||
return calculateOptimalVideoSize(
|
||||
containerWidth,
|
||||
maxHeight || defaultHeight,
|
||||
videoMetadata,
|
||||
resizeModeEnum
|
||||
);
|
||||
}
|
||||
|
||||
// 默认尺寸
|
||||
return {
|
||||
width: containerWidth,
|
||||
height: defaultHeight,
|
||||
};
|
||||
};
|
||||
|
||||
const videoSize = calculateVideoDimensions();
|
||||
|
||||
// 处理视频加载完成
|
||||
const handleVideoReady = (event: any) => {
|
||||
const status = event.nativeEvent || event;
|
||||
setVideoStatus(status);
|
||||
setIsLoading(false);
|
||||
|
||||
if (status.isLoaded) {
|
||||
// 提取视频元数据
|
||||
const metadata = extractVideoMetadata(status);
|
||||
if (metadata) {
|
||||
setVideoMetadata(metadata);
|
||||
console.log('视频元数据:', {
|
||||
尺寸: `${metadata.width}×${metadata.height}`,
|
||||
宽高比: metadata.aspectRatio.toFixed(2),
|
||||
时长: metadata.duration.toFixed(2) + '秒',
|
||||
方向: metadata.orientation,
|
||||
});
|
||||
}
|
||||
|
||||
// 如果有封面图且设置了自动播放,隐藏封面
|
||||
if (showPosterOverlay && autoPlay) {
|
||||
setShowPosterOverlay(false);
|
||||
}
|
||||
}
|
||||
|
||||
onReady?.(status);
|
||||
};
|
||||
|
||||
// 处理视频加载错误
|
||||
const handleVideoError = (error: any) => {
|
||||
setIsLoading(false);
|
||||
console.error('视频加载失败:', error);
|
||||
onError?.(error);
|
||||
};
|
||||
|
||||
// 处理视频播放状态变化
|
||||
const handlePlaybackStatusUpdate = (status: AVPlaybackStatus) => {
|
||||
setVideoStatus(status);
|
||||
|
||||
// 视频开始播放时隐藏封面
|
||||
if (status.isLoaded && status.isPlaying && showPosterOverlay) {
|
||||
setShowPosterOverlay(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理容器点击
|
||||
const handleContainerPress = () => {
|
||||
if (onPress) {
|
||||
onPress();
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果视频已加载但未播放,开始播放
|
||||
if (videoStatus?.isLoaded && !videoStatus.isPlaying && !useNativeControls) {
|
||||
videoRef.current?.playAsync();
|
||||
}
|
||||
};
|
||||
|
||||
// 自动播放逻辑
|
||||
useEffect(() => {
|
||||
if (autoPlay && videoRef.current && videoStatus?.isLoaded) {
|
||||
videoRef.current.playAsync();
|
||||
}
|
||||
}, [autoPlay, videoStatus]);
|
||||
|
||||
const isVideoLoaded = videoStatus?.isLoaded;
|
||||
const isVideoPlaying = videoStatus?.isLoaded && videoStatus.isPlaying;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.container, { height: videoSize.height }, style]}
|
||||
onPress={handleContainerPress}
|
||||
activeOpacity={0.9}
|
||||
disabled={!onPress && useNativeControls}
|
||||
>
|
||||
<ThemedView style={[styles.videoContainer, { backgroundColor }]}>
|
||||
{/* 封面图 */}
|
||||
{poster && showPosterOverlay && (
|
||||
<Image
|
||||
source={{ uri: poster }}
|
||||
style={[
|
||||
styles.poster,
|
||||
{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: placeholderColor,
|
||||
},
|
||||
]}
|
||||
contentFit="cover"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 视频播放器 */}
|
||||
<Video
|
||||
ref={videoRef}
|
||||
source={source}
|
||||
style={[
|
||||
styles.video,
|
||||
{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
]}
|
||||
resizeMode={resizeMode}
|
||||
shouldPlay={shouldPlay}
|
||||
isLooping={isLooping}
|
||||
isMuted={isMuted}
|
||||
useNativeControls={useNativeControls}
|
||||
onReadyForDisplay={handleVideoReady}
|
||||
onError={handleVideoError}
|
||||
onPlaybackStatusUpdate={handlePlaybackStatusUpdate}
|
||||
/>
|
||||
|
||||
{/* 加载指示器 */}
|
||||
{isLoading && (
|
||||
<View style={styles.loadingOverlay}>
|
||||
<ActivityIndicator size="large" color="#4ECDC4" />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 播放按钮覆盖层(当视频暂停且无原生控件时显示) */}
|
||||
{!useNativeControls && isVideoLoaded && !isVideoPlaying && !isLoading && (
|
||||
<View style={styles.playButtonOverlay}>
|
||||
<View style={styles.playButton}>
|
||||
<ThemedView style={styles.playIcon}>
|
||||
{'▶️'}
|
||||
</ThemedView>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 错误状态 */}
|
||||
{!isLoading && !isVideoLoaded && (
|
||||
<View style={styles.errorOverlay}>
|
||||
<ThemedView style={styles.errorMessage}>
|
||||
{'❌'}
|
||||
</ThemedView>
|
||||
</View>
|
||||
)}
|
||||
</ThemedView>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
videoContainer: {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
video: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
poster: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: 1,
|
||||
},
|
||||
loadingOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.1)',
|
||||
zIndex: 2,
|
||||
},
|
||||
playButtonOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 2,
|
||||
},
|
||||
playButton: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
playIcon: {
|
||||
fontSize: 24,
|
||||
color: '#fff',
|
||||
textAlign: 'center' as any,
|
||||
},
|
||||
errorOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.05)',
|
||||
zIndex: 2,
|
||||
},
|
||||
errorMessage: {
|
||||
fontSize: 32,
|
||||
opacity: 0.5,
|
||||
},
|
||||
});
|
||||
|
||||
export default VideoPlayer;
|
||||
69
constants/theme.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
|
||||
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
|
||||
*/
|
||||
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
const tintColorLight = '#0a7ea4';
|
||||
const tintColorDark = '#fff';
|
||||
|
||||
export const Colors = {
|
||||
light: {
|
||||
text: '#11181C',
|
||||
background: '#fff',
|
||||
tint: tintColorLight,
|
||||
icon: '#687076',
|
||||
tabIconDefault: '#687076',
|
||||
tabIconSelected: tintColorLight,
|
||||
card: '#fff',
|
||||
cardBorder: '#e0e0e0',
|
||||
categoryBadge: '#007AFF',
|
||||
categoryBadgeText: '#fff',
|
||||
tagBadge: '#e0e0e0',
|
||||
tagBadgeText: '#11181C',
|
||||
imagePlaceholder: '#f0f0f0',
|
||||
error: '#ff3b30',
|
||||
},
|
||||
dark: {
|
||||
text: '#ECEDEE',
|
||||
background: '#151718',
|
||||
tint: tintColorDark,
|
||||
icon: '#9BA1A6',
|
||||
tabIconDefault: '#9BA1A6',
|
||||
tabIconSelected: tintColorDark,
|
||||
card: '#1F2223',
|
||||
cardBorder: '#2C2E2F',
|
||||
categoryBadge: '#0A84FF',
|
||||
categoryBadgeText: '#fff',
|
||||
tagBadge: '#2C2E2F',
|
||||
tagBadgeText: '#ECEDEE',
|
||||
imagePlaceholder: '#2C2E2F',
|
||||
error: '#ff453a',
|
||||
},
|
||||
};
|
||||
|
||||
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: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
|
||||
serif: "Georgia, 'Times New Roman', serif",
|
||||
rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
|
||||
mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
||||
},
|
||||
});
|
||||
10
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/*'],
|
||||
},
|
||||
]);
|
||||
13
hooks/use-auth.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useSession } from "@/lib/auth/client";
|
||||
|
||||
export function useAuth() {
|
||||
const { data: session, isPending, error } = useSession();
|
||||
|
||||
return {
|
||||
user: session?.user ?? null,
|
||||
session: session ?? null,
|
||||
isLoading: isPending,
|
||||
isAuthenticated: !!session?.user,
|
||||
error: error ?? null,
|
||||
};
|
||||
}
|
||||
1
hooks/use-color-scheme.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { useColorScheme } from 'react-native';
|
||||
21
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';
|
||||
}
|
||||
320
hooks/use-template-run.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { Alert } from 'react-native';
|
||||
import {
|
||||
runTemplate,
|
||||
getTemplateGeneration,
|
||||
pollTemplateGeneration
|
||||
} from '@/lib/api/template-runs';
|
||||
import {
|
||||
RunTemplateData,
|
||||
TemplateGeneration,
|
||||
RunProgress,
|
||||
GenerationStatus
|
||||
} from '@/lib/types/template-run';
|
||||
|
||||
type UseTemplateRunState = 'idle' | 'submitting' | 'running' | 'completed' | 'error';
|
||||
|
||||
interface UseTemplateRunOptions {
|
||||
onSuccess?: (result: TemplateGeneration) => void;
|
||||
onError?: (error: Error) => void;
|
||||
onProgress?: (progress: RunProgress) => void;
|
||||
}
|
||||
|
||||
export function useTemplateRun(options: UseTemplateRunOptions = {}) {
|
||||
const [state, setState] = useState<UseTemplateRunState>('idle');
|
||||
const [progress, setProgress] = useState<RunProgress>({
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
message: '准备开始...',
|
||||
});
|
||||
const [result, setResult] = useState<TemplateGeneration | null>(null);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const pollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
// 清理轮询
|
||||
const clearPolling = useCallback(() => {
|
||||
if (pollTimeoutRef.current) {
|
||||
clearTimeout(pollTimeoutRef.current);
|
||||
pollTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 更新进度状态
|
||||
const updateProgress = useCallback((generation: TemplateGeneration) => {
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
let progressPercent = 0;
|
||||
let message = '处理中...';
|
||||
|
||||
switch (generation.status) {
|
||||
case 'pending':
|
||||
progressPercent = 10;
|
||||
message = '任务已提交,等待开始...';
|
||||
break;
|
||||
case 'running':
|
||||
progressPercent = 50;
|
||||
message = '正在生成内容,请稍候...';
|
||||
break;
|
||||
case 'completed':
|
||||
progressPercent = 100;
|
||||
message = '生成完成!';
|
||||
break;
|
||||
case 'failed':
|
||||
progressPercent = 0;
|
||||
message = '生成失败,请重试';
|
||||
break;
|
||||
}
|
||||
|
||||
const newProgress: RunProgress = {
|
||||
status: generation.status,
|
||||
progress: progressPercent,
|
||||
message,
|
||||
result: generation,
|
||||
};
|
||||
|
||||
setProgress(newProgress);
|
||||
options.onProgress?.(newProgress);
|
||||
}, [options.onProgress]);
|
||||
|
||||
// 处理轮询完成
|
||||
const handlePollComplete = useCallback((generation: TemplateGeneration) => {
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
clearPolling();
|
||||
setResult(generation);
|
||||
setState('completed');
|
||||
updateProgress(generation);
|
||||
options.onSuccess?.(generation);
|
||||
}, [clearPolling, updateProgress, options.onSuccess]);
|
||||
|
||||
// 处理轮询错误
|
||||
const handlePollError = useCallback((error: Error) => {
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
clearPolling();
|
||||
setError(error);
|
||||
setState('error');
|
||||
options.onError?.(error);
|
||||
}, [clearPolling, options.onError]);
|
||||
|
||||
// 运行模板
|
||||
const executeTemplate = useCallback(async (
|
||||
templateId: string,
|
||||
data: RunTemplateData
|
||||
) => {
|
||||
if (state === 'submitting' || state === 'running') {
|
||||
console.warn('模板正在运行中,请勿重复提交');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 重置状态
|
||||
setState('submitting');
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setProgress({
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
message: '正在提交任务...',
|
||||
});
|
||||
|
||||
// 提交模板运行
|
||||
const runResponse = await runTemplate(templateId, data);
|
||||
|
||||
if (!runResponse.success || !runResponse.data) {
|
||||
throw new Error('提交任务失败');
|
||||
}
|
||||
|
||||
const generationId = runResponse.data;
|
||||
setState('running');
|
||||
setProgress({
|
||||
status: 'pending',
|
||||
progress: 10,
|
||||
message: '任务已提交,开始生成...',
|
||||
});
|
||||
|
||||
// 开始轮询
|
||||
pollTemplateGeneration(
|
||||
generationId,
|
||||
handlePollComplete,
|
||||
handlePollError
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
console.error('运行模板失败:', err);
|
||||
|
||||
clearPolling();
|
||||
setError(err);
|
||||
setState('error');
|
||||
setProgress({
|
||||
status: 'failed',
|
||||
progress: 0,
|
||||
message: err.message || '运行失败',
|
||||
});
|
||||
|
||||
options.onError?.(err);
|
||||
}
|
||||
}, [state, clearPolling, handlePollComplete, handlePollError, options.onError]);
|
||||
|
||||
// 手动刷新状态
|
||||
const refreshStatus = useCallback(async (generationId: string) => {
|
||||
try {
|
||||
const response = await getTemplateGeneration(generationId);
|
||||
if (response.success && response.data) {
|
||||
updateProgress(response.data);
|
||||
|
||||
if (response.data.status === 'completed') {
|
||||
handlePollComplete(response.data);
|
||||
} else if (response.data.status === 'failed') {
|
||||
handlePollError(new Error('任务执行失败'));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('刷新状态失败:', error);
|
||||
}
|
||||
}, [updateProgress, handlePollComplete, handlePollError]);
|
||||
|
||||
// 取消运行
|
||||
const cancelRun = useCallback(() => {
|
||||
if (state === 'submitting' || state === 'running') {
|
||||
clearPolling();
|
||||
setState('idle');
|
||||
setProgress({
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
message: '已取消',
|
||||
});
|
||||
|
||||
Alert.alert('提示', '任务已取消');
|
||||
}
|
||||
}, [state, clearPolling]);
|
||||
|
||||
// 重置状态
|
||||
const reset = useCallback(() => {
|
||||
clearPolling();
|
||||
setState('idle');
|
||||
setProgress({
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
message: '准备开始...',
|
||||
});
|
||||
setResult(null);
|
||||
setError(null);
|
||||
}, [clearPolling]);
|
||||
|
||||
// 重试
|
||||
const retry = useCallback((templateId: string, data: RunTemplateData) => {
|
||||
reset();
|
||||
executeTemplate(templateId, data);
|
||||
}, [reset, executeTemplate]);
|
||||
|
||||
// 组件卸载时清理
|
||||
const cleanup = useCallback(() => {
|
||||
isMountedRef.current = false;
|
||||
clearPolling();
|
||||
}, [clearPolling]);
|
||||
|
||||
return {
|
||||
// 状态
|
||||
state,
|
||||
progress,
|
||||
result,
|
||||
error,
|
||||
isLoading: state === 'submitting' || state === 'running',
|
||||
isCompleted: state === 'completed',
|
||||
isError: state === 'error',
|
||||
|
||||
// 操作
|
||||
executeTemplate,
|
||||
refreshStatus,
|
||||
cancelRun,
|
||||
reset,
|
||||
retry,
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
// 便捷的 Hook 用于表单数据管理
|
||||
export function useTemplateFormData(initialData: RunTemplateData = {}) {
|
||||
const [formData, setFormData] = useState<RunTemplateData>(initialData);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const updateField = useCallback((fieldName: string, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [fieldName]: value }));
|
||||
// 清除该字段的错误
|
||||
if (errors[fieldName]) {
|
||||
setErrors(prev => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[fieldName];
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
}, [errors]);
|
||||
|
||||
const setFieldError = useCallback((fieldName: string, error: string) => {
|
||||
setErrors(prev => ({ ...prev, [fieldName]: error }));
|
||||
}, []);
|
||||
|
||||
const clearErrors = useCallback(() => {
|
||||
setErrors({});
|
||||
}, []);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setFormData(initialData);
|
||||
clearErrors();
|
||||
}, [initialData, clearErrors]);
|
||||
|
||||
const validateForm = useCallback((validation: Record<string, any> = {}) => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
let isValid = true;
|
||||
|
||||
Object.entries(validation).forEach(([fieldName, rules]) => {
|
||||
const value = formData[fieldName];
|
||||
|
||||
// 检查必填
|
||||
if (rules.required && (value === undefined || value === null || value === '')) {
|
||||
newErrors[fieldName] = `${fieldName} 是必填项`;
|
||||
isValid = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果值为空且不是必填,跳过其他验证
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 其他验证规则
|
||||
if (rules.min && typeof value === 'string' && value.length < rules.min) {
|
||||
newErrors[fieldName] = `${fieldName} 至少需要 ${rules.min} 个字符`;
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (rules.max && typeof value === 'string' && value.length > rules.max) {
|
||||
newErrors[fieldName] = `${fieldName} 最多 ${rules.max} 个字符`;
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (rules.pattern && typeof value === 'string' && !new RegExp(rules.pattern).test(value)) {
|
||||
newErrors[fieldName] = rules.message || `${fieldName} 格式不正确`;
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
setErrors(newErrors);
|
||||
return { isValid, errors: newErrors };
|
||||
}, [formData]);
|
||||
|
||||
return {
|
||||
formData,
|
||||
errors,
|
||||
updateField,
|
||||
setFieldError,
|
||||
clearErrors,
|
||||
resetForm,
|
||||
validateForm,
|
||||
isValid: Object.keys(errors).length === 0,
|
||||
};
|
||||
}
|
||||
21
hooks/use-theme-color.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 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 useThemeColor(
|
||||
props: { light?: string; dark?: string },
|
||||
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
|
||||
) {
|
||||
const theme = useColorScheme() ?? 'light';
|
||||
const colorFromProps = props[theme];
|
||||
|
||||
if (colorFromProps) {
|
||||
return colorFromProps;
|
||||
} else {
|
||||
return Colors[theme][colorName];
|
||||
}
|
||||
}
|
||||
53
lib/api/client.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { fetch, FetchRequestInit } from 'expo/fetch';
|
||||
import { storage } from '../storage';
|
||||
|
||||
const BASE_URL = 'https://api.mixvideo.bowong.cc';
|
||||
|
||||
export interface ApiRequestOptions extends FetchRequestInit {
|
||||
params?: Record<string, string | number | boolean | undefined>;
|
||||
}
|
||||
|
||||
export async function apiClient<T = any>(
|
||||
endpoint: string,
|
||||
options: ApiRequestOptions = {}
|
||||
): Promise<T> {
|
||||
const { params, ...fetchOptions } = options;
|
||||
|
||||
let url = `${BASE_URL}${endpoint}`;
|
||||
|
||||
if (params) {
|
||||
const queryParams = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
queryParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
const queryString = queryParams.toString();
|
||||
if (queryString) {
|
||||
url += `?${queryString}`;
|
||||
}
|
||||
}
|
||||
|
||||
const token = storage.getItem('bestaibest.better-auth.session_token');
|
||||
|
||||
const headers: any = {
|
||||
'Content-Type': 'application/json',
|
||||
...fetchOptions.headers,
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...fetchOptions,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`API Error: ${response.status} - ${error}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
56
lib/api/template-runs.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { apiClient } from './client';
|
||||
import { RunTemplateResponse, TemplateGenerationResponse, RunTemplateData } from '../types/template-run';
|
||||
|
||||
export async function runTemplate(templateId: string, data: RunTemplateData): Promise<RunTemplateResponse> {
|
||||
return apiClient<RunTemplateResponse>(`/api/templates/${templateId}/run`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ data }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getTemplateGeneration(generationId: string): Promise<TemplateGenerationResponse> {
|
||||
return apiClient<TemplateGenerationResponse>(`/api/template-generations/${generationId}`);
|
||||
}
|
||||
|
||||
export async function pollTemplateGeneration(
|
||||
generationId: string,
|
||||
onComplete: (result: any) => void,
|
||||
onError: (error: Error) => void,
|
||||
maxAttempts: number = 100,
|
||||
intervalMs: number = 3000
|
||||
): Promise<void> {
|
||||
let attempts = 0;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
attempts++;
|
||||
|
||||
if (attempts > maxAttempts) {
|
||||
throw new Error('轮询超时,请稍后重试');
|
||||
}
|
||||
|
||||
const response = await getTemplateGeneration(generationId);
|
||||
const generation = response.data;
|
||||
|
||||
// 检查是否完成
|
||||
if (generation.status === 'completed') {
|
||||
onComplete(generation);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否失败
|
||||
if (generation.status === 'failed') {
|
||||
throw new Error('任务执行失败');
|
||||
}
|
||||
|
||||
// 继续轮询
|
||||
setTimeout(poll, intervalMs);
|
||||
|
||||
} catch (error) {
|
||||
onError(error as Error);
|
||||
}
|
||||
};
|
||||
|
||||
// 开始轮询
|
||||
setTimeout(poll, intervalMs);
|
||||
}
|
||||
26
lib/api/templates.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { apiClient } from './client';
|
||||
import { TemplatesResponse, TemplateResponse } from '../types/template';
|
||||
|
||||
export interface GetTemplatesParams {
|
||||
page?: number;
|
||||
size?: number;
|
||||
search?: string;
|
||||
categoryId?: string;
|
||||
status?: 'AUDITING' | 'RELEASE' | 'EDITING';
|
||||
}
|
||||
|
||||
export async function getTemplates(params: GetTemplatesParams = {}): Promise<TemplatesResponse> {
|
||||
return apiClient<TemplatesResponse>('/api/templates', {
|
||||
params: {
|
||||
page: params.page || 1,
|
||||
size: params.size || 10,
|
||||
search: params.search,
|
||||
categoryId: params.categoryId,
|
||||
status: params.status,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getTemplateById(id: string): Promise<TemplateResponse> {
|
||||
return apiClient<TemplateResponse>(`/api/templates/${id}`);
|
||||
}
|
||||
27
lib/auth/client.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { expoClient } from "@better-auth/expo/client";
|
||||
import { stripeClient } from "@bowong/better-auth-stripe/client";
|
||||
import { usernameClient } from "better-auth/client/plugins";
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { storage } from '../storage';
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: "http://localhost:14333/api/auth",
|
||||
fetchOptions: {
|
||||
credentials: "omit",
|
||||
auth: {
|
||||
type: 'Bearer',
|
||||
token: () => storage.getItem(`bestaibest.better-auth.session_token`) || ``
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
expoClient({
|
||||
scheme: "bestaibestwebapp",
|
||||
storagePrefix: "bestaibest",
|
||||
storage: storage,
|
||||
}),
|
||||
usernameClient(),
|
||||
stripeClient({ subscription: true }),
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
export const { signIn, signUp, signOut, useSession, $Infer, subscription } = authClient;
|
||||
24
lib/auth/types.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
image?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
user: User;
|
||||
session: {
|
||||
id: string;
|
||||
userId: string;
|
||||
expiresAt: Date;
|
||||
token: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AuthError {
|
||||
message: string;
|
||||
code?: string;
|
||||
}
|
||||
45
lib/storage.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
interface Storage {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
}
|
||||
|
||||
class WebStorage implements Storage {
|
||||
getItem(key: string): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
return window.localStorage.getItem(key);
|
||||
}
|
||||
|
||||
setItem(key: string, value: string) {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(key, value);
|
||||
}
|
||||
|
||||
removeItem(key: string) {
|
||||
if (typeof window === "undefined") return;
|
||||
console.log(`[WebStorage] Removing key: ${key}`);
|
||||
window.localStorage.removeItem(key);
|
||||
console.log(`[WebStorage] Key removed. Verification:`, window.localStorage.getItem(key));
|
||||
}
|
||||
}
|
||||
|
||||
class NativeStorage implements Storage {
|
||||
getItem(key: string): string | null {
|
||||
return SecureStore.getItem(key);
|
||||
}
|
||||
|
||||
setItem(key: string, value: string) {
|
||||
SecureStore.setItem(key, value);
|
||||
}
|
||||
|
||||
removeItem(key: string) {
|
||||
console.log(`[NativeStorage] Removing key: ${key}`);
|
||||
SecureStore.setItem(key, "");
|
||||
console.log(`[NativeStorage] Key set to empty string`);
|
||||
}
|
||||
}
|
||||
|
||||
export const storage: Storage = Platform.OS === "web" ? new WebStorage() : new NativeStorage();
|
||||
68
lib/types/template-run.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
export type GenerateType = 'UNKNOWN' | 'IMAGE' | 'VIDEO' | 'TEXT';
|
||||
|
||||
export type GenerationStatus = 'pending' | 'running' | 'completed' | 'failed';
|
||||
|
||||
export interface TemplateGeneration {
|
||||
id: string;
|
||||
userId: string;
|
||||
templateId: string;
|
||||
type: GenerateType;
|
||||
resultUrl: string[];
|
||||
status: GenerationStatus;
|
||||
creditsCost?: number;
|
||||
creditsTransactionId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TemplateGenerationResponse {
|
||||
success: boolean;
|
||||
data: TemplateGeneration;
|
||||
}
|
||||
|
||||
export interface RunTemplateResponse {
|
||||
success: boolean;
|
||||
data: string; // generation ID
|
||||
}
|
||||
|
||||
export interface FormFieldOption {
|
||||
label: string;
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
export interface FormFieldSchema {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'text' | 'number' | 'textarea' | 'select' | 'image' | 'video' | 'color';
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
options?: FormFieldOption[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
defaultValue?: any;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface RunFormSchema {
|
||||
fields: FormFieldSchema[];
|
||||
validation?: {
|
||||
[fieldName: string]: {
|
||||
required?: boolean;
|
||||
min?: number;
|
||||
max?: number;
|
||||
pattern?: string;
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface RunTemplateData {
|
||||
[fieldName: string]: any;
|
||||
}
|
||||
|
||||
export interface RunProgress {
|
||||
status: GenerationStatus;
|
||||
progress: number; // 0-100
|
||||
message: string;
|
||||
result?: TemplateGeneration;
|
||||
}
|
||||
55
lib/types/template.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
nameEn: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
nameEn: string;
|
||||
description: string;
|
||||
descriptionEn: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Template {
|
||||
id: string;
|
||||
title: string;
|
||||
titleEn: string;
|
||||
description: string;
|
||||
descriptionEn: string;
|
||||
coverImageUrl: string;
|
||||
previewUrl: string;
|
||||
aspectRatio: string;
|
||||
userId: string;
|
||||
categoryId: string;
|
||||
content?: unknown;
|
||||
formSchema?: unknown;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
category: Category | null;
|
||||
tags: Tag[];
|
||||
}
|
||||
|
||||
export interface PaginationMeta {
|
||||
page: number;
|
||||
size: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface TemplatesResponse {
|
||||
success: boolean;
|
||||
data: Template[];
|
||||
pagination: PaginationMeta;
|
||||
}
|
||||
|
||||
export interface TemplateResponse {
|
||||
success: boolean;
|
||||
data: Template;
|
||||
}
|
||||
173
lib/utils/form-schema-transformer.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { RunFormSchema, FormFieldSchema } from '@/lib/types/template-run';
|
||||
|
||||
// 定义工作流节点的数据结构
|
||||
interface WorkflowNode {
|
||||
id: string;
|
||||
type: 'image' | 'video' | 'text' | 'audio';
|
||||
data: {
|
||||
label: string;
|
||||
description?: string;
|
||||
flowType: 'start' | 'normal' | 'end';
|
||||
actionData?: {
|
||||
prompt?: string;
|
||||
aspectRatio?: string;
|
||||
selectedModel?: string;
|
||||
duration?: string;
|
||||
advancedParams?: Record<string, any>;
|
||||
};
|
||||
output?: {
|
||||
images?: Array<{ url: string }>;
|
||||
videos?: Array<{ url: string }>;
|
||||
coverUrl?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface FormSchemaData {
|
||||
startNodes: WorkflowNode[];
|
||||
endNodes: WorkflowNode[];
|
||||
}
|
||||
|
||||
// 节点类型到表单字段类型的映射
|
||||
const nodeTypeToFieldType = {
|
||||
image: 'image',
|
||||
video: 'video',
|
||||
text: 'textarea',
|
||||
audio: 'text', // 暂时用文本字段处理音频
|
||||
};
|
||||
|
||||
// 生成字段名称
|
||||
const generateFieldName = (node: WorkflowNode, index: number): string => {
|
||||
const typePrefix = node.type;
|
||||
const flowType = node.data.flowType;
|
||||
return `${typePrefix}_${flowType}_${index}`;
|
||||
};
|
||||
|
||||
// 生成字段标签
|
||||
const generateFieldLabel = (node: WorkflowNode): string => {
|
||||
const baseLabel = node.data.label || `节点 ${node.id.slice(-8)}`;
|
||||
const flowTypeLabel = {
|
||||
start: '(输入)',
|
||||
normal: '(处理)',
|
||||
end: '(输出)'
|
||||
};
|
||||
return baseLabel + (flowTypeLabel[node.data.flowType] || '');
|
||||
};
|
||||
|
||||
// 转换单个节点为表单字段
|
||||
const transformNodeToFormField = (node: WorkflowNode, index: number): FormFieldSchema | null => {
|
||||
// 只为start节点生成表单字段,因为用户只需要输入数据
|
||||
if (node.data.flowType !== 'start') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fieldName = generateFieldName(node, index);
|
||||
const fieldType = nodeTypeToFieldType[node.type] || 'text';
|
||||
const fieldLabel = generateFieldLabel(node);
|
||||
|
||||
const field: FormFieldSchema = {
|
||||
name: fieldName,
|
||||
label: fieldLabel,
|
||||
type: fieldType as any,
|
||||
required: true,
|
||||
description: node.data.description || `请上传${node.type === 'image' ? '图片' : node.type === 'video' ? '视频' : '内容'}`,
|
||||
};
|
||||
|
||||
// 根据节点类型设置默认值和占位符
|
||||
switch (node.type) {
|
||||
case 'image':
|
||||
field.placeholder = '点击上传图片';
|
||||
if (node.data.output?.coverUrl) {
|
||||
field.defaultValue = node.data.output.coverUrl;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'video':
|
||||
field.placeholder = '点击上传视频';
|
||||
if (node.data.output?.videos?.[0]?.url) {
|
||||
field.defaultValue = node.data.output.videos[0].url;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'text':
|
||||
field.placeholder = '请输入文本内容';
|
||||
field.min = 1;
|
||||
field.max = 1000;
|
||||
break;
|
||||
}
|
||||
|
||||
// 如果节点有预填的prompt,作为提示信息
|
||||
if (node.data.actionData?.prompt) {
|
||||
field.description += `\n\n提示:${node.data.actionData.prompt}`;
|
||||
}
|
||||
|
||||
return field;
|
||||
};
|
||||
|
||||
// 主要的转换函数
|
||||
export function transformWorkflowToFormSchema(formSchemaData: unknown): RunFormSchema {
|
||||
try {
|
||||
// 类型守卫:检查数据格式
|
||||
if (!formSchemaData || typeof formSchemaData !== 'object') {
|
||||
console.warn('formSchema数据格式无效');
|
||||
return { fields: [] };
|
||||
}
|
||||
|
||||
const data = formSchemaData as any;
|
||||
|
||||
// 检查是否包含nodes数组(新格式)或startNodes/endNodes(旧格式)
|
||||
let nodesToProcess: WorkflowNode[] = [];
|
||||
|
||||
if (Array.isArray(data.nodes)) {
|
||||
// 新格式:从nodes数组中筛选start节点
|
||||
nodesToProcess = data.nodes.filter((node: any) =>
|
||||
node.data?.flowType === 'start'
|
||||
);
|
||||
} else if (Array.isArray(data.startNodes)) {
|
||||
// 旧格式:使用startNodes
|
||||
nodesToProcess = data.startNodes;
|
||||
}
|
||||
|
||||
if (nodesToProcess.length === 0) {
|
||||
console.log('未找到需要用户输入的节点');
|
||||
return { fields: [] };
|
||||
}
|
||||
|
||||
// 转换节点为表单字段
|
||||
const fields: FormFieldSchema[] = [];
|
||||
|
||||
nodesToProcess.forEach((node: any, index: number) => {
|
||||
const formField = transformNodeToFormField(node, index);
|
||||
if (formField) {
|
||||
fields.push(formField);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`成功转换 ${fields.length} 个表单字段`);
|
||||
|
||||
return {
|
||||
fields,
|
||||
validation: {}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('转换formSchema失败:', error);
|
||||
return { fields: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// 验证转换后的表单配置
|
||||
export function validateFormSchema(schema: RunFormSchema): boolean {
|
||||
if (!schema || !Array.isArray(schema.fields)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return schema.fields.every(field => {
|
||||
return (
|
||||
field.name &&
|
||||
field.label &&
|
||||
field.type &&
|
||||
['text', 'number', 'textarea', 'select', 'image', 'video', 'color'].includes(field.type)
|
||||
);
|
||||
});
|
||||
}
|
||||
7
metro.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
config.resolver.unstable_enablePackageExports = true;
|
||||
|
||||
module.exports = config;
|
||||
12188
package-lock.json
generated
Normal file
58
package.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "bw-expo-app-v3",
|
||||
"main": "expo-router/entry",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web",
|
||||
"lint": "expo lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@better-auth/expo": "^1.3.27",
|
||||
"@better-auth/stripe": "^1.3.27",
|
||||
"@bowong/better-auth-stripe": "1.3.27-a",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/elements": "^2.6.3",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"better-auth": "1.3.27",
|
||||
"expo": "~54.0.13",
|
||||
"expo-av": "~16.0.7",
|
||||
"expo-constants": "~18.0.9",
|
||||
"expo-file-system": "~19.0.17",
|
||||
"expo-font": "~14.0.9",
|
||||
"expo-haptics": "~15.0.7",
|
||||
"expo-image": "~3.0.9",
|
||||
"expo-image-picker": "^17.0.8",
|
||||
"expo-linear-gradient": "~15.0.7",
|
||||
"expo-linking": "~8.0.8",
|
||||
"expo-media-library": "~18.2.0",
|
||||
"expo-router": "~6.0.11",
|
||||
"expo-secure-store": "^15.0.7",
|
||||
"expo-splash-screen": "~31.0.10",
|
||||
"expo-status-bar": "~3.0.8",
|
||||
"expo-symbols": "~1.0.7",
|
||||
"expo-system-ui": "~6.0.7",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.4",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-worklets": "0.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.1.0",
|
||||
"typescript": "~5.9.2",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~10.0.0"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
17
tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".expo/types/**/*.ts",
|
||||
"expo-env.d.ts"
|
||||
]
|
||||
}
|
||||
162
utils/cookie-encoding.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Cookie编码处理工具
|
||||
* 解决特殊字符在Cookie中的编码问题
|
||||
*/
|
||||
|
||||
/**
|
||||
* 安全编码Cookie值
|
||||
* 使用encodeURIComponent确保特殊字符正确编码
|
||||
*
|
||||
* @param value 原始值
|
||||
* @returns 编码后的值
|
||||
*/
|
||||
export function encodeCookieValue(value: string): string {
|
||||
if (!value) return '';
|
||||
|
||||
try {
|
||||
// 使用encodeURIComponent编码所有特殊字符
|
||||
return encodeURIComponent(value);
|
||||
} catch (error) {
|
||||
console.error('Cookie值编码失败:', error);
|
||||
return value; // 编码失败时返回原值
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全解码Cookie值
|
||||
* 使用decodeURIComponent解码编码的值
|
||||
*
|
||||
* @param encodedValue 编码后的值
|
||||
* @returns 解码后的原始值
|
||||
*/
|
||||
export function decodeCookieValue(encodedValue: string): string {
|
||||
if (!encodedValue) return '';
|
||||
|
||||
try {
|
||||
// 使用decodeURIComponent解码
|
||||
return decodeURIComponent(encodedValue);
|
||||
} catch (error) {
|
||||
console.error('Cookie值解码失败:', error);
|
||||
// 解码失败时,可能值已经未编码,返回原值
|
||||
return encodedValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量编码Cookie对象中的所有值
|
||||
*
|
||||
* @param cookieObj Cookie对象 {key: value}
|
||||
* @returns 编码后的Cookie对象
|
||||
*/
|
||||
export function encodeCookieObject(cookieObj: Record<string, string>): Record<string, string> {
|
||||
const encoded: Record<string, string> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(cookieObj)) {
|
||||
encoded[key] = encodeCookieValue(value);
|
||||
}
|
||||
|
||||
return encoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量解码Cookie对象中的所有值
|
||||
*
|
||||
* @param encodedCookieObj 编码的Cookie对象
|
||||
* @returns 解码后的Cookie对象
|
||||
*/
|
||||
export function decodeCookieObject(encodedCookieObj: Record<string, string>): Record<string, string> {
|
||||
const decoded: Record<string, string> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(encodedCookieObj)) {
|
||||
decoded[key] = decodeCookieValue(value);
|
||||
}
|
||||
|
||||
return decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全解析包含编码值的Cookie字符串
|
||||
*
|
||||
* @param cookieString Cookie字符串
|
||||
* @returns 解析后的Cookie对象
|
||||
*/
|
||||
export function parseEncodedCookieString(cookieString: string): Record<string, string> {
|
||||
if (!cookieString) return {};
|
||||
|
||||
const cookies: Record<string, string> = {};
|
||||
|
||||
// 分割多个Cookie对
|
||||
const cookiePairs = cookieString.split(';').map(pair => pair.trim());
|
||||
|
||||
for (const pair of cookiePairs) {
|
||||
if (!pair) continue;
|
||||
|
||||
const [key, ...valueParts] = pair.split('=');
|
||||
if (!key) continue;
|
||||
|
||||
const value = valueParts.join('=');
|
||||
cookies[key] = decodeCookieValue(value);
|
||||
}
|
||||
|
||||
return cookies;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建包含编码值的Cookie字符串
|
||||
*
|
||||
* @param cookieObj Cookie对象
|
||||
* @returns 编码后的Cookie字符串
|
||||
*/
|
||||
export function createEncodedCookieString(cookieObj: Record<string, string>): string {
|
||||
const encodedPairs: string[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(cookieObj)) {
|
||||
const encodedValue = encodeCookieValue(value);
|
||||
encodedPairs.push(`${key}=${encodedValue}`);
|
||||
}
|
||||
|
||||
return encodedPairs.join('; ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查字符串是否包含特殊字符
|
||||
*
|
||||
* @param value 要检查的字符串
|
||||
* @returns 是否包含需要编码的字符
|
||||
*/
|
||||
export function hasSpecialChars(value: string): boolean {
|
||||
// 检查是否包含需要编码的特殊字符
|
||||
const specialChars = /[^\w\-._~!$&'()*+,;=:@\/]/;
|
||||
return specialChars.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能编码:仅对包含特殊字符的值进行编码
|
||||
*
|
||||
* @param value 要编码的值
|
||||
* @returns 编码后的值
|
||||
*/
|
||||
export function smartEncodeCookieValue(value: string): string {
|
||||
if (!value) return '';
|
||||
|
||||
// 如果包含特殊字符则编码,否则返回原值
|
||||
return hasSpecialChars(value) ? encodeCookieValue(value) : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能解码:尝试解码,失败则返回原值
|
||||
*
|
||||
* @param encodedValue 可能编码的值
|
||||
* @returns 解码后的值
|
||||
*/
|
||||
export function smartDecodeCookieValue(encodedValue: string): string {
|
||||
if (!encodedValue) return '';
|
||||
|
||||
// 尝试解码,如果解码后的值与原值相同,可能说明没有编码过
|
||||
try {
|
||||
const decoded = decodeCookieValue(encodedValue);
|
||||
return decoded;
|
||||
} catch (error) {
|
||||
return encodedValue;
|
||||
}
|
||||
}
|
||||
212
utils/video-utils.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { AVPlaybackStatus } from 'expo-av';
|
||||
|
||||
export interface VideoMetadata {
|
||||
width: number;
|
||||
height: number;
|
||||
duration: number;
|
||||
aspectRatio: number;
|
||||
orientation: 'portrait' | 'landscape' | 'square';
|
||||
}
|
||||
|
||||
/**
|
||||
* 从AVPlaybackStatus中提取视频元数据
|
||||
*/
|
||||
export function extractVideoMetadata(status: AVPlaybackStatus): VideoMetadata | null {
|
||||
if (!status.isLoaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 尝试从不同的属性获取视频尺寸
|
||||
let width = 0, height = 0;
|
||||
|
||||
// 检查是否有naturalSize属性
|
||||
if ('naturalSize' in status && status.naturalSize) {
|
||||
const naturalSize = status.naturalSize as any;
|
||||
width = naturalSize.width || 0;
|
||||
height = naturalSize.height || 0;
|
||||
}
|
||||
|
||||
// 如果没有获取到尺寸,使用默认值
|
||||
if (width === 0 || height === 0) {
|
||||
width = 1920; // 默认宽度
|
||||
height = 1080; // 默认高度
|
||||
}
|
||||
|
||||
const duration = status.durationMillis ? status.durationMillis / 1000 : 0;
|
||||
const aspectRatio = width > 0 ? width / height : 1;
|
||||
|
||||
// 判断视频方向
|
||||
let orientation: 'portrait' | 'landscape' | 'square';
|
||||
if (Math.abs(width - height) < 10) {
|
||||
orientation = 'square';
|
||||
} else if (width > height) {
|
||||
orientation = 'landscape';
|
||||
} else {
|
||||
orientation = 'portrait';
|
||||
}
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
duration,
|
||||
aspectRatio,
|
||||
orientation,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据容器尺寸和视频元数据计算最佳显示尺寸
|
||||
*/
|
||||
export function calculateOptimalVideoSize(
|
||||
containerWidth: number,
|
||||
maxHeight: number,
|
||||
metadata: VideoMetadata,
|
||||
resizeMode: 'contain' | 'cover' | 'stretch' = 'contain'
|
||||
): { width: number; height: number } {
|
||||
const { aspectRatio } = metadata;
|
||||
|
||||
switch (resizeMode) {
|
||||
case 'contain':
|
||||
// 保持宽高比,完全显示在容器内
|
||||
if (containerWidth / aspectRatio <= maxHeight) {
|
||||
return {
|
||||
width: containerWidth,
|
||||
height: containerWidth / aspectRatio,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
width: maxHeight * aspectRatio,
|
||||
height: maxHeight,
|
||||
};
|
||||
}
|
||||
|
||||
case 'cover':
|
||||
// 保持宽高比,填满容器(可能裁剪)
|
||||
if (containerWidth / aspectRatio >= maxHeight) {
|
||||
return {
|
||||
width: containerWidth,
|
||||
height: containerWidth / aspectRatio,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
width: maxHeight * aspectRatio,
|
||||
height: maxHeight,
|
||||
};
|
||||
}
|
||||
|
||||
case 'stretch':
|
||||
// 拉伸填满容器
|
||||
return {
|
||||
width: containerWidth,
|
||||
height: maxHeight,
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
width: containerWidth,
|
||||
height: containerWidth / aspectRatio,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化视频时长
|
||||
*/
|
||||
export function formatVideoDuration(seconds: number): string {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
|
||||
if (minutes === 0) {
|
||||
return `${remainingSeconds}秒`;
|
||||
} else if (remainingSeconds === 0) {
|
||||
return `${minutes}分钟`;
|
||||
} else {
|
||||
return `${minutes}分${remainingSeconds}秒`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化视频尺寸信息
|
||||
*/
|
||||
export function formatVideoSize(metadata: VideoMetadata): string {
|
||||
const { width, height } = metadata;
|
||||
|
||||
if (width >= 1000 || height >= 1000) {
|
||||
return `${(width / 1000).toFixed(1)}k × ${(height / 1000).toFixed(1)}k`;
|
||||
} else {
|
||||
return `${width} × ${height}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测视频文件类型
|
||||
*/
|
||||
export function getVideoFileType(url: string): string | null {
|
||||
const extension = url.split('.').pop()?.toLowerCase();
|
||||
|
||||
const videoExtensions = [
|
||||
'mp4', 'webm', 'ogg', 'mov', 'avi',
|
||||
'mkv', 'flv', 'wmv', 'm4v', '3gp'
|
||||
];
|
||||
|
||||
return extension && videoExtensions.includes(extension) ? extension : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成视频封面图的URI(如果需要)
|
||||
*/
|
||||
export function generateVideoPosterUrl(videoUrl: string): string {
|
||||
// 某些CDN支持通过参数生成封面图
|
||||
// 这里可以根据实际使用的视频服务进行调整
|
||||
if (videoUrl.includes('youtube.com') || videoUrl.includes('youtu.be')) {
|
||||
// YouTube视频封面图逻辑
|
||||
const videoId = extractYouTubeVideoId(videoUrl);
|
||||
return videoId ? `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg` : '';
|
||||
}
|
||||
|
||||
// 其他视频服务的封面图逻辑可以在这里添加
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 从YouTube URL提取视频ID
|
||||
*/
|
||||
function extractYouTubeVideoId(url: string): string | null {
|
||||
const regex = /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/;
|
||||
const match = url.match(regex);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证视频URL是否有效
|
||||
*/
|
||||
export function isValidVideoUrl(url: string): boolean {
|
||||
if (!url || typeof url !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const isValidProtocol = ['http:', 'https:', 'ftp:'].includes(urlObj.protocol);
|
||||
const hasVideoExtension = getVideoFileType(url) !== null;
|
||||
|
||||
return isValidProtocol && (hasVideoExtension || url.includes('stream'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取推荐的缩略图时间点(视频的10%、30%、60%位置)
|
||||
*/
|
||||
export function getThumbnailTimepoints(duration: number): number[] {
|
||||
if (duration <= 0) return [1];
|
||||
|
||||
const points = [
|
||||
duration * 0.1,
|
||||
duration * 0.3,
|
||||
duration * 0.6,
|
||||
];
|
||||
|
||||
return points.map(t => Math.max(1, Math.floor(t)));
|
||||
}
|
||||