feat: 应用界面重构 - 从模板中心到AI内容生成平台

- 重构首页:从模板列表展示改为AI功能展示界面
- 更新底部导航:将explore改为content,index改为home
- 新增多个页面:积分、兑换、历史记录、结果展示、设置等
- 优化UI组件:新增通用按钮、分类标签、加载状态等组件
- 清理冗余文件:删除旧的认证文档和积分详情页面
- 更新主题配置:调整配色方案和视觉风格
- 修复导入路径:优化组件导入结构

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
imeepos
2025-10-31 17:53:56 +08:00
parent 668caaa91f
commit b9399bf4cf
38 changed files with 5641 additions and 1663 deletions

709
app/templates/[id]/form.tsx Normal file
View File

@@ -0,0 +1,709 @@
import React, { useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
Image,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { ArrowLeft, UploadCloud } from 'lucide-react';
import { Feather } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker';
import { DynamicForm } from '@/components/forms/dynamic-form';
import { getTemplateById } from '@/lib/api/templates';
import { Template } from '@/lib/types/template';
import { RunFormSchema, RunTemplateData } from '@/lib/types/template-run';
import { transformWorkflowToFormSchema, validateFormSchema } from '@/lib/utils/form-schema-transformer';
interface Step1FormData {
characterImage: string;
productImage: string;
script: string;
}
interface Step2FormData extends RunTemplateData {}
interface FormData {
step1: Step1FormData;
step2: Step2FormData;
}
type UploadFieldKey = 'characterImage' | 'productImage';
const FALLBACK_PREVIEW =
'https://images.unsplash.com/photo-1542204165-0198c1e21a3b?auto=format&fit=crop&w=1200&q=80';
const FALLBACK_INSET =
'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=640&q=80';
export default function TemplateFormScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const [currentStep, setCurrentStep] = useState(1);
const [template, setTemplate] = useState<Template | null>(null);
const [formSchema, setFormSchema] = useState<RunFormSchema>({ fields: [] });
const [loading, setLoading] = useState(true);
const [formData, setFormData] = useState<FormData>({
step1: {
characterImage: '',
productImage: '',
script: '',
},
step2: {},
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
loadInitialData();
}, [id]);
const loadInitialData = async () => {
try {
setLoading(true);
const templateResponse = await getTemplateById(id);
if (templateResponse.success && templateResponse.data) {
setTemplate(templateResponse.data);
if (templateResponse.data.formSchema) {
try {
const rawData =
typeof templateResponse.data.formSchema === 'string'
? JSON.parse(templateResponse.data.formSchema as string)
: templateResponse.data.formSchema;
const formConfig = transformWorkflowToFormSchema(rawData);
if (validateFormSchema(formConfig)) {
setFormSchema(formConfig);
}
} catch (error) {
console.error('Failed to parse form schema:', error);
}
}
}
} catch (error) {
console.error('Failed to load template:', error);
Alert.alert('Error', 'Failed to load data. Please retry later.');
} finally {
setLoading(false);
}
};
const validateStep1 = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.step1.characterImage) {
newErrors.characterImage = 'Please upload the character image';
}
if (!formData.step1.productImage) {
newErrors.productImage = 'Please upload the product image';
}
if (!formData.step1.script.trim()) {
newErrors.script = 'Please provide the character script';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleStep1Next = () => {
if (validateStep1()) {
setCurrentStep(2);
}
};
const handleStep2Prev = () => {
setCurrentStep(1);
};
const handleStep2Submit = async () => {
if (!template) return;
setIsSubmitting(true);
try {
const combinedData = {
...formData.step1,
...formData.step2,
};
console.log('Submitting template payload:', combinedData);
Alert.alert('Success', 'Template configuration saved.', [
{
text: 'OK',
onPress: () => {
router.push(`/templates/${id}/run`);
},
},
]);
} catch (error) {
console.error('Submission failed:', error);
Alert.alert('Error', 'Submission failed. Please retry later.');
} finally {
setIsSubmitting(false);
}
};
const handleImagePick = async (field: UploadFieldKey) => {
try {
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permission.granted) {
Alert.alert(
'Permission Required',
'Please allow photo library access in Settings to upload images.',
);
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
quality: 0.9,
});
if (result.canceled || !result.assets || result.assets.length === 0) {
return;
}
const uri = result.assets[0].uri;
setFormData(prev => ({
...prev,
step1: {
...prev.step1,
[field]: uri,
},
}));
if (errors[field]) {
setErrors(prev => ({
...prev,
[field]: '',
}));
}
} catch (error) {
console.error('Failed to pick image:', error);
Alert.alert('Error', 'Unable to pick image. Please try again later.');
}
};
const renderUploadCard = (
field: UploadFieldKey,
label: string,
description: string,
) => {
const value = formData.step1[field];
return (
<View style={styles.uploadItem}>
<TouchableOpacity
activeOpacity={0.9}
onPress={() => handleImagePick(field)}
style={[
styles.uploadCard,
value && styles.uploadCardFilled,
errors[field] && styles.uploadCardError,
]}
>
{value ? (
<Image source={{ uri: value }} style={styles.uploadImage} />
) : (
<>
<View style={styles.uploadIconWrap}>
<UploadCloud size={26} color="#D1FF00" strokeWidth={1.4} />
</View>
<Text style={styles.uploadLabel}>{label}</Text>
<Text style={styles.uploadDescription}>{description}</Text>
</>
)}
</TouchableOpacity>
{errors[field] ? (
<Text style={styles.inlineError}>{errors[field]}</Text>
) : null}
</View>
);
};
const renderStep1 = () => {
const heroImage = template?.previewUrl || template?.coverImageUrl || FALLBACK_PREVIEW;
const insetImage = template?.coverImageUrl || FALLBACK_INSET;
return (
<ScrollView
style={styles.stepScroll}
contentContainerStyle={styles.step1Content}
showsVerticalScrollIndicator={false}
>
<View style={styles.topBar}>
<TouchableOpacity
activeOpacity={0.85}
onPress={() => router.back()}
style={styles.backButton}
>
<ArrowLeft size={22} color="#FFFFFF" strokeWidth={2.4} />
</TouchableOpacity>
</View>
<Text style={styles.heroTitle}>
{(template?.title ?? 'Insert your product into an ASMR video').toUpperCase()}
</Text>
<Text style={styles.heroSubtitle}>
{template?.description ||
'ASMR-Add-On keeps your original image the same while seamlessly adding our uploaded product into the ASMR scene.'}
</Text>
<View style={styles.previewStage}>
<Image source={{ uri: heroImage }} style={styles.previewImage} />
<View style={styles.previewInset}>
<Image source={{ uri: insetImage }} style={styles.previewInsetImage} />
</View>
</View>
<View style={styles.uploadRow}>
{renderUploadCard(
'characterImage',
'UPLOAD CHARACTER IMAGE',
'PNG, JPG or Paste from clipboard',
)}
{renderUploadCard(
'productImage',
'UPLOAD CHARACTER IMAGE',
'PNG, JPG or Paste from clipboard',
)}
</View>
<View style={styles.promptField}>
<TextInput
style={[styles.promptInput, errors.script && styles.promptInputError]}
placeholder="What should your character say?"
placeholderTextColor="rgba(255, 255, 255, 0.5)"
value={formData.step1.script}
onChangeText={text => {
setFormData(prev => ({
...prev,
step1: {
...prev.step1,
script: text,
},
}));
if (errors.script) {
setErrors(prev => ({
...prev,
script: '',
}));
}
}}
multiline
textAlignVertical="top"
/>
{errors.script ? <Text style={styles.inlineError}>{errors.script}</Text> : null}
</View>
<TouchableOpacity
style={styles.generateButton}
activeOpacity={0.88}
onPress={handleStep1Next}
>
<Feather name="star" size={20} color="#050505" />
<Text style={styles.generateLabel}>Generate Video</Text>
</TouchableOpacity>
</ScrollView>
);
};
const renderStep2 = () => (
<View style={styles.step2Wrapper}>
<View style={styles.topBar}>
<TouchableOpacity
activeOpacity={0.85}
onPress={handleStep2Prev}
style={styles.backButton}
>
<ArrowLeft size={22} color="#FFFFFF" strokeWidth={2.4} />
</TouchableOpacity>
<Text style={styles.stepIndicator}>STEP 2 OF 2</Text>
</View>
<Text style={styles.step2Title}>Run Parameters</Text>
<Text style={styles.step2Subtitle}>
Fine-tune the details so the ASMR video delivers the mood you expect.
</Text>
<View style={styles.step2FormShell}>
{formSchema.fields.length > 0 ? (
<DynamicForm
schema={formSchema}
initialData={formData.step2}
onDataChange={data => {
setFormData(prev => ({
...prev,
step2: data,
}));
}}
/>
) : (
<View style={styles.emptyState}>
<Text style={styles.emptyIcon}>📋</Text>
<Text style={styles.emptyText}>No extra parameters</Text>
<Text style={styles.emptyDescription}>
This template is ready to gostart generating the ASMR experience instantly.
</Text>
</View>
)}
</View>
</View>
);
const renderBottomActions = () => {
if (currentStep !== 2) {
return null;
}
return (
<SafeAreaView edges={['bottom']} style={styles.bottomSafeArea}>
<View style={styles.bottomActions}>
<TouchableOpacity
style={styles.secondaryButton}
activeOpacity={0.85}
onPress={handleStep2Prev}
>
<Text style={styles.secondaryLabel}>Back</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.submitButton}
activeOpacity={0.9}
onPress={handleStep2Submit}
disabled={isSubmitting}
>
{isSubmitting ? (
<ActivityIndicator color="#050505" />
) : (
<>
<Feather name="zap" size={18} color="#050505" />
<Text style={styles.submitLabel}>Generate Video</Text>
</>
)}
</TouchableOpacity>
</View>
</SafeAreaView>
);
};
if (loading) {
return (
<View style={styles.loadingCanvas}>
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right']}>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#D1FF00" />
<Text style={styles.loadingText}>Loading...</Text>
</View>
</SafeAreaView>
</View>
);
}
return (
<View style={styles.canvas}>
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right']}>
{currentStep === 1 ? renderStep1() : renderStep2()}
</SafeAreaView>
{renderBottomActions()}
</View>
);
}
const styles = StyleSheet.create({
canvas: {
flex: 1,
backgroundColor: '#050505',
},
safeArea: {
flex: 1,
},
loadingCanvas: {
flex: 1,
backgroundColor: '#050505',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
marginTop: 16,
fontSize: 16,
color: 'rgba(255, 255, 255, 0.7)',
letterSpacing: 0.3,
},
stepScroll: {
flex: 1,
},
step1Content: {
paddingHorizontal: 24,
paddingBottom: 40,
},
topBar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 28,
},
backButton: {
width: 48,
height: 48,
borderRadius: 18,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
backgroundColor: 'rgba(18, 18, 18, 0.78)',
alignItems: 'center',
justifyContent: 'center',
},
stepIndicator: {
flex: 1,
textAlign: 'right',
fontSize: 13,
letterSpacing: 2,
color: 'rgba(255, 255, 255, 0.5)',
},
heroTitle: {
fontSize: 26,
lineHeight: 32,
fontWeight: '700',
color: '#FFFFFF',
letterSpacing: 0.8,
textTransform: 'uppercase',
marginBottom: 16,
},
heroSubtitle: {
fontSize: 14,
lineHeight: 20,
color: 'rgba(255, 255, 255, 0.68)',
marginBottom: 24,
},
previewStage: {
borderRadius: 32,
overflow: 'hidden',
backgroundColor: '#111318',
marginBottom: 28,
},
previewImage: {
width: '100%',
height: 220,
},
previewInset: {
position: 'absolute',
bottom: 18,
left: 18,
width: 74,
height: 74,
borderRadius: 24,
borderWidth: 2,
borderColor: '#050505',
backgroundColor: '#050505',
overflow: 'hidden',
},
previewInsetImage: {
width: '100%',
height: '100%',
},
uploadRow: {
flexDirection: 'row',
gap: 18,
marginBottom: 24,
},
uploadItem: {
flex: 1,
},
uploadCard: {
borderRadius: 28,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.08)',
backgroundColor: 'rgba(17, 19, 24, 0.95)',
paddingHorizontal: 20,
paddingVertical: 28,
alignItems: 'center',
justifyContent: 'center',
minHeight: 188,
overflow: 'hidden',
},
uploadCardFilled: {
paddingHorizontal: 0,
paddingVertical: 0,
},
uploadCardError: {
borderColor: '#FF6B6B',
},
uploadIconWrap: {
width: 60,
height: 60,
borderRadius: 22,
backgroundColor: 'rgba(209, 255, 0, 0.12)',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 16,
},
uploadLabel: {
fontSize: 14,
fontWeight: '700',
letterSpacing: 0.6,
color: '#FFFFFF',
textAlign: 'center',
textTransform: 'uppercase',
marginBottom: 6,
},
uploadDescription: {
fontSize: 12,
lineHeight: 16,
color: 'rgba(255, 255, 255, 0.55)',
textAlign: 'center',
},
uploadImage: {
width: '100%',
height: '100%',
resizeMode: 'cover',
},
promptField: {
marginBottom: 18,
},
promptInput: {
borderRadius: 28,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.08)',
backgroundColor: 'rgba(17, 19, 24, 0.95)',
minHeight: 140,
paddingHorizontal: 22,
paddingVertical: 22,
fontSize: 15,
lineHeight: 22,
color: '#FFFFFF',
},
promptInputError: {
borderColor: '#FF6B6B',
},
inlineError: {
marginTop: 8,
fontSize: 12,
color: '#FF6B6B',
letterSpacing: 0.2,
},
generateButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
height: 58,
borderRadius: 30,
backgroundColor: '#D1FF00',
gap: 10,
},
generateLabel: {
fontSize: 17,
fontWeight: '700',
letterSpacing: 0.4,
color: '#050505',
},
step2Wrapper: {
flex: 1,
paddingHorizontal: 24,
paddingBottom: 24,
},
step2Title: {
fontSize: 22,
fontWeight: '700',
color: '#FFFFFF',
letterSpacing: 0.6,
marginBottom: 10,
},
step2Subtitle: {
fontSize: 13,
lineHeight: 18,
color: 'rgba(255, 255, 255, 0.6)',
marginBottom: 20,
},
step2FormShell: {
flex: 1,
borderRadius: 32,
backgroundColor: 'rgba(17, 19, 24, 0.78)',
overflow: 'hidden',
},
emptyState: {
flex: 1,
paddingHorizontal: 32,
alignItems: 'center',
justifyContent: 'center',
},
emptyIcon: {
fontSize: 48,
marginBottom: 16,
color: '#FFFFFF',
},
emptyText: {
fontSize: 18,
fontWeight: '600',
color: '#FFFFFF',
marginBottom: 8,
},
emptyDescription: {
fontSize: 14,
lineHeight: 20,
color: 'rgba(255, 255, 255, 0.6)',
textAlign: 'center',
},
bottomSafeArea: {
backgroundColor: '#050505',
},
bottomActions: {
flexDirection: 'row',
gap: 16,
paddingHorizontal: 24,
paddingTop: 12,
paddingBottom: 24,
borderTopWidth: 1,
borderTopColor: 'rgba(255, 255, 255, 0.08)',
},
secondaryButton: {
flex: 1,
height: 56,
borderRadius: 28,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.2)',
alignItems: 'center',
justifyContent: 'center',
},
secondaryLabel: {
fontSize: 15,
fontWeight: '600',
color: 'rgba(255, 255, 255, 0.78)',
letterSpacing: 0.2,
},
submitButton: {
flex: 1.6,
height: 56,
borderRadius: 28,
backgroundColor: '#D1FF00',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 10,
},
submitLabel: {
fontSize: 16,
fontWeight: '700',
color: '#050505',
letterSpacing: 0.3,
},
});