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