From d11ad9e5d7c3ccd5d62fb303598a4f4e42d5f555 Mon Sep 17 00:00:00 2001 From: km2025 Date: Fri, 26 Dec 2025 11:46:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20aniStorage=20?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E7=B3=BB=E7=BB=9F=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=AD=98=E5=82=A8=E4=B8=8E=E7=AE=A1=E7=90=86?= =?UTF-8?q?=20refactor:=20=E6=9B=B4=E6=96=B0=E5=A4=9A=E4=B8=AA=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E4=BB=A5=E4=BD=BF=E7=94=A8=20KeyboardAwareScrollView?= =?UTF-8?q?=20=E4=BB=A3=E6=9B=BF=20ScrollView=20fix:=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E5=8A=9F=E8=83=BD=E7=9A=84=E5=BB=B6=E8=BF=9F?= =?UTF-8?q?=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/settings.json | 5 + app/(tabs)/explore.tsx | 10 +- app/(tabs)/index.tsx | 51 +++++++- app/(tabs)/sync.tsx | 20 ++- app/_layout.tsx | 4 +- app/auth.tsx | 289 +++++++++++++++++++++-------------------- app/pointList.tsx | 4 +- utils/aniStorage.ts | 139 ++++++++++++++++++++ 8 files changed, 358 insertions(+), 164 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 utils/aniStorage.ts diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..bd7c548 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "chatgpt.openOnStartup": true, +} \ No newline at end of file diff --git a/app/(tabs)/explore.tsx b/app/(tabs)/explore.tsx index 1921cc8..ae26571 100644 --- a/app/(tabs)/explore.tsx +++ b/app/(tabs)/explore.tsx @@ -18,6 +18,7 @@ import { Image } from 'expo-image' import { APP_VERSION } from '@/app.constants' import * as Sentry from '@sentry/react-native' +import { KeyboardAwareScrollView } from 'react-native-keyboard-controller' export default function TabTwoScreen() { const { @@ -197,10 +198,7 @@ export default function TabTwoScreen() { } return ( - } - > + BLE Explorer 插件当前版本号: {APP_VERSION} @@ -308,7 +306,7 @@ export default function TabTwoScreen() { onChangeText={setUserId} placeholder="输入用户ID" placeholderTextColor="#999" - editable={isConnected} + // editable={isConnected} /> @@ -325,7 +323,7 @@ export default function TabTwoScreen() { Write UUID: {BLE_UUIDS.WRITE_CHARACTERISTIC} Read UUID: {BLE_UUIDS.READ_CHARACTERISTIC} - + ) } diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 7f4775d..bcbb25f 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -6,7 +6,7 @@ import { Dimensions, FlatList, TextInput, RefreshControl, ActivityIndicator } fr import { LinearGradient } from 'expo-linear-gradient' import Animated, { Easing, useAnimatedStyle, useSharedValue, withRepeat, withTiming } from 'react-native-reanimated' -import { router } from 'expo-router' +import { router, useRouter } from 'expo-router' import { IOS_UNIVERSAL_LINK } from '@/app.constants' import { screenHeight, screenWidth } from '@/utils' import { cn } from '@/utils/cn' @@ -49,11 +49,36 @@ const Banner = memo(function Banner({ bgVideo }) { type SearchOverlayProps = { isOpen: boolean - searchText: string - onChange: (v: string) => void + searchText?: string + onChange?: (v: string) => void onClose: () => void + onSearch: (v: string) => void } -const SearchOverlay = memo(function SearchOverlay({ isOpen, searchText, onChange, onClose }) { +const SearchOverlay = memo(function SearchOverlay({ isOpen, onChange, onClose, onSearch }) { + const timerRef = useRef | null>(null) + const [searchText, setSearchText] = useState('') + const handleTextChange = (text: string) => { + setSearchText(text) + onChange && onChange(text) + + if (timerRef.current) { + clearTimeout(timerRef.current) + } + + timerRef.current = setTimeout(() => { + onSearch(text) + }, 2000) + } + + useEffect(() => { + // 删除定时器 + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current) + } + } + }, []) + if (!isOpen) return null return ( @@ -63,9 +88,9 @@ const SearchOverlay = memo(function SearchOverlay({ isOpen, > (function SearchOverlay({ isOpen, > + + + 登出 + ) }) @@ -631,6 +663,11 @@ export default function Sync() { ) }, [activeTab, isAuthenticated]) + const handleSearch = useCallback((text: string) => { + console.log('Search for:', text) + // Implement your search logic here + }, []) + return ( @@ -658,7 +695,7 @@ export default function Sync() { ListEmptyComponent={renderListEmpty} /> - + setIsSearchOpen(false)} onSearch={handleSearch} /> ) } diff --git a/app/(tabs)/sync.tsx b/app/(tabs)/sync.tsx index 3eddc38..1ad7187 100644 --- a/app/(tabs)/sync.tsx +++ b/app/(tabs)/sync.tsx @@ -16,6 +16,8 @@ import { useTemplateGenerations } from '@/hooks/data/use-template-generations' import { useFileUpload } from '@/hooks/actions/use-file-upload' import { useTemplateActions } from '@/hooks/actions/use-template-actions' import { useAuth } from '@/hooks/core/use-auth' +import { aniStorage } from '@/utils/aniStorage' +import { get } from 'react-native/Libraries/TurboModule/TurboModuleRegistry' // ============ 常量定义 ============ const BACKGROUND_VIDEOS = [ @@ -462,7 +464,15 @@ const Sync = () => { return !!connectedDevice?.id && !!selectedItem?.imageUrl }, [connectedDevice, selectedItem]) - const handleSync = useCallback(() => { + const handleSync = useCallback(async () => { + // await aniStorage.set('test_url', { test: 'data' }) + console.log( + 'aniStorage.has----------------', + await aniStorage.has('test_url'), + // await aniStorage.delete('test_url'), + // await aniStorage.get('test_url'), + ) + if (!canSync) { Toast.show({ title: '请先连接设备' }) return @@ -504,8 +514,12 @@ const Sync = () => { Toast.showLoading({ title: '上传中...', duration: 30e3 }) // 上传到云端 - const file = await fetch(uri).then((r) => r.blob()) - const { url, error } = await uploadFile(file, 'generations') + const fileBlob = await fetch(uri).then((r) => r.blob()) + const mimeType = fileBlob.type || (isVideo ? 'video/mp4' : 'image/jpeg') + + const file = new File([fileBlob], fileName, { type: mimeType }) + + const { url, error } = await uploadFile(file) Toast.hideLoading() diff --git a/app/_layout.tsx b/app/_layout.tsx index 2772271..5b053f0 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -78,10 +78,10 @@ function RootLayout() { return ( - - {/* */} + + ) diff --git a/app/auth.tsx b/app/auth.tsx index 258b992..b2b2a0c 100644 --- a/app/auth.tsx +++ b/app/auth.tsx @@ -1,10 +1,9 @@ -import React, { useState, useMemo, useCallback } from 'react' +import React, { useState, useCallback } from 'react' import { Block, Text, Toast, VideoBox } from '@share/components' import { Ionicons } from '@expo/vector-icons' -import { ScrollView, Dimensions, TextInput, Platform } from 'react-native' +import { Dimensions, TextInput, Platform } from 'react-native' import { useAuth } from '@/hooks/core/use-auth' -import { KeyboardAvoidingView } from 'react-native-keyboard-controller' - +import { KeyboardAwareScrollView } from 'react-native-keyboard-controller' const BACKGROUND_VIDEOS = [ 'https://cdn.roasmax.cn/material/b46f380532e14cf58dd350dbacc7c34a.mp4', @@ -12,8 +11,6 @@ const BACKGROUND_VIDEOS = [ 'https://cdn.roasmax.cn/material/e4947477843f4067be7c37569a33d17b.mp4', ] - - type AuthMode = 'login' | 'register' export default function Auth() { @@ -27,8 +24,6 @@ export default function Auth() { const [confirmPassword, setConfirmPassword] = useState('') const [loading, setLoading] = useState(false) - const { height: screenHeight } = Dimensions.get('window') - const handleLogin = useCallback(async () => { if (!username || !password) { Toast.show({ title: '请填写账号和密码' }) @@ -79,8 +74,8 @@ export default function Auth() { name: username, }) - console.log('result------------', result); - + // console.log('result------------', result) + if (result.error) { Toast.show({ title: result.error.message || '注册失败' }) } else { @@ -102,57 +97,106 @@ export default function Auth() { }, [mode, handleLogin, handleRegister]) return ( - - - - - - - - - - - - - - - LOOMART - + + + + + + + + + + + + LOOMART + + - - {(['login', 'register'] as const).map((tabMode) => { - const isActive = mode === tabMode - return ( - setMode(tabMode)} - className={`flex-1 items-center justify-center border-[3px] border-black py-[12px] shadow-hard-black ${isActive ? 'bg-accent' : 'bg-white'}`} - style={{ transform: [{ skewX: '-6deg' }] }} + + {(['login', 'register'] as const).map((tabMode) => { + const isActive = mode === tabMode + return ( + setMode(tabMode)} + className={`flex-1 items-center justify-center border-[3px] border-black py-[12px] shadow-hard-black ${isActive ? 'bg-accent' : 'bg-white'}`} + style={{ transform: [{ skewX: '-6deg' }] }} + > + - - {tabMode === 'login' ? '登录' : '注册'} - - - ) - })} - + {tabMode === 'login' ? '登录' : '注册'} + + + ) + })} + - - - - {mode === 'login' ? ( + + + + {mode === 'login' ? ( + + 账号 + + + + + + ) : ( + <> - 账号 - + 邮箱 + + + + + + + + 用户名 + - ) : ( - <> - - 邮箱 - - - - - + + )} - - 用户名 - - - - - - - )} + + 密码 + + + + + + {mode === 'register' && ( - 密码 - + 确认密码 + + )} - {mode === 'register' && ( - - 确认密码 - - - - - - )} - - - {loading ? ( - - ) : ( - - )} - {loading ? '处理中...' : mode === 'login' ? '登录' : '注册'} - - - {mode === 'login' && ( - - 忘记密码?点击找回 - - )} + + {loading ? : } + {loading ? '处理中...' : mode === 'login' ? '登录' : '注册'} + + {mode === 'login' && ( + + 忘记密码?点击找回 + + )} + - - © 2025 LOOMART. All rights reserved. - + + © 2025 LOOMART. All rights reserved. - - - + + + + ) } diff --git a/app/pointList.tsx b/app/pointList.tsx index b07ee0e..38c91e1 100644 --- a/app/pointList.tsx +++ b/app/pointList.tsx @@ -10,7 +10,7 @@ import { Stack, useRouter } from 'expo-router' import Alipay from 'expo-native-alipay' import ExpoWeChat from 'expo-wechat' import { alipay, loomart } from '@/lib/auth' -import { ANDROID_ID, IOS_UNIVERSAL_LINK } from '@/app.constants' +import { ANDROID_ID, IOS_UNIVERSAL_LINK, SCHEME } from '@/app.constants' import { useUserBalance } from '@/hooks/core' const { width: SCREEN_WIDTH } = Dimensions.get('window') @@ -44,7 +44,7 @@ export default function ChargePage() { console.log('initpay ---------', Alipay) - Alipay.setAlipayScheme(ANDROID_ID) + // Alipay.setAlipayScheme(SCHEME) // ⚠️ 目前不可用,设置支付宝沙箱环境,仅 Android 支持 Alipay.setAlipaySandbox(true) diff --git a/utils/aniStorage.ts b/utils/aniStorage.ts new file mode 100644 index 0000000..13b42ac --- /dev/null +++ b/utils/aniStorage.ts @@ -0,0 +1,139 @@ +import * as FileSystem from 'expo-file-system/legacy' + +/** + * 基于 Expo FileSystem 的持久化缓存系统,用于存储 .ani 文件数据 + * 键是 URL,值是 ani 文件内容 + */ + +// 定义 ani 文件内容的类型 +type AniData = object | string + +const CACHE_FOLDER = `${FileSystem.cacheDirectory}ani_cache/` + +/** + * 将 URL 转换为合法的文件名 + * + * 为什么需要这样做? + * 1. URL 中包含文件系统不允许的特殊字符(如 /, :, ?, & 等)。 + * 2. URL 的长度可能超过操作系统对文件名的长度限制(通常为 255 字符)。 + * 3. 简单的哈希算法可以将任意长度的 URL 转换为简短且唯一的标识符。 + */ +const hashUrl = (url: string): string => { + let hash = 0 + for (let i = 0; i < url.length; i++) { + const char = url.charCodeAt(i) + hash = (hash << 5) - hash + char + hash |= 0 // Convert to 32bit integer + } + // 使用 hex 字符串和长度作为后缀来减少碰撞概率 + return `${hash.toString(16)}_${url.length}` +} + +class AniStorage { + constructor() { + this.ensureDir() + } + + /** + * 确保存储目录存在 + */ + private async ensureDir() { + try { + // 直接尝试创建目录,intermediates: true 会处理父目录不存在的情况 + // 如果目录已存在,通常不会报错,或者我们在 catch 中忽略 + await FileSystem.makeDirectoryAsync(CACHE_FOLDER, { intermediates: true }) + } catch (error) { + // 忽略目录已存在的错误 + } + } + + /** + * 获取缓存文件路径 + */ + private getFilePath(url: string): string { + const filename = hashUrl(url) + return `${CACHE_FOLDER}${filename}.json` + } + + /** + * 获取缓存中的 ani 数据 + * @param url 资源的 URL + * @returns 缓存的数据,如果不存在则返回 undefined + */ + async get(url: string): Promise { + try { + const filePath = this.getFilePath(url) + // 移除 getInfoAsync 检查,直接尝试读取 + // 如果文件不存在,readAsStringAsync 会抛出错误,我们在 catch 中处理 + const content = await FileSystem.readAsStringAsync(filePath) + try { + return JSON.parse(content) + } catch { + return content + } + } catch (error) { + // 文件不存在或读取失败时返回 undefined + return undefined + } + } + + /** + * 设置 ani 数据到缓存中 + * @param url 资源的 URL + * @param data ani 文件数据 + */ + async set(url: string, data: AniData): Promise { + try { + await this.ensureDir() + const filePath = this.getFilePath(url) + const content = typeof data === 'string' ? data : JSON.stringify(data) + await FileSystem.writeAsStringAsync(filePath, content) + } catch (error) { + console.warn('AniCache set error:', error) + } + } + + /** + * 检查缓存中是否存在该 URL (异步) + * @param url 资源的 URL + */ + async has(url: string): Promise { + try { + const filePath = this.getFilePath(url) + // 由于 getInfoAsync 被弃用,这里尝试读取文件来检查是否存在 + await FileSystem.readAsStringAsync(filePath) + return true + } catch { + return false + } + } + + /** + * 清除所有缓存 + */ + async clear(): Promise { + try { + // idempotent: true 使得如果文件/目录不存在也不会报错 + await FileSystem.deleteAsync(CACHE_FOLDER, { idempotent: true }) + await this.ensureDir() + } catch (error) { + console.warn('AniCache clear error:', error) + } + } + + /** + * 删除指定 URL 的缓存 + * @param url 资源的 URL + */ + async delete(url: string): Promise { + try { + const filePath = this.getFilePath(url) + await FileSystem.deleteAsync(filePath, { idempotent: true }) + } catch (error) { + console.warn('AniCache delete error:', error) + } + } +} + +// 导出单例实例 +export const aniStorage = new AniStorage()