From eee280d9b3e27db1b91cc74cfc97d5a0087fcb5f Mon Sep 17 00:00:00 2001 From: imeepos Date: Mon, 10 Nov 2025 11:58:56 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E6=92=AD=E6=94=BE=E5=99=A8=E6=97=A0=E9=99=90=E5=BE=AA?= =?UTF-8?q?=E7=8E=AF=E6=B8=B2=E6=9F=93=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 主要修复 - 修复 FullscreenMediaModal 和 FullscreenVideoModal 中的 useEffect 循环依赖 - 重构 VideoPlayer 组件的视频属性管理逻辑 - 优化 useVideoPlayer 的初始化回调机制 ## 新增功能 - 新增标签 API 支持 (lib/api/tags.ts) - 新增内容骨架屏组件 (components/profile/content-skeleton.tsx) - 新增返回按钮组件 (components/ui/back-button.tsx) ## 改进优化 - 优化视频播放器的性能,避免重复初始化 - 修复 useEffect 依赖项导致的无限循环更新 - 完善类型定义和 API 接口 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- LOGIN_MODAL_FIXES.md | 92 ----- REFACTORING.md | 179 --------- app.json | 5 +- app/(tabs)/history.tsx | 392 ++++++++++++++++---- app/(tabs)/home.tsx | 4 +- app/_layout.tsx | 13 +- app/result.tsx | 60 ++- app/templates/[id].tsx | 385 ++++++++++++++----- app/templates/[id]/form.tsx | 85 ++--- bun.lock | 235 +++++++----- components/HistoryCard.tsx | 10 +- components/media/fullscreen-media-modal.tsx | 12 +- components/profile/content-skeleton.tsx | 104 ++++++ components/profile/content-tabs.tsx | 36 +- components/profile/profile-empty-state.tsx | 2 +- components/profile/profile-screen.tsx | 6 +- components/templates/template-card.tsx | 8 +- components/ui/back-button.tsx | 39 ++ components/user/generation-records.tsx | 6 +- components/video/fullscreen-video-modal.tsx | 151 ++++---- components/video/video-player.tsx | 239 ++++-------- lib/api/tags.ts | 158 ++++++++ lib/utils/form-schema-transformer.ts | 7 +- package.json | 21 +- types/expo-video-migration.d.ts | 49 +++ utils/video-utils.ts | 12 +- 26 files changed, 1425 insertions(+), 885 deletions(-) delete mode 100644 LOGIN_MODAL_FIXES.md delete mode 100644 REFACTORING.md create mode 100644 components/profile/content-skeleton.tsx create mode 100644 components/ui/back-button.tsx create mode 100644 lib/api/tags.ts create mode 100644 types/expo-video-migration.d.ts diff --git a/LOGIN_MODAL_FIXES.md b/LOGIN_MODAL_FIXES.md deleted file mode 100644 index 11b373c..0000000 --- a/LOGIN_MODAL_FIXES.md +++ /dev/null @@ -1,92 +0,0 @@ -# 登录弹框修复总结 - -## 🔧 **修复内容** - -### 1. **禁用密码自动填充** -- **CSS方案**:通过CSS `input[type="password"]` 选择器覆盖密码字段样式 -- **跨平台属性**:`textContentType="newPassword"` (仅iOS/Android) - 原生禁用密码自动填充 - -### 2. **样式覆盖优化** -- **CSS类名**:`.login-input-field` 直接应用到真实``元素 -- **全选择器覆盖**: - - `:-webkit-autofill` - - `:-internal-autofill-selected` - - `:-internal-autofill-previewed` - - 支持 `input` 和 `textarea` 元素 - - 使用属性选择器 `input[class*="login-input-field"]` 确保精度 - -### 3. **跨平台兼容性** -- **iOS**:使用原生 `textContentType="newPassword"` -- **Android**:使用原生 `textContentType="newPassword"` -- **Web**:仅使用CSS样式覆盖,避免React Native `autoComplete` 类型限制 - -## ✅ **预期效果** - -### **Web端行为** -1. **邮箱输入**: - - ✅ 自动填充时背景保持暗黑主题色 - - ✅ 文字保持浅色 - - ✅ 不触发密码自动填充 - -2. **密码输入**: - - ✅ CSS样式覆盖自动填充背景 - - ✅ 自动填充时背景保持暗黑主题色 - - ✅ 文字保持浅色 - - ✅ 密码字符加密显示 - -3. **视觉一致性**: - - ✅ 所有自动填充状态保持 `#1a1d23` 背景色 - - ✅ 所有文字保持 `#f5f6f8` 颜色 - - ✅ 选择高亮使用 `#f6474d` 红色 - - ✅ 长过渡时间(9999s)防止重新应用 - -### **移动端行为** -- **iOS**:原生输入体验,使用 `textContentType="newPassword"` 禁用密码自动填充 -- **Android**:原生输入体验,使用 `textContentType="newPassword"` 禁用密码自动填充 - -## 🎯 **技术亮点** - -1. **TypeScript类型安全**: - - 避免React Native `autoComplete` 严格的类型限制 - - 使用CSS属性选择器实现精确控制 - - 所有属性应用符合React Native类型定义 - -2. **DOM操作安全**: - - 仅在Web平台执行CSS注入 - - 组件卸载时自动清理样式 - - 不影响原生平台的React Native逻辑 - -3. **性能优化**: - - CSS仅在组件挂载时注入一次 - - 无定时器或持续监控 - - 9999秒过渡延迟防止重新应用 - -4. **代码简洁**: - - 使用 `Platform.select` 跨平台条件性代码 - - 属性解构避免重复代码 - - 清晰的注释说明用途 - -## 📝 **文件修改** - -- `components/auth/login-modal.tsx` - - ✅ 添加CSS类名 `login-input-field` - - ✅ 优化CSS选择器精确度 - - ✅ 添加 `textContentType` 跨平台支持 - - ✅ 避免 `autoComplete` 类型错误 - - ✅ 增强密码字段样式覆盖 - -## 🚫 **修复的问题** - -| 问题 | 修复前 | 修复后 | -|------|--------|--------| -| TypeScript类型错误 | `autoComplete` 类型不匹配 | 移除了有问题的属性 | -| Chrome自动填充 | 淡蓝色背景 | 暗黑主题色 `#1a1d23` | -| 密码自动填充 | 有提示 | CSS覆盖样式 + 原生属性禁用 | -| 文字颜色 | 可能变深色 | 保持浅色 `#f5f6f8` | -| 移动端体验 | 原生 | 原生(无变化) | - ---- - -**测试建议**:在Chrome、Safari、Edge等浏览器中测试自动填充功能,验证样式是否正确应用。特别注意密码字段的自动填充禁用效果。 - -**技术说明**:使用CSS属性选择器 `[class*="login-input-field"]` 可以精确匹配带有该类名的输入框,绕过React Native Web的DOM层级问题。 diff --git a/REFACTORING.md b/REFACTORING.md deleted file mode 100644 index 3202986..0000000 --- a/REFACTORING.md +++ /dev/null @@ -1,179 +0,0 @@ -# Profile 页面重构报告 - -## 重构背景 - -原始的 `app/(tabs)/profile.tsx` 文件违背了职责单一原则,承担了以下多个职责: -- UI 渲染 -- 数据获取 -- 状态管理 -- 业务逻辑 -- 数据转换 -- 样式定义 - -文件长度达到 995 行代码,严重违反了代码简洁和职责分离的原则。 - -## 重构方案 - -根据代码艺术家的哲学 "存在即合理" 和 "优雅即简约",将文件拆分为以下结构: - -### 目录结构 - -``` -components/profile/ -├── index.ts # 统一导出入口 -├── profile-screen.tsx # 主屏幕组件(组合所有子组件) -├── profile-header.tsx # 顶部计费信息和设置按钮 -├── profile-identity.tsx # 用户身份信息(头像、用户名、统计) -├── content-tabs.tsx # 内容标签页 -├── content-gallery.tsx # 内容画廊列表 -├── profile-empty-state.tsx # 空状态 -├── profile-loading-state.tsx # 加载状态 -├── profile-error-state.tsx # 错误状态 -└── divider.tsx # 分割线组件 - -hooks/ -└── use-profile-data.ts # 数据获取 Hook - -utils/ -└── profile-data.ts # 数据转换工具函数 -``` - -### 组件职责 - -| 组件 | 职责 | 责任范围 | -|------|------|----------| -| `ProfileScreen` | 页面容器和数据协调 | 组合所有子组件,管理全局状态 | -| `ProfileHeader` | 顶部区域渲染 | 设置按钮和计费信息 | -| `ProfileIdentity` | 用户身份展示 | 头像、用户名、编辑按钮、统计数据 | -| `ContentTabs` | 内容类型切换 | All/Image/Video 标签切换 | -| `ContentGallery` | 内容列表 | 分页加载、图片/视频渲染 | -| `ProfileEmptyState` | 空状态 | 无内容时的提示和操作 | -| `ProfileLoadingState` | 加载状态 | 加载动画和提示文字 | -| `ProfileErrorState` | 错误状态 | 错误提示和重试按钮 | -| `Divider` | 视觉分割 | 统一的分割线样式 | - -### 逻辑层 - -| 文件 | 职责 | 功能 | -|------|------|------| -| `useProfileData` | 数据获取逻辑 | API 调用、分页管理、状态同步 | -| `profile-data` | 数据转换 | 用户数据解析、格式化、默认值处理 | - -## 重构成果 - -### 代码指标对比 - -| 指标 | 重构前 | 重构后 | 改进 | -|------|--------|--------|------| -| 主文件行数 | 995 行 | 124 行 | ⬇️ 87.5% | -| 文件数量 | 1 个 | 11 个 | 结构更清晰 | -| 组件数量 | 0 个 | 8 个 | 可复用性提升 | -| 函数复杂度 | 高 | 低 | 单一职责 | -| 可测试性 | 低 | 高 | 模块化设计 | - -### 代码质量提升 - -✅ **职责单一**: 每个组件只负责一个明确的功能 -✅ **可维护性**: 修改某个功能只需关注对应文件 -✅ **可测试性**: 组件可独立测试 -✅ **可复用性**: 子组件可在其他页面复用 -✅ **可读性**: 代码结构清晰,意图明确 -✅ **类型安全**: 保持完整的 TypeScript 类型检查 - -### 架构优势 - -1. **关注点分离**: UI 展示、数据获取、业务逻辑完全分离 -2. **状态管理**: 通过 Hook 统一管理组件状态 -3. **数据流**: 清晰的单向数据流设计 -4. **样式统一**: 主题色彩集中管理 -5. **导入优化**: 通过 index.ts 统一导出,简化导入 - -## 使用示例 - -### 导入方式 - -```typescript -// 方式 1: 直接导入组件 -import { ProfileScreen } from '@/components/profile'; - -// 方式 2: 导入特定组件 -import { ProfileHeader, ContentGallery } from '@/components/profile'; - -// 方式 3: 使用数据 Hook -import { useProfileData } from '@/hooks/use-profile-data'; - -// 方式 4: 使用工具函数 -import { deriveDisplayName, createStats } from '@/utils/profile-data'; -``` - -### 组件使用 - -```typescript -export default function ProfilePage() { - return ( - - ); -} -``` - -## 最佳实践 - -### 1. 组件设计原则 -- 保持组件小而专注(单一功能) -- 通过 props 传递数据和控制行为 -- 使用内部状态时考虑使用 Hook -- 避免在组件中直接处理业务逻辑 - -### 2. Hook 设计原则 -- 封装数据获取逻辑 -- 内部管理组件状态 -- 暴露简洁的 API -- 保持与视图层解耦 - -### 3. 工具函数设计原则 -- 纯函数,无副作用 -- 输入输出类型明确 -- 可独立测试 -- 避免依赖 React 生命周期 - -## 测试建议 - -### 组件测试 -- 单元测试:验证 props 渲染正确性 -- 交互测试:验证点击事件和状态切换 -- 渲染测试:验证不同状态下的 UI - -### Hook 测试 -- 数据获取:mock API 调用 -- 状态管理:测试状态变更 -- 边界情况:测试错误和加载状态 - -### 工具函数测试 -- 输入验证:测试各种输入类型 -- 输出验证:测试格式化和转换 -- 边界值测试:测试 null/undefined 处理 - -## 未来优化方向 - -1. **性能优化** - - 使用 `React.memo` 优化子组件渲染 - - 实现虚拟列表优化大量数据展示 - - 图片懒加载和缓存优化 - -2. **类型安全** - - 完善 TypeScript 类型定义 - - 添加运行时类型检查 - -3. **测试覆盖** - - 添加单元测试和集成测试 - - 实施 E2E 测试 - -4. **可访问性** - - 添加无障碍支持 - - 优化屏幕阅读器兼容性 - ---- - -**重构日期**: 2025-11-03 -**重构者**: Claude Code -**原则**: 存在即合理,优雅即简约 diff --git a/app.json b/app.json index 60b7ccb..6accc99 100644 --- a/app.json +++ b/app.json @@ -39,7 +39,10 @@ "backgroundColor": "#000000" } } - ] + ], + "expo-video", + "expo-audio", + "expo-web-browser" ], "experiments": { "typedRoutes": true, diff --git a/app/(tabs)/history.tsx b/app/(tabs)/history.tsx index bfc2416..cb53927 100644 --- a/app/(tabs)/history.tsx +++ b/app/(tabs)/history.tsx @@ -1,73 +1,171 @@ import { ThemedView } from '@/components/themed-view'; import { StatusBar } from 'expo-status-bar'; -import React from 'react'; -import { Image, ScrollView, StyleSheet, Text, View } from 'react-native'; +import React, { useState, useEffect } from 'react'; +import { ActivityIndicator, Image, ScrollView, StyleSheet, Text, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; +import { getTemplateGenerations, TemplateGeneration } from '@/lib/api/template-generations'; -type GalleryLane = 'leading' | 'trailing'; - -interface GalleryTile { - id: string; - lane: GalleryLane; - uri: string; - height: number; +interface GroupedData { + [date: string]: TemplateGeneration[]; } -const curatedGallery: GalleryTile[] = [ - { - id: 'ember-circle', - lane: 'leading', - uri: 'https://images.unsplash.com/photo-1616004655122-818af0f3efc6?auto=format&fit=crop&w=1200&q=80', - height: 232, - }, - { - id: 'ocean-horizon', - lane: 'trailing', - uri: 'https://images.unsplash.com/photo-1469474968028-56623f02e42e?auto=format&fit=crop&w=1200&q=80', - height: 152, - }, - { - id: 'studio-poise', - lane: 'trailing', - uri: 'https://images.unsplash.com/photo-1582096897407-9b6c86e1a8d3?auto=format&fit=crop&w=1200&q=80', - height: 232, - }, - { - id: 'duo-portrait', - lane: 'leading', - uri: 'https://images.unsplash.com/photo-1601040122900-86ad461b8234?auto=format&fit=crop&w=1200&q=80', - height: 148, - }, - { - id: 'sage-armor', - lane: 'leading', - uri: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=1200&q=80', - height: 224, - }, - { - id: 'velvet-repose', - lane: 'trailing', - uri: 'https://images.unsplash.com/photo-1515378791036-0648a3ef77b2?auto=format&fit=crop&w=1200&q=80', - height: 188, - }, - { - id: 'nocturne-lounge', - lane: 'trailing', - uri: 'https://images.unsplash.com/photo-1573497491208-6b1acb260507?auto=format&fit=crop&w=1200&q=80', - height: 184, - }, - { - id: 'sunlit-duet', - lane: 'leading', - uri: 'https://images.unsplash.com/photo-1531257240576-a4a0ef4af1c8?auto=format&fit=crop&w=1200&q=80', - height: 188, - }, -]; +const groupByDate = (data: TemplateGeneration[]): GroupedData => { + const groups: GroupedData = {}; -const leadingNarratives = curatedGallery.filter(tile => tile.lane === 'leading'); -const trailingNarratives = curatedGallery.filter(tile => tile.lane === 'trailing'); + data.forEach(item => { + const date = new Date(item.createdAt); + const dateKey = date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + weekday: 'short' + }); + + if (!groups[dateKey]) { + groups[dateKey] = []; + } + groups[dateKey].push(item); + }); + + return groups; +}; + +const formatDateLabel = (dateStr: string): string => { + const date = new Date(dateStr); + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + const dateOnly = new Date(date.toDateString()); + const todayOnly = new Date(today.toDateString()); + const yesterdayOnly = new Date(yesterday.toDateString()); + + if (dateOnly.getTime() === todayOnly.getTime()) { + return 'Today'; + } else if (dateOnly.getTime() === yesterdayOnly.getTime()) { + return 'Yesterday'; + } else { + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + weekday: 'short' + }); + } +}; + +const distributeToColumns = (items: TemplateGeneration[]) => { + const leftColumn: TemplateGeneration[] = []; + const rightColumn: TemplateGeneration[] = []; + let leftHeight = 0; + let rightHeight = 0; + + items.forEach(item => { + const height = item.type === 'VIDEO' ? 280 : 240; + + if (leftHeight <= rightHeight) { + leftColumn.push(item); + leftHeight += height + 16; + } else { + rightColumn.push(item); + rightHeight += height + 16; + } + }); + + return { leftColumn, rightColumn }; +}; + +const getImageHeight = (type: string): number => { + switch (type) { + case 'VIDEO': + return 280; + case 'IMAGE': + return 240; + default: + return 200; + } +}; + +const getStatusColor = (status: string): string => { + switch (status) { + case 'completed': + return '#4CAF50'; + case 'processing': + case 'pending': + return '#FFA726'; + case 'failed': + return '#EF5350'; + default: + return '#9E9E9E'; + } +}; + +const getStatusText = (status: string): string => { + switch (status) { + case 'completed': + return '已完成'; + case 'processing': + case 'pending': + return '处理中'; + case 'failed': + return '失败'; + default: + return status; + } +}; export default function HistoryScreen() { + const [groupedData, setGroupedData] = useState({}); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchData = async () => { + try { + setLoading(true); + const response = await getTemplateGenerations({ limit: 100 }); + + if (response.success && response.data) { + const grouped = groupByDate(response.data.generations); + setGroupedData(grouped); + } else { + setError('获取数据失败'); + } + } catch (err) { + setError(err instanceof Error ? err.message : '获取数据失败'); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, []); + + if (loading) { + return ( + + + + + + 加载中... + + + + ); + } + + if (error) { + return ( + + + + + {error} + + + + ); + } + return ( @@ -77,27 +175,77 @@ export default function HistoryScreen() { contentContainerStyle={styles.scrollContent} > Content Generation - 10.19 Wednesday - - - {leadingNarratives.map(tile => ( - - ))} + + {Object.keys(groupedData).length === 0 ? ( + + 暂无生成记录 - - {trailingNarratives.map(tile => ( - - ))} - - + ) : ( + Object.entries(groupedData).map(([date, items]) => { + const { leftColumn, rightColumn } = distributeToColumns(items); + + return ( + + {date} + + + {leftColumn.map(item => ( + + {item.resultUrl && item.resultUrl.length > 0 ? ( + + ) : ( + + + {item.type === 'VIDEO' ? '🎬' : item.type === 'IMAGE' ? '🖼️' : '📝'} + + + )} + + + + + {getStatusText(item.status)} + + {item.template?.title || '未知模板'} + + + ))} + + + + {rightColumn.map(item => ( + + {item.resultUrl && item.resultUrl.length > 0 ? ( + + ) : ( + + + {item.type === 'VIDEO' ? '🎬' : item.type === 'IMAGE' ? '🖼️' : '📝'} + + + )} + + + + + {getStatusText(item.status)} + + {item.template?.title || '未知模板'} + + + ))} + + + + ); + }) + )} @@ -125,13 +273,14 @@ const styles = StyleSheet.create({ }, dateline: { marginTop: 16, + marginBottom: 16, fontSize: 16, fontWeight: '500', color: '#EDEDED', }, gallery: { flexDirection: 'row', - marginTop: 24, + marginBottom: 24, }, leadingLane: { flex: 1, @@ -145,5 +294,84 @@ const styles = StyleSheet.create({ width: '100%', borderRadius: 28, marginBottom: 16, + overflow: 'hidden', + }, + image: { + width: '100%', + borderRadius: 28, + }, + placeholderImage: { + backgroundColor: '#2A2A2A', + justifyContent: 'center', + alignItems: 'center', + }, + placeholderText: { + fontSize: 48, + }, + overlay: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + padding: 12, + backgroundColor: 'rgba(0, 0, 0, 0.6)', + borderBottomLeftRadius: 28, + borderBottomRightRadius: 28, + }, + statusBadge: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 4, + }, + statusDot: { + width: 8, + height: 8, + borderRadius: 4, + marginRight: 6, + }, + statusText: { + fontSize: 12, + color: '#FFFFFF', + fontWeight: '500', + }, + templateName: { + fontSize: 13, + color: '#FFFFFF', + fontWeight: '600', + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + loadingText: { + marginTop: 16, + fontSize: 16, + color: '#FFFFFF', + }, + errorContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 24, + }, + errorText: { + fontSize: 16, + color: '#EF5350', + textAlign: 'center', + }, + emptyContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 48, + }, + emptyText: { + fontSize: 16, + color: '#9E9E9E', + textAlign: 'center', + }, + dateGroup: { + marginBottom: 24, }, }); diff --git a/app/(tabs)/home.tsx b/app/(tabs)/home.tsx index 1b5948b..c7371d3 100644 --- a/app/(tabs)/home.tsx +++ b/app/(tabs)/home.tsx @@ -1,3 +1,4 @@ +import { router } from 'expo-router'; import { useEffect, useMemo, useState } from 'react'; import { ScrollView, StyleSheet, View } from 'react-native'; @@ -190,7 +191,8 @@ export default function ExploreScreen() { const handleGeneratePress = (item: CommunityItem) => { requireAuth(() => { - console.log('用户已登录,执行生成操作', item.id); + const templateId = item.id.split('-').pop() || item.id; + router.push(`/templates/${templateId}`); }); }; diff --git a/app/_layout.tsx b/app/_layout.tsx index c3d754f..7fc6d3c 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -4,11 +4,16 @@ import 'react-native-reanimated'; import { AuthProvider } from '@/components/auth/auth-provider'; import { useColorScheme } from '@/hooks/use-color-scheme'; -import * as Clarity from '@microsoft/react-native-clarity'; +import { Platform } from 'react-native'; import { useEffect } from 'react'; -Clarity.initialize('tyq6bmjzo1', { - logLevel: Clarity.LogLevel.Verbose, // Note: Use "LogLevel.Verbose" value while testing to debug initialization issues. -}); + +// Clarity 只支持 android 和 iOS,web 平台跳过初始化 +if (Platform.OS === 'android' || Platform.OS === 'ios') { + const Clarity = require('@microsoft/react-native-clarity').default; + Clarity.initialize('tyq6bmjzo1', { + logLevel: Clarity.LogLevel.Verbose, + }); +} export const unstable_settings = { anchor: '(tabs)', }; diff --git a/app/result.tsx b/app/result.tsx index 95261d4..168c3c4 100644 --- a/app/result.tsx +++ b/app/result.tsx @@ -1,6 +1,15 @@ import { ThemedView } from '@/components/themed-view'; -import { ResizeMode, Video } from 'expo-av'; +import { VideoView, useVideoPlayer } from 'expo-video'; import * as MediaLibrary from 'expo-media-library'; +import { Platform } from 'react-native'; +import { ThemedText } from '@/components/themed-text'; + +// ResizeMode 兼容映射 - 移除 'stretch',expo-video 不支持 +const ResizeMode = { + CONTAIN: 'contain' as const, + COVER: 'cover' as const, + STRETCH: 'fill' as const, // 使用 'fill' 替代 'stretch' +}; import { router, useLocalSearchParams } from 'expo-router'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { @@ -183,14 +192,28 @@ export default function ResultPage() { if (!url) return null; if (result?.type === 'VIDEO') { + // 简化实现:使用原生 HTML5 video 或 Image 作为回退 + if (Platform.OS === 'web') { + return ( +