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>
This commit is contained in:
imeepos
2025-10-14 10:32:52 +08:00
commit 7e3f94bae3
71 changed files with 20800 additions and 0 deletions

View 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
View 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
View File

@@ -0,0 +1 @@
{ "recommendations": ["expo.vscode-expo-tools"] }

7
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit",
"source.sortMembers": "explicit"
}
}

74
CLAUDE.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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&apos;s current color scheme is, and so you can adjust UI colors accordingly.
</ThemedText>
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
<ThemedText type="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
View 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
View 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
View 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
View 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',
},
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

BIN
assets/images/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
assets/images/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

2247
bun.lock Normal file

File diff suppressed because it is too large Load Diff

View 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",
},
});

View 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',
},
});

View 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,
},
});

View File

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

View File

@@ -0,0 +1,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',
},
});

View 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,
},
});

View 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,
},
});

View 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,
},
});

View 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,
},
});

View 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
View 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
View 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>
);
}

View 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',
},
});

View 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',
},
});

View 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',
},
});

View 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',
},
});

View 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',
},
});

View 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',
},
});

View 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} />;
}

View 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,
},
});

View 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,
]}
/>
);
}

View 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} />;
}

View 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
View 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
View 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
View 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,
};
}

View File

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

View File

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

320
hooks/use-template-run.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}

View 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
View 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

File diff suppressed because it is too large Load Diff

58
package.json Normal file
View 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
View 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
View 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
View 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)));
}