From c00a8c6ff6461d9f786c18704cf42edcf2973173 Mon Sep 17 00:00:00 2001
From: baizhi958216 <1475289190@qq.com>
Date: Tue, 30 Jun 2026 13:16:15 +0800
Subject: [PATCH] feat: build mobile app shell
Signed-off-by: baizhi958216 <1475289190@qq.com>
---
metro.config.js | 20 +
package.json | 2 +
src/app/create.tsx | 342 ++++++++++++++++
src/app/explore.tsx | 180 ---------
src/app/index.tsx | 343 ++++++++++++----
src/app/profile.tsx | 676 ++++++++++++++++++++++++++++++++
src/components/app-tabs.tsx | 38 +-
src/components/app-tabs.web.tsx | 163 ++++----
src/lib/model-config.ts | 56 +++
tsconfig.json | 6 +
10 files changed, 1474 insertions(+), 352 deletions(-)
create mode 100644 metro.config.js
create mode 100644 src/app/create.tsx
delete mode 100644 src/app/explore.tsx
create mode 100644 src/app/profile.tsx
create mode 100644 src/lib/model-config.ts
diff --git a/metro.config.js b/metro.config.js
new file mode 100644
index 0000000..47d6a08
--- /dev/null
+++ b/metro.config.js
@@ -0,0 +1,20 @@
+const path = require('path');
+const { getDefaultConfig } = require('expo/metro-config');
+
+const projectRoot = __dirname;
+const workspaceRoot = path.resolve(projectRoot, '..');
+
+const config = getDefaultConfig(projectRoot);
+
+config.watchFolders = [workspaceRoot];
+config.resolver.alias = {
+ ...config.resolver.alias,
+ '@infiplot/core': path.resolve(workspaceRoot, 'packages/core/src/index.ts'),
+ '@infiplot/types': path.resolve(workspaceRoot, 'packages/types/src/index.ts'),
+};
+config.resolver.nodeModulesPaths = [
+ path.resolve(projectRoot, 'node_modules'),
+ path.resolve(workspaceRoot, 'node_modules'),
+];
+
+module.exports = config;
diff --git a/package.json b/package.json
index 3a75f4a..a350647 100644
--- a/package.json
+++ b/package.json
@@ -4,6 +4,8 @@
"version": "1.0.0",
"dependencies": {
"@expo/ui": "~56.0.18",
+ "@infiplot/core": "workspace:*",
+ "@infiplot/types": "workspace:*",
"expo": "~56.0.12",
"expo-constants": "~56.0.18",
"expo-device": "~56.0.4",
diff --git a/src/app/create.tsx b/src/app/create.tsx
new file mode 100644
index 0000000..7a468b3
--- /dev/null
+++ b/src/app/create.tsx
@@ -0,0 +1,342 @@
+import { creationOptions, defaultStoryDraft } from '@infiplot/core';
+import { SymbolView } from 'expo-symbols';
+import { useState } from 'react';
+import {
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Text,
+ TextInput,
+ View,
+} from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+
+import { BottomTabInset } from '@/constants/theme';
+
+export default function CreateScreen() {
+ const [world, setWorld] = useState(defaultStoryDraft.worldSetting);
+ const [style, setStyle] = useState(defaultStoryDraft.styleGuide);
+ const [template, setTemplate] = useState(creationOptions.templates[1]);
+ const [ratio, setRatio] = useState(creationOptions.ratios[0]);
+ const [rhythm, setRhythm] = useState(creationOptions.rhythms[0]);
+
+ const ready = world.trim().length > 10 && style.trim().length > 5;
+
+ return (
+
+
+
+
+ AI 剧情创作
+ 写下两段文字,生成可刷的短剧。
+
+
+
+
+
+
+
+ 题材模板
+
+
+
+
+
+ 世界设定
+ {world.length}
+
+
+
+
+
+
+ 视觉风格
+ {style.length}
+
+
+
+
+
+
+ 画面比例
+
+
+
+ 叙事节奏
+
+
+
+
+
+ 生成流程
+
+ {['故事圣经', '分镜脚本', '角色视觉', '首集预览'].map((step, index) => (
+
+ {index + 1}
+ {step}
+
+ ))}
+
+
+
+
+
+ [
+ styles.generateButton,
+ !ready && styles.disabledButton,
+ pressed && ready && styles.pressed,
+ ]}>
+
+ {ready ? '生成第一集' : '继续补充设定'}
+
+
+
+ );
+}
+
+function ChipRow({
+ options,
+ value,
+ onChange,
+ compact,
+}: {
+ options: readonly string[];
+ value: string;
+ onChange: (value: string) => void;
+ compact?: boolean;
+}) {
+ return (
+
+ {options.map((option) => {
+ const selected = option === value;
+ return (
+ onChange(option)}
+ style={({ pressed }) => [
+ styles.chip,
+ compact && styles.compactChip,
+ selected && styles.selectedChip,
+ pressed && styles.pressed,
+ ]}>
+ {option}
+
+ );
+ })}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ safe: {
+ flex: 1,
+ backgroundColor: '#09090b',
+ },
+ scroll: {
+ flex: 1,
+ },
+ content: {
+ padding: 18,
+ paddingBottom: BottomTabInset + 112,
+ gap: 18,
+ },
+ header: {
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ justifyContent: 'space-between',
+ gap: 18,
+ paddingTop: 8,
+ },
+ eyebrow: {
+ color: '#ff4d6d',
+ fontSize: 12,
+ fontWeight: '900',
+ marginBottom: 8,
+ },
+ title: {
+ color: '#fff',
+ fontSize: 30,
+ lineHeight: 36,
+ fontWeight: '900',
+ maxWidth: 300,
+ },
+ headerIcon: {
+ width: 46,
+ height: 46,
+ borderRadius: 23,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: 'rgba(255,255,255,0.12)',
+ },
+ section: {
+ gap: 10,
+ },
+ label: {
+ color: 'rgba(255,255,255,0.7)',
+ fontSize: 13,
+ fontWeight: '900',
+ },
+ chips: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: 10,
+ },
+ compactChips: {
+ gap: 8,
+ },
+ chip: {
+ minHeight: 38,
+ borderRadius: 19,
+ paddingHorizontal: 14,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: 'rgba(255,255,255,0.09)',
+ borderWidth: 1,
+ borderColor: 'rgba(255,255,255,0.08)',
+ },
+ compactChip: {
+ minHeight: 32,
+ paddingHorizontal: 10,
+ },
+ selectedChip: {
+ backgroundColor: '#fff',
+ borderColor: '#fff',
+ },
+ chipText: {
+ color: '#fff',
+ fontSize: 13,
+ fontWeight: '800',
+ },
+ selectedChipText: {
+ color: '#09090b',
+ },
+ fieldBlock: {
+ gap: 10,
+ },
+ fieldHeader: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ },
+ counter: {
+ color: 'rgba(255,255,255,0.42)',
+ fontSize: 12,
+ fontWeight: '800',
+ },
+ textArea: {
+ minHeight: 158,
+ borderRadius: 8,
+ padding: 14,
+ color: '#fff',
+ fontSize: 16,
+ lineHeight: 24,
+ fontWeight: '600',
+ backgroundColor: 'rgba(255,255,255,0.08)',
+ borderWidth: 1,
+ borderColor: 'rgba(255,255,255,0.1)',
+ },
+ shortArea: {
+ minHeight: 116,
+ },
+ twoColumns: {
+ flexDirection: 'row',
+ gap: 12,
+ },
+ optionCard: {
+ flex: 1,
+ gap: 12,
+ borderRadius: 8,
+ padding: 12,
+ backgroundColor: 'rgba(255,255,255,0.07)',
+ },
+ previewCard: {
+ borderRadius: 8,
+ padding: 14,
+ gap: 12,
+ backgroundColor: 'rgba(255,255,255,0.07)',
+ },
+ previewTitle: {
+ color: '#fff',
+ fontSize: 15,
+ fontWeight: '900',
+ },
+ steps: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: 10,
+ },
+ step: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ minWidth: '46%',
+ },
+ stepIndex: {
+ width: 22,
+ height: 22,
+ borderRadius: 11,
+ overflow: 'hidden',
+ textAlign: 'center',
+ lineHeight: 22,
+ color: '#09090b',
+ backgroundColor: '#b8ff5d',
+ fontSize: 12,
+ fontWeight: '900',
+ },
+ stepText: {
+ color: 'rgba(255,255,255,0.82)',
+ fontSize: 13,
+ fontWeight: '800',
+ },
+ footer: {
+ position: 'absolute',
+ left: 0,
+ right: 0,
+ bottom: 0,
+ paddingHorizontal: 18,
+ paddingTop: 12,
+ paddingBottom: BottomTabInset + 14,
+ backgroundColor: 'rgba(9,9,11,0.96)',
+ },
+ generateButton: {
+ height: 52,
+ borderRadius: 26,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 8,
+ backgroundColor: '#fff',
+ },
+ disabledButton: {
+ opacity: 0.42,
+ },
+ generateText: {
+ color: '#050505',
+ fontSize: 16,
+ fontWeight: '900',
+ },
+ pressed: {
+ opacity: 0.76,
+ },
+});
diff --git a/src/app/explore.tsx b/src/app/explore.tsx
deleted file mode 100644
index 2934085..0000000
--- a/src/app/explore.tsx
+++ /dev/null
@@ -1,180 +0,0 @@
-import { Image } from 'expo-image';
-import { SymbolView } from 'expo-symbols';
-import { Platform, Pressable, ScrollView, StyleSheet } from 'react-native';
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
-
-import { ExternalLink } from '@/components/external-link';
-import { ThemedText } from '@/components/themed-text';
-import { ThemedView } from '@/components/themed-view';
-import { Collapsible } from '@/components/ui/collapsible';
-import { WebBadge } from '@/components/web-badge';
-import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme';
-import { useTheme } from '@/hooks/use-theme';
-
-export default function TabTwoScreen() {
- const safeAreaInsets = useSafeAreaInsets();
- const insets = {
- ...safeAreaInsets,
- bottom: safeAreaInsets.bottom + BottomTabInset + Spacing.three,
- };
- const theme = useTheme();
-
- const contentPlatformStyle = Platform.select({
- android: {
- paddingTop: insets.top,
- paddingLeft: insets.left,
- paddingRight: insets.right,
- paddingBottom: insets.bottom,
- },
- web: {
- paddingTop: Spacing.six,
- paddingBottom: Spacing.four,
- },
- });
-
- return (
-
-
-
- Explore
-
- This starter app includes example{'\n'}code to help you get started.
-
-
-
- pressed && styles.pressed}>
-
- Expo documentation
-
-
-
-
-
-
-
-
-
- This app has two screens: src/app/index.tsx and{' '}
- src/app/explore.tsx
-
-
- The layout file in src/app/_layout.tsx sets up
- the tab navigator.
-
-
- Learn more
-
-
-
-
-
-
- You can open this project on Android, iOS, and the web. To open the web version,
- press w in the terminal running this
- project.
-
-
-
-
-
-
-
- For static images, you can use the @2x and{' '}
- @3x suffixes to provide files for different
- screen densities.
-
-
-
- Learn more
-
-
-
-
-
- This template has light and dark mode support. The{' '}
- useColorScheme() hook lets you inspect what the
- user's current color scheme is, and so you can adjust UI colors accordingly.
-
-
- Learn more
-
-
-
-
-
- This template includes an example of an animated component. The{' '}
- src/components/ui/collapsible.tsx component uses
- the powerful react-native-reanimated library to
- animate opening this hint.
-
-
-
- {Platform.OS === 'web' && }
-
-
- );
-}
-
-const styles = StyleSheet.create({
- scrollView: {
- flex: 1,
- },
- contentContainer: {
- flexDirection: 'row',
- justifyContent: 'center',
- },
- container: {
- maxWidth: MaxContentWidth,
- flexGrow: 1,
- },
- titleContainer: {
- gap: Spacing.three,
- alignItems: 'center',
- paddingHorizontal: Spacing.four,
- paddingVertical: Spacing.six,
- },
- centerText: {
- textAlign: 'center',
- },
- pressed: {
- opacity: 0.7,
- },
- linkButton: {
- flexDirection: 'row',
- paddingHorizontal: Spacing.four,
- paddingVertical: Spacing.two,
- borderRadius: Spacing.five,
- justifyContent: 'center',
- gap: Spacing.one,
- alignItems: 'center',
- },
- sectionsWrapper: {
- gap: Spacing.five,
- paddingHorizontal: Spacing.four,
- paddingTop: Spacing.three,
- },
- collapsibleContent: {
- alignItems: 'center',
- },
- imageTutorial: {
- width: '100%',
- aspectRatio: 296 / 171,
- borderRadius: Spacing.three,
- marginTop: Spacing.two,
- },
- imageReact: {
- width: 100,
- height: 100,
- alignSelf: 'center',
- },
-});
diff --git a/src/app/index.tsx b/src/app/index.tsx
index 8ec3e6f..4bef814 100644
--- a/src/app/index.tsx
+++ b/src/app/index.tsx
@@ -1,98 +1,295 @@
-import * as Device from 'expo-device';
-import { Platform, StyleSheet } from 'react-native';
-import { SafeAreaView } from 'react-native-safe-area-context';
+import { mobileDramaFeed } from '@infiplot/core';
+import type { MobileDrama } from '@infiplot/types';
+import { SymbolView } from 'expo-symbols';
+import type { ComponentProps } from 'react';
+import { useMemo, useRef, useState } from 'react';
+import {
+ Dimensions,
+ FlatList,
+ Pressable,
+ StyleSheet,
+ Text,
+ View,
+ type NativeScrollEvent,
+ type NativeSyntheticEvent,
+} from 'react-native';
+import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
-import { AnimatedIcon } from '@/components/animated-icon';
-import { HintRow } from '@/components/hint-row';
-import { ThemedText } from '@/components/themed-text';
-import { ThemedView } from '@/components/themed-view';
-import { WebBadge } from '@/components/web-badge';
-import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme';
+import { BottomTabInset } from '@/constants/theme';
-function getDevMenuHint() {
- if (Platform.OS === 'web') {
- return use browser devtools;
+type SymbolName = ComponentProps['name'];
+
+export default function FeedScreen() {
+ const { height } = Dimensions.get('window');
+ const insets = useSafeAreaInsets();
+ const pageHeight = height;
+ const [activeIndex, setActiveIndex] = useState(0);
+ const listRef = useRef>(null);
+
+ const snapOffsets = useMemo(
+ () => mobileDramaFeed.map((_, index) => index * pageHeight),
+ [pageHeight],
+ );
+
+ function handleScrollEnd(event: NativeSyntheticEvent) {
+ const nextIndex = Math.round(event.nativeEvent.contentOffset.y / pageHeight);
+ setActiveIndex(Math.max(0, Math.min(mobileDramaFeed.length - 1, nextIndex)));
}
- if (Device.isDevice) {
- return (
-
- shake device or press m in terminal
-
- );
- }
- const shortcut = Platform.OS === 'android' ? 'cmd+m (or ctrl+m)' : 'cmd+d';
+
return (
-
- press {shortcut}
-
+
+ item.id}
+ pagingEnabled
+ snapToOffsets={snapOffsets}
+ decelerationRate="fast"
+ showsVerticalScrollIndicator={false}
+ onMomentumScrollEnd={handleScrollEnd}
+ renderItem={({ item, index }) => (
+
+ )}
+ />
+
);
}
-export default function HomeScreen() {
+function DramaPage({
+ drama,
+ active,
+ height,
+ bottomInset,
+}: {
+ drama: MobileDrama;
+ active: boolean;
+ height: number;
+ bottomInset: number;
+}) {
return (
-
-
-
-
-
- Welcome to Expo
-
-
+
+
+
+ {drama.title}
+ {drama.episode}
+
-
- get started
-
+
+
+ InfiPlot
+
+
+ 刷剧
+
+
-
- src/app/index.tsx}
- />
-
- npm run reset-project}
- />
-
+
+
+
+
+
- {Platform.OS === 'web' && }
+
+ @{drama.creator}
+ {drama.title}
+ {drama.hook}
+
+ {drama.tags.map((tag) => (
+
+ #{tag}
+
+ ))}
+
+
+ [styles.primaryButton, pressed && styles.pressed]}>
+
+ 继续看
+
+ {drama.episode}
+
+
-
+
+ );
+}
+
+function ActionButton({ name, label }: { name: SymbolName; label: string }) {
+ return (
+ [styles.actionButton, pressed && styles.pressed]}>
+
+
+
+ {label}
+
);
}
const styles = StyleSheet.create({
- container: {
+ root: {
flex: 1,
+ backgroundColor: '#000',
+ },
+ page: {
+ overflow: 'hidden',
+ },
+ posterGlow: {
+ position: 'absolute',
+ width: 520,
+ height: 520,
+ borderRadius: 260,
+ top: '14%',
+ left: -120,
+ opacity: 0.5,
+ transform: [{ rotate: '-18deg' }],
+ },
+ posterOrb: {
+ position: 'absolute',
+ top: '18%',
+ alignSelf: 'center',
+ width: '76%',
+ aspectRatio: 0.72,
+ borderWidth: 1,
+ borderRadius: 28,
justifyContent: 'center',
- flexDirection: 'row',
- },
- safeArea: {
- flex: 1,
- paddingHorizontal: Spacing.four,
alignItems: 'center',
- gap: Spacing.three,
- paddingBottom: BottomTabInset + Spacing.three,
- maxWidth: MaxContentWidth,
+ padding: 24,
+ backgroundColor: 'rgba(255,255,255,0.08)',
},
- heroSection: {
- alignItems: 'center',
- justifyContent: 'center',
- flex: 1,
- paddingHorizontal: Spacing.four,
- gap: Spacing.four,
- },
- title: {
+ posterTitle: {
+ fontSize: 42,
+ lineHeight: 48,
+ fontWeight: '800',
textAlign: 'center',
},
- code: {
- textTransform: 'uppercase',
+ posterSubtitle: {
+ color: 'rgba(255,255,255,0.72)',
+ marginTop: 14,
+ fontSize: 13,
+ fontWeight: '700',
},
- stepContainer: {
- gap: Spacing.three,
- alignSelf: 'stretch',
- paddingHorizontal: Spacing.three,
- paddingVertical: Spacing.four,
- borderRadius: Spacing.four,
+ safe: {
+ flex: 1,
+ },
+ topBar: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingHorizontal: 18,
+ paddingTop: 6,
+ },
+ brand: {
+ color: '#fff',
+ fontSize: 20,
+ fontWeight: '900',
+ },
+ livePill: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 6,
+ paddingHorizontal: 12,
+ paddingVertical: 7,
+ borderRadius: 99,
+ backgroundColor: 'rgba(0,0,0,0.32)',
+ },
+ liveDot: {
+ width: 7,
+ height: 7,
+ borderRadius: 7,
+ },
+ liveText: {
+ color: '#fff',
+ fontSize: 12,
+ fontWeight: '800',
+ },
+ sideRail: {
+ position: 'absolute',
+ right: 14,
+ gap: 18,
+ alignItems: 'center',
+ },
+ actionButton: {
+ alignItems: 'center',
+ gap: 6,
+ },
+ actionIcon: {
+ width: 48,
+ height: 48,
+ borderRadius: 24,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: 'rgba(255,255,255,0.16)',
+ },
+ actionLabel: {
+ color: '#fff',
+ fontSize: 11,
+ fontWeight: '800',
+ },
+ infoPanel: {
+ marginTop: 'auto',
+ paddingLeft: 18,
+ paddingRight: 82,
+ gap: 10,
+ },
+ creator: {
+ color: '#fff',
+ fontSize: 15,
+ fontWeight: '900',
+ },
+ title: {
+ color: '#fff',
+ fontSize: 30,
+ lineHeight: 36,
+ fontWeight: '900',
+ },
+ hook: {
+ color: 'rgba(255,255,255,0.88)',
+ fontSize: 15,
+ lineHeight: 22,
+ fontWeight: '600',
+ },
+ tags: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: 8,
+ },
+ tag: {
+ color: '#fff',
+ fontSize: 13,
+ fontWeight: '800',
+ },
+ playRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 12,
+ marginTop: 4,
+ },
+ primaryButton: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 6,
+ paddingHorizontal: 18,
+ height: 42,
+ borderRadius: 21,
+ backgroundColor: '#fff',
+ },
+ primaryButtonText: {
+ color: '#101010',
+ fontSize: 14,
+ fontWeight: '900',
+ },
+ episode: {
+ color: 'rgba(255,255,255,0.78)',
+ fontSize: 12,
+ fontWeight: '700',
+ flexShrink: 1,
+ },
+ pressed: {
+ opacity: 0.75,
},
});
diff --git a/src/app/profile.tsx b/src/app/profile.tsx
new file mode 100644
index 0000000..bb8006d
--- /dev/null
+++ b/src/app/profile.tsx
@@ -0,0 +1,676 @@
+import type { ModelConfig, ModelConfigMap, ModelRole } from '@infiplot/types';
+import { SymbolView } from 'expo-symbols';
+import type { ComponentProps } from 'react';
+import { useEffect, useMemo, useState } from 'react';
+import {
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Switch,
+ Text,
+ TextInput,
+ View,
+} from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+
+import { BottomTabInset } from '@/constants/theme';
+import {
+ clearModelConfig,
+ defaultModelConfig,
+ readModelConfig,
+ writeModelConfig,
+} from '@/lib/model-config';
+
+type SymbolName = ComponentProps['name'];
+
+const modelGroups: {
+ id: ModelRole;
+ title: string;
+ hint: string;
+ accent: string;
+}[] = [
+ { id: 'text', title: '文本模型', hint: '剧情、分镜、选择分支', accent: '#ff4d6d' },
+ { id: 'image', title: '图像模型', hint: '角色、场景、封面生成', accent: '#b8ff5d' },
+ { id: 'vision', title: '视觉理解', hint: '风格图解析、画面标注', accent: '#78d7ff' },
+ { id: 'tts', title: '语音模型', hint: '对白配音、旁白', accent: '#ffd166' },
+];
+
+const roleLabels: Record = {
+ text: '文本',
+ image: '图像',
+ vision: '视觉',
+ tts: '语音',
+};
+
+export default function ProfileScreen() {
+ const [syncEnabled, setSyncEnabled] = useState(true);
+ const [showKeys, setShowKeys] = useState(false);
+ const [signedIn, setSignedIn] = useState(false);
+ const [email, setEmail] = useState('');
+ const [selectedRole, setSelectedRole] = useState('text');
+ const [configs, setConfigs] = useState(defaultModelConfig);
+ const [savedConfigs, setSavedConfigs] = useState(defaultModelConfig);
+ const [status, setStatus] = useState<'loading' | 'saved' | 'dirty'>('loading');
+
+ useEffect(() => {
+ let mounted = true;
+
+ readModelConfig().then((stored) => {
+ if (!mounted) return;
+ setConfigs(stored);
+ setSavedConfigs(stored);
+ setStatus('saved');
+ });
+
+ return () => {
+ mounted = false;
+ };
+ }, []);
+
+ const selectedGroup = modelGroups.find((group) => group.id === selectedRole) ?? modelGroups[0];
+ const selectedConfig = configs[selectedRole];
+
+ const configuredCount = useMemo(
+ () => Object.values(configs).filter((config) => config.apiKey.trim().length > 0).length,
+ [configs],
+ );
+
+ const dirty = useMemo(
+ () => JSON.stringify(configs) !== JSON.stringify(savedConfigs),
+ [configs, savedConfigs],
+ );
+
+ function updateConfig(field: keyof ModelConfig, value: string) {
+ setConfigs((current) => ({
+ ...current,
+ [selectedRole]: {
+ ...current[selectedRole],
+ [field]: value,
+ },
+ }));
+ setStatus('dirty');
+ }
+
+ async function handleSave() {
+ const saved = await writeModelConfig(configs);
+ setConfigs(saved);
+ setSavedConfigs(saved);
+ setStatus('saved');
+ }
+
+ async function handleReset() {
+ const reset = await clearModelConfig();
+ setConfigs(reset);
+ setSavedConfigs(reset);
+ setSelectedRole('text');
+ setStatus('saved');
+ }
+
+ function handleAuthAction() {
+ if (signedIn) {
+ setSignedIn(false);
+ setEmail('');
+ return;
+ }
+
+ if (email.trim().length > 3) {
+ setSignedIn(true);
+ }
+ }
+
+ return (
+
+
+
+
+ IP
+
+
+ {signedIn ? 'InfiPlot 创作者' : '未登录创作者'}
+
+ {signedIn ? email.trim() : '登录后同步我的剧情、草稿和模型配置。'}
+
+
+ [styles.loginButton, pressed && styles.pressed]}>
+ {signedIn ? '退出' : '登录'}
+
+
+
+ {!signedIn && (
+
+
+ [
+ styles.loginSubmit,
+ email.trim().length <= 3 && styles.disabledButton,
+ pressed && email.trim().length > 3 && styles.pressed,
+ ]}>
+ 继续
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ 模型 API
+ 对应 web 端文字、图片、视觉和 TTS 配置。
+
+ setShowKeys((value) => !value)}
+ style={({ pressed }) => [styles.iconButton, pressed && styles.pressed]}>
+
+
+
+
+
+ {modelGroups.map((group) => {
+ const selected = group.id === selectedRole;
+ const configured = configs[group.id].apiKey.trim().length > 0;
+
+ return (
+ setSelectedRole(group.id)}
+ style={({ pressed }) => [
+ styles.roleTab,
+ selected && styles.selectedRoleTab,
+ pressed && styles.pressed,
+ ]}>
+
+
+ {roleLabels[group.id]}
+
+
+ );
+ })}
+
+
+
+
+
+ {selectedGroup.title}
+ {selectedGroup.hint}
+
+
+
+ {status === 'loading' ? '读取中' : dirty ? '未保存' : '已保存'}
+
+
+
+
+ updateConfig('provider', value)}
+ placeholder="openai / claude / gemini / stepfun"
+ />
+ updateConfig('baseUrl', value)}
+ placeholder="https://api.example.com/v1"
+ />
+ updateConfig('apiKey', value)}
+ secureTextEntry={!showKeys}
+ placeholder="sk-..."
+ />
+ updateConfig('model', value)}
+ placeholder="model name"
+ />
+
+
+ [styles.secondaryButton, pressed && styles.pressed]}>
+ 重置
+
+ [
+ styles.saveButton,
+ !dirty && styles.disabledButton,
+ pressed && dirty && styles.pressed,
+ ]}>
+
+ 保存配置
+
+
+
+
+
+
+ 云端同步
+ 登录后自动同步我的剧情和配置。
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function Stat({ value, label }: { value: string; label: string }) {
+ return (
+
+ {value}
+ {label}
+
+ );
+}
+
+function InputLine({
+ label,
+ value,
+ onChangeText,
+ secureTextEntry,
+ placeholder,
+}: {
+ label: string;
+ value: string;
+ onChangeText: (value: string) => void;
+ secureTextEntry?: boolean;
+ placeholder: string;
+}) {
+ return (
+
+ {label}
+
+
+ );
+}
+
+function MenuItem({ name, label }: { name: SymbolName; label: string }) {
+ return (
+ [styles.menuItem, pressed && styles.pressed]}>
+
+
+
+ {label}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ safe: {
+ flex: 1,
+ backgroundColor: '#09090b',
+ },
+ content: {
+ padding: 18,
+ paddingBottom: BottomTabInset + 34,
+ gap: 18,
+ },
+ profileCard: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 12,
+ borderRadius: 8,
+ padding: 14,
+ backgroundColor: 'rgba(255,255,255,0.08)',
+ },
+ avatar: {
+ width: 54,
+ height: 54,
+ borderRadius: 27,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: '#ff4d6d',
+ },
+ avatarText: {
+ color: '#fff',
+ fontSize: 18,
+ fontWeight: '900',
+ },
+ profileCopy: {
+ flex: 1,
+ gap: 3,
+ },
+ name: {
+ color: '#fff',
+ fontSize: 18,
+ fontWeight: '900',
+ },
+ subline: {
+ color: 'rgba(255,255,255,0.58)',
+ fontSize: 12,
+ lineHeight: 17,
+ fontWeight: '700',
+ },
+ loginButton: {
+ height: 36,
+ paddingHorizontal: 16,
+ borderRadius: 18,
+ justifyContent: 'center',
+ backgroundColor: '#fff',
+ },
+ loginText: {
+ color: '#09090b',
+ fontSize: 13,
+ fontWeight: '900',
+ },
+ loginPanel: {
+ flexDirection: 'row',
+ gap: 10,
+ borderRadius: 8,
+ padding: 12,
+ backgroundColor: 'rgba(255,255,255,0.06)',
+ },
+ loginInput: {
+ flex: 1,
+ minHeight: 42,
+ borderRadius: 8,
+ paddingHorizontal: 12,
+ color: '#fff',
+ fontSize: 13,
+ fontWeight: '700',
+ backgroundColor: 'rgba(0,0,0,0.22)',
+ borderWidth: 1,
+ borderColor: 'rgba(255,255,255,0.08)',
+ },
+ loginSubmit: {
+ minWidth: 70,
+ height: 42,
+ borderRadius: 21,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: '#fff',
+ },
+ loginSubmitText: {
+ color: '#09090b',
+ fontSize: 13,
+ fontWeight: '900',
+ },
+ stats: {
+ flexDirection: 'row',
+ borderRadius: 8,
+ paddingVertical: 14,
+ backgroundColor: 'rgba(255,255,255,0.06)',
+ },
+ stat: {
+ flex: 1,
+ alignItems: 'center',
+ gap: 4,
+ },
+ statValue: {
+ color: '#fff',
+ fontSize: 20,
+ fontWeight: '900',
+ },
+ statLabel: {
+ color: 'rgba(255,255,255,0.54)',
+ fontSize: 12,
+ fontWeight: '800',
+ },
+ sectionHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: 16,
+ },
+ sectionTitle: {
+ color: '#fff',
+ fontSize: 23,
+ fontWeight: '900',
+ },
+ sectionHint: {
+ color: 'rgba(255,255,255,0.56)',
+ fontSize: 12,
+ lineHeight: 18,
+ fontWeight: '700',
+ marginTop: 4,
+ },
+ iconButton: {
+ width: 42,
+ height: 42,
+ borderRadius: 21,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: 'rgba(255,255,255,0.1)',
+ },
+ roleTabs: {
+ flexDirection: 'row',
+ gap: 8,
+ },
+ roleTab: {
+ flex: 1,
+ minHeight: 42,
+ borderRadius: 8,
+ alignItems: 'center',
+ justifyContent: 'center',
+ flexDirection: 'row',
+ gap: 6,
+ backgroundColor: 'rgba(255,255,255,0.07)',
+ borderWidth: 1,
+ borderColor: 'rgba(255,255,255,0.08)',
+ },
+ selectedRoleTab: {
+ backgroundColor: 'rgba(255,255,255,0.14)',
+ borderColor: 'rgba(255,255,255,0.2)',
+ },
+ roleDot: {
+ width: 7,
+ height: 7,
+ borderRadius: 7,
+ },
+ roleText: {
+ color: 'rgba(255,255,255,0.58)',
+ fontSize: 12,
+ fontWeight: '900',
+ },
+ selectedRoleText: {
+ color: '#fff',
+ },
+ modelEditor: {
+ gap: 12,
+ borderRadius: 8,
+ padding: 14,
+ backgroundColor: 'rgba(255,255,255,0.075)',
+ borderWidth: 1,
+ borderColor: 'rgba(255,255,255,0.08)',
+ },
+ modelTop: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ gap: 12,
+ },
+ modelTitle: {
+ color: '#fff',
+ fontSize: 17,
+ fontWeight: '900',
+ },
+ modelHint: {
+ color: 'rgba(255,255,255,0.52)',
+ fontSize: 12,
+ fontWeight: '700',
+ marginTop: 3,
+ },
+ statusPill: {
+ height: 26,
+ justifyContent: 'center',
+ borderRadius: 13,
+ paddingHorizontal: 10,
+ backgroundColor: 'rgba(184,255,93,0.16)',
+ },
+ dirtyPill: {
+ backgroundColor: 'rgba(255,209,102,0.16)',
+ },
+ statusText: {
+ color: '#b8ff5d',
+ fontSize: 11,
+ fontWeight: '900',
+ },
+ dirtyText: {
+ color: '#ffd166',
+ },
+ inputLine: {
+ gap: 6,
+ },
+ inputLabel: {
+ color: 'rgba(255,255,255,0.5)',
+ fontSize: 11,
+ fontWeight: '900',
+ },
+ input: {
+ minHeight: 42,
+ borderRadius: 8,
+ paddingHorizontal: 12,
+ color: '#fff',
+ fontSize: 13,
+ fontWeight: '700',
+ backgroundColor: 'rgba(0,0,0,0.22)',
+ borderWidth: 1,
+ borderColor: 'rgba(255,255,255,0.08)',
+ },
+ actionRow: {
+ flexDirection: 'row',
+ gap: 10,
+ marginTop: 2,
+ },
+ secondaryButton: {
+ height: 44,
+ borderRadius: 22,
+ paddingHorizontal: 18,
+ justifyContent: 'center',
+ backgroundColor: 'rgba(255,255,255,0.1)',
+ },
+ secondaryText: {
+ color: '#fff',
+ fontSize: 14,
+ fontWeight: '900',
+ },
+ saveButton: {
+ flex: 1,
+ height: 44,
+ borderRadius: 22,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 7,
+ backgroundColor: '#fff',
+ },
+ disabledButton: {
+ opacity: 0.42,
+ },
+ saveText: {
+ color: '#09090b',
+ fontSize: 14,
+ fontWeight: '900',
+ },
+ settingRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: 18,
+ borderRadius: 8,
+ padding: 14,
+ backgroundColor: 'rgba(255,255,255,0.07)',
+ },
+ settingTextBlock: {
+ flex: 1,
+ gap: 3,
+ },
+ settingTitle: {
+ color: '#fff',
+ fontSize: 15,
+ fontWeight: '900',
+ },
+ settingHint: {
+ color: 'rgba(255,255,255,0.52)',
+ fontSize: 12,
+ fontWeight: '700',
+ },
+ menuList: {
+ borderRadius: 8,
+ overflow: 'hidden',
+ backgroundColor: 'rgba(255,255,255,0.07)',
+ },
+ menuItem: {
+ minHeight: 56,
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 12,
+ paddingHorizontal: 14,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderBottomColor: 'rgba(255,255,255,0.08)',
+ },
+ menuIcon: {
+ width: 34,
+ height: 34,
+ borderRadius: 17,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: 'rgba(255,255,255,0.1)',
+ },
+ menuLabel: {
+ flex: 1,
+ color: '#fff',
+ fontSize: 14,
+ fontWeight: '800',
+ },
+ pressed: {
+ opacity: 0.76,
+ },
+});
diff --git a/src/components/app-tabs.tsx b/src/components/app-tabs.tsx
index 80719bc..02a2f9c 100644
--- a/src/components/app-tabs.tsx
+++ b/src/components/app-tabs.tsx
@@ -1,30 +1,36 @@
import { NativeTabs } from 'expo-router/unstable-native-tabs';
-import { useColorScheme } from 'react-native';
-
-import { Colors } from '@/constants/theme';
export default function AppTabs() {
- const scheme = useColorScheme();
- const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
-
return (
+ backgroundColor="#09090b"
+ iconColor={{ default: 'rgba(255,255,255,0.54)', selected: '#ffffff' }}
+ indicatorColor="rgba(255,255,255,0.12)"
+ labelStyle={{
+ default: { color: 'rgba(255,255,255,0.54)', fontSize: 11, fontWeight: '800' },
+ selected: { color: '#ffffff', fontSize: 11, fontWeight: '900' },
+ }}>
- Home
+ 刷剧
-
- Explore
+
+ 创作
+
+
+
+ 我的
+
diff --git a/src/components/app-tabs.web.tsx b/src/components/app-tabs.web.tsx
index ca2787d..4867893 100644
--- a/src/components/app-tabs.web.tsx
+++ b/src/components/app-tabs.web.tsx
@@ -1,115 +1,112 @@
import {
- Tabs,
TabList,
- TabTrigger,
TabSlot,
+ TabTrigger,
TabTriggerSlotProps,
- TabListProps,
+ Tabs,
} from 'expo-router/ui';
import { SymbolView } from 'expo-symbols';
-import { Pressable, useColorScheme, View, StyleSheet } from 'react-native';
+import type { ComponentProps } from 'react';
+import { Pressable, StyleSheet, Text, View } from 'react-native';
+import type { Href } from 'expo-router';
-import { ExternalLink } from './external-link';
-import { ThemedText } from './themed-text';
-import { ThemedView } from './themed-view';
+type SymbolName = ComponentProps['name'];
-import { Colors, MaxContentWidth, Spacing } from '@/constants/theme';
+const tabs = [
+ {
+ name: 'feed',
+ href: '/' as Href,
+ label: '刷剧',
+ icon: { ios: 'play.rectangle.fill', android: 'slideshow', web: 'slideshow' },
+ },
+ {
+ name: 'create',
+ href: '/create' as Href,
+ label: '创作',
+ icon: { ios: 'wand.and.stars', android: 'auto_fix_high', web: 'auto_fix_high' },
+ },
+ {
+ name: 'profile',
+ href: '/profile' as Href,
+ label: '我的',
+ icon: { ios: 'person.crop.circle.fill', android: 'person', web: 'person' },
+ },
+] satisfies {
+ name: string;
+ href: Href;
+ label: string;
+ icon: SymbolName;
+}[];
export default function AppTabs() {
return (
-
+
-
-
- Home
-
-
- Explore
-
-
+
+ {tabs.map((tab) => (
+
+ {tab.label}
+
+ ))}
+
);
}
-export function TabButton({ children, isFocused, ...props }: TabTriggerSlotProps) {
+function TabButton({
+ children,
+ icon,
+ isFocused,
+ ...props
+}: TabTriggerSlotProps & { icon: SymbolName }) {
return (
- pressed && styles.pressed}>
-
-
- {children}
-
-
+ [styles.tabButton, isFocused && styles.activeTab, pressed && styles.pressed]}>
+
+ {children}
);
}
-export function CustomTabList(props: TabListProps) {
- const scheme = useColorScheme();
- const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
-
- return (
-
-
-
- Expo Starter
-
-
- {props.children}
-
-
-
- Docs
-
-
-
-
-
- );
-}
-
const styles = StyleSheet.create({
- tabListContainer: {
+ slot: {
+ height: '100%',
+ },
+ tabList: {
position: 'absolute',
- width: '100%',
- padding: Spacing.three,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ height: 74,
+ flexDirection: 'row',
+ justifyContent: 'space-around',
+ alignItems: 'center',
+ backgroundColor: '#09090b',
+ borderTopWidth: StyleSheet.hairlineWidth,
+ borderTopColor: 'rgba(255,255,255,0.12)',
+ },
+ tabButton: {
+ minWidth: 86,
+ height: 52,
+ borderRadius: 8,
+ alignItems: 'center',
justifyContent: 'center',
- alignItems: 'center',
- flexDirection: 'row',
+ gap: 4,
},
- innerContainer: {
- paddingVertical: Spacing.two,
- paddingHorizontal: Spacing.five,
- borderRadius: Spacing.five,
- flexDirection: 'row',
- alignItems: 'center',
- flexGrow: 1,
- gap: Spacing.two,
- maxWidth: MaxContentWidth,
+ activeTab: {
+ backgroundColor: 'rgba(255,255,255,0.1)',
},
- brandText: {
- marginRight: 'auto',
+ tabText: {
+ color: 'rgba(255,255,255,0.5)',
+ fontSize: 12,
+ fontWeight: '800',
+ },
+ activeText: {
+ color: '#fff',
+ fontWeight: '900',
},
pressed: {
- opacity: 0.7,
- },
- tabButtonView: {
- paddingVertical: Spacing.one,
- paddingHorizontal: Spacing.three,
- borderRadius: Spacing.three,
- },
- externalPressable: {
- flexDirection: 'row',
- justifyContent: 'center',
- alignItems: 'center',
- gap: Spacing.one,
- marginLeft: Spacing.three,
+ opacity: 0.72,
},
});
diff --git a/src/lib/model-config.ts b/src/lib/model-config.ts
new file mode 100644
index 0000000..d8a17ac
--- /dev/null
+++ b/src/lib/model-config.ts
@@ -0,0 +1,56 @@
+import { defaultModelConfig, normalizeModelConfig } from '@infiplot/core';
+import type { ModelConfigMap } from '@infiplot/types';
+import { Platform } from 'react-native';
+
+const STORAGE_KEY = 'infiplot:mobile:model-config';
+
+export { defaultModelConfig };
+export type { ModelConfigMap };
+
+let memoryStore: string | null = null;
+
+function getWebStorage() {
+ if (Platform.OS !== 'web' || typeof window === 'undefined') return null;
+ return window.localStorage;
+}
+
+export async function readModelConfig(): Promise {
+ try {
+ const storage = getWebStorage();
+ const raw = storage ? storage.getItem(STORAGE_KEY) : memoryStore;
+ if (!raw) return defaultModelConfig;
+ return normalizeModelConfig(JSON.parse(raw));
+ } catch {
+ return defaultModelConfig;
+ }
+}
+
+export async function writeModelConfig(config: ModelConfigMap): Promise {
+ const normalized = normalizeModelConfig(config);
+ const serialized = JSON.stringify(normalized);
+
+ try {
+ const storage = getWebStorage();
+ if (storage) {
+ storage.setItem(STORAGE_KEY, serialized);
+ } else {
+ memoryStore = serialized;
+ }
+ } catch {
+ memoryStore = serialized;
+ }
+
+ return normalized;
+}
+
+export async function clearModelConfig(): Promise {
+ try {
+ const storage = getWebStorage();
+ if (storage) storage.removeItem(STORAGE_KEY);
+ } catch {
+ // Storage can be unavailable in private sessions.
+ }
+
+ memoryStore = null;
+ return defaultModelConfig;
+}
diff --git a/tsconfig.json b/tsconfig.json
index 2e9a669..9295d06 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -8,6 +8,12 @@
],
"@/assets/*": [
"./assets/*"
+ ],
+ "@infiplot/core": [
+ "../packages/core/src/index.ts"
+ ],
+ "@infiplot/types": [
+ "../packages/types/src/index.ts"
]
}
},