Compare commits

...

11 Commits

Author SHA1 Message Date
745667f554 feat: 优化通知权限申请 2025-07-23 20:59:23 +08:00
ad860171d6 fix: lint error 2025-07-23 16:35:23 +08:00
208e8c3510 feat: 统一权限申请提示组件 2025-07-23 15:00:15 +08:00
e7861d9ce9 feat: 注销账号+文案 2025-07-22 23:00:02 +08:00
128175339b fix: permission request 2025-07-22 20:18:37 +08:00
5d3270934f feat: 隐私协议 (#10)
All checks were successful
Prod Deploy / Explore-Gitea-Actions (push) Successful in 35s
Reviewed-on: #10
2025-07-21 20:33:22 +08:00
74b2167ba9 fix/v0.5.0_bug (#9)
Co-authored-by: Junhui Chen <chenjunhui@fairclip.cn>
Reviewed-on: #9
2025-07-21 19:49:18 +08:00
182d2b02d4 chore: 修改版本号和api服务地址 2025-07-21 17:25:38 +08:00
9deeaa9a0c feat: 支持页面
All checks were successful
Prod Deploy / Explore-Gitea-Actions (push) Successful in 35s
2025-07-21 17:18:01 +08:00
2f8a3d2948 feat: app icon 2025-07-21 16:57:33 +08:00
4864e8b9c7 fix: db for web 2025-07-21 16:28:22 +08:00
49 changed files with 1760 additions and 521 deletions

View File

@ -2,23 +2,21 @@
"expo": {
"name": "memowake",
"slug": "memowake",
"version": "1.0.0",
"version": "0.5.0",
"orientation": "portrait",
"icon": "",
"icon": "./assets/icons/png/app.png",
"scheme": "memowake",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"supportsTablet": true,
"infoPlist": {
"NSPhotoLibraryUsageDescription": "Allow $(PRODUCT_NAME) to access your photos.",
"NSPhotoLibraryUsageDescription": "允许访问照片库以便模型使用您照片库中的素材进行视频创作”例如上传您参加音乐节的现场图生成一个音乐节体验Vlog",
"NSPhotoLibraryAddUsageDescription": "需要保存图片到相册",
"NSLocationWhenInUseUsageDescription": "Allow $(PRODUCT_NAME) to access your location to get photo location data.",
"NSLocationWhenInUseUsageDescription": "允许获取位置信息以便模型使用您的位置信息进行个性化创作”例如上传您去欧洲旅游的位置信息结合在当地拍摄的照片生成一个欧洲旅行攻略Vlog",
"ITSAppUsesNonExemptEncryption": false,
"UIBackgroundModes": [
"fetch",
"location",
"audio"
"fetch"
]
},
"bundleIdentifier": "com.memowake.app"
@ -34,13 +32,8 @@
"ACCESS_MEDIA_LOCATION",
"android.permission.RECORD_AUDIO",
"android.permission.MODIFY_AUDIO_SETTINGS",
"android.permission.READ_EXTERNAL_STORAGE",
"android.permission.WRITE_EXTERNAL_STORAGE",
"android.permission.ACCESS_MEDIA_LOCATION",
"FOREGROUND_SERVICE",
"WAKE_LOCK",
"READ_EXTERNAL_STORAGE",
"WRITE_EXTERNAL_STORAGE"
"WAKE_LOCK"
],
"edgeToEdgeEnabled": true,
"package": "com.memowake.app"
@ -48,7 +41,7 @@
"web": {
"bundler": "metro",
"output": "static",
"favicon": ""
"favicon": "./assets/icons/png/app.png"
},
"plugins": [
"expo-router",
@ -68,9 +61,7 @@
[
"expo-location",
{
"locationAlwaysAndWhenInUsePermission": "允许 $(PRODUCT_NAME) 访问您的位置",
"locationAlwaysPermission": "允许 $(PRODUCT_NAME) 访问您的位置",
"locationWhenInUsePermission": "允许 $(PRODUCT_NAME) 访问您的位置"
"locationWhenInUsePermission": "允许 $(PRODUCT_NAME) 获取位置信息,以便使用您的位置信息进行个性化创作.例如上传您去欧洲旅游的位置信息结合在当地拍摄的照片生成一个欧洲旅行攻略Vlog"
}
],
[
@ -82,8 +73,8 @@
[
"expo-media-library",
{
"photosPermission": "Allow $(PRODUCT_NAME) to access your photos.",
"savePhotosPermission": "Allow $(PRODUCT_NAME) to save photos.",
"photosPermission": "允许 $(PRODUCT_NAME) 访问照片库以便我们使用您照片库中的素材进行视频创作。例如上传您参加音乐节的现场图生成一个音乐节体验Vlog",
"savePhotosPermission": "允许 $(PRODUCT_NAME) 保存媒体到照片库以便保存您生成的视频。例如生成音乐节体验Vlog后保存到您的相册",
"isAccessMediaLocationEnabled": true
}
],
@ -96,8 +87,7 @@
"router": {},
"eas": {
"projectId": "04721dd4-6b15-495a-b9ec-98187c613172"
},
"API_ENDPOINT": "http://192.168.31.115:18080/api"
}
}
}
}

View File

@ -1,5 +1,6 @@
import { HapticTab } from '@/components/HapticTab';
import { TabBarIcon } from '@/components/navigation/TabBarIcon';
import { requestNotificationPermission } from '@/components/owner/utils';
import TabBarBackground from '@/components/ui/TabBarBackground';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
@ -8,6 +9,7 @@ import * as Notifications from 'expo-notifications';
import { Tabs } from 'expo-router';
import * as SecureStore from 'expo-secure-store';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Platform } from 'react-native';
interface PollingData {
@ -17,6 +19,7 @@ interface PollingData {
extra: any;
}
export default function TabLayout() {
const { t } = useTranslation();
const colorScheme = useColorScheme();
const [pollingData, setPollingData] = useState<PollingData[]>([]);
const pollingInterval = useRef<NodeJS.Timeout | number>(null);
@ -25,9 +28,9 @@ export default function TabLayout() {
const [token, setToken] = useState('');
const sendNotification = async (item: PollingData) => {
// 请求通知权限
const { status } = await Notifications.requestPermissionsAsync();
if (status !== 'granted') {
alert('请先允许通知权限');
const granted = await requestNotificationPermission();
if (!granted) {
console.log('用户拒绝了通知权限');
return;
}
@ -280,6 +283,27 @@ export default function TabLayout() {
tabBarStyle: { display: 'none' } // 确保在标签栏中不显示
}}
/>
{/* 隐私协议 */}
<Tabs.Screen
name="privacy-policy"
options={{
title: 'privacy-policy',
tabBarButton: () => null, // 隐藏底部标签栏
headerShown: false, // 隐藏导航栏
tabBarStyle: { display: 'none' } // 确保在标签栏中不显示
}}
/>
{/* Support Screen */}
<Tabs.Screen
name="support"
options={{
title: t('tabTitle', { ns: 'support' }),
tabBarButton: () => null, // 隐藏底部标签栏
headerShown: false, // 隐藏导航栏
tabBarStyle: { display: 'none' } // 确保在标签栏中不显示
}}
/>
{/* Debug Screen - only in development */}
{process.env.NODE_ENV === 'development' && (

View File

@ -18,6 +18,8 @@ export default function HomeScreen() {
router.replace('/ask')
}, false).then(() => {
setIsLoading(false);
}).catch(() => {
setIsLoading(false);
});
}, []);

View File

@ -52,10 +52,6 @@ const LoginScreen = () => {
router.setParams({ [key]: value });
}
useEffect(() => {
// setError('123')
}, [])
return (
<KeyboardAvoidingView
style={{ flex: 1 }}

View File

@ -1,5 +1,4 @@
import ConversationsSvg from '@/assets/icons/svg/conversations.svg';
import MoreArrowSvg from '@/assets/icons/svg/moreArrow.svg';
import PointsSvg from '@/assets/icons/svg/points.svg';
import StoriesSvg from '@/assets/icons/svg/stories.svg';
import UsedStorageSvg from '@/assets/icons/svg/usedStorage.svg';
@ -40,6 +39,7 @@ export default function OwnerPage() {
// 设置弹窗
const [modalVisible, setModalVisible] = useState(false);
// 数据统计
const [countData, setCountData] = useState<CountData>({} as CountData);
@ -89,13 +89,10 @@ export default function OwnerPage() {
{/* 资源数据 */}
<View style={styles.resourceContainer}>
<View style={{ gap: 16, width: "80%" }}>
<View style={{ gap: 16 }}>
<ResourceComponent title={t("generalSetting.usedStorage", { ns: "personal" })} data={{ all: userInfoDetails.total_bytes, used: countData.used_bytes }} icon={<UsedStorageSvg />} isFormatBytes={true} />
<ResourceComponent title={t("generalSetting.remainingPoints", { ns: "personal" })} data={{ all: userInfoDetails.total_points, used: userInfoDetails.remain_points }} icon={<PointsSvg />} />
</View>
<View style={{ alignItems: 'flex-end', flex: 1 }}>
<MoreArrowSvg />
</View>
</View>
{/* 数据统计 */}
<CountComponent
@ -130,8 +127,10 @@ export default function OwnerPage() {
</View>
}
/>
{/* 设置弹窗 */}
{/* 设置弹窗 - 使用条件渲染避免层级冲突 */}
{modalVisible && (
<SettingModal modalVisible={modalVisible} setModalVisible={setModalVisible} userInfo={userInfoDetails.user_info} />
)}
{/* 导航栏 */}
<AskNavbar />

View File

@ -0,0 +1,159 @@
import { fetchApi } from "@/lib/server-api-util";
import { Policy } from "@/types/personal-info";
import { useEffect, useState } from "react";
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from "react-native";
import RenderHtml from 'react-native-render-html';
const PrivacyPolicy = () => {
const [article, setArticle] = useState<Policy>({} as Policy);
useEffect(() => {
const loadArticle = async () => {
fetchApi<Policy>(`/system-config/policy/privacy_policy`).then((res: any) => {
setArticle(res)
}).catch((error: any) => {
console.log(error)
})
}
loadArticle();
}, []);
if (!article) {
return (
<View style={styles.container}>
<Text>...</Text>
</View>
);
}
return (
<View style={styles.centeredView}>
<View style={styles.modalView}>
<View style={styles.modalHeader}>
<Text style={{ opacity: 0 }}>Settings</Text>
<Text style={styles.modalTitle}>{'Privacy Policy'}</Text>
<TouchableOpacity style={{ opacity: 0 }}>
<Text style={styles.closeButton}>×</Text>
</TouchableOpacity>
</View>
<ScrollView style={styles.modalContent} showsVerticalScrollIndicator={false}>
<RenderHtml
source={{ html: article.content }}
tagsStyles={{
p: { fontSize: 16, lineHeight: 24 },
strong: { fontWeight: 'bold' },
em: { fontStyle: 'italic' },
}}
/>
</ScrollView>
</View>
</View>
);
};
export default PrivacyPolicy;
const styles = StyleSheet.create({
centeredView: {
flex: 1,
justifyContent: 'flex-end',
backgroundColor: 'rgba(0,0,0,0.5)',
},
container: {
flex: 1,
},
modalView: {
width: '100%',
height: '100%',
backgroundColor: 'white',
paddingHorizontal: 16,
},
modalHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 20,
},
modalTitle: {
fontSize: 20,
fontWeight: 'bold',
color: '#4C320C',
},
closeButton: {
fontSize: 28,
color: '#4C320C',
padding: 10,
},
modalContent: {
flex: 1,
},
modalText: {
fontSize: 16,
color: '#4C320C',
},
premium: {
backgroundColor: "#FAF9F6",
padding: 16,
borderRadius: 24,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
content: {
flex: 1,
flexDirection: 'column',
gap: 4,
backgroundColor: '#FAF9F6',
borderRadius: 24,
paddingVertical: 8
},
item: {
paddingHorizontal: 16,
paddingVertical: 8,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
itemText: {
fontSize: 14,
fontWeight: '600',
color: '#4C320C',
},
upgradeButton: {
backgroundColor: '#E2793F',
borderRadius: 20,
paddingHorizontal: 16,
paddingVertical: 8,
},
upgradeButtonText: {
color: '#fff',
fontSize: 14,
fontWeight: "600"
},
switchContainer: {
width: 50,
height: 30,
borderRadius: 15,
justifyContent: 'center',
paddingHorizontal: 2,
},
switchOn: {
backgroundColor: '#E2793F',
alignItems: 'flex-end',
},
switchOff: {
backgroundColor: '#E5E5E5',
alignItems: 'flex-start',
},
switchCircle: {
width: 26,
height: 26,
borderRadius: 13,
},
switchCircleOn: {
backgroundColor: 'white',
},
switchCircleOff: {
backgroundColor: '#A5A5A5',
},
});

67
app/(tabs)/support.tsx Normal file
View File

@ -0,0 +1,67 @@
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import Head from 'expo-router/head';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Linking, Text, TouchableOpacity, View } from 'react-native';
const SupportScreen = () => {
const { t } = useTranslation('support');
const handleWeChatSupport = () => {
Linking.openURL('https://work.weixin.qq.com/kfid/kfca0ac87f4e05e8bfd');
};
const handleEmailSupport = () => {
Linking.openURL('mailto:memowake@fairclip.cn');
};
return (
<>
<Head>
<title>{t('pageTitle')}</title>
</Head>
<LinearGradient
colors={['#FFB645', '#E2793F']}
className="flex-1 items-center justify-center p-6"
>
<View className="items-center mb-12">
<Text className="text-white text-5xl font-extrabold tracking-tight">
MemoWake
</Text>
<Text className="text-white/90 text-2xl mt-4 text-center max-w-xs">
{t('title')}
</Text>
<Text className="text-white/90 text-lg mt-4 text-center max-w-xs">
{t('description')}
</Text>
</View>
<View className="w-full max-w-xs">
<TouchableOpacity
className="bg-white/90 rounded-xl px-6 py-4 flex-row items-center justify-center shadow-lg mb-5"
onPress={handleWeChatSupport}
activeOpacity={0.8}
>
<Ionicons name="chatbubbles-outline" size={24} color="black" />
<Text className="text-black font-bold text-lg ml-3">
{t('onlineSupport')}
</Text>
</TouchableOpacity>
<TouchableOpacity
className="bg-black/80 rounded-xl px-6 py-4 flex-row items-center justify-center shadow-lg"
onPress={handleEmailSupport}
activeOpacity={0.8}
>
<Ionicons name="mail-outline" size={24} color="white" />
<Text className="text-white font-bold text-lg ml-3">
{t('emailSupport')}
</Text>
</TouchableOpacity>
</View>
</LinearGradient>
</>
);
};
export default SupportScreen;

View File

@ -7,6 +7,7 @@ import { StatusBar } from 'expo-status-bar';
import { useEffect } from 'react';
import 'react-native-reanimated';
import '../global.css';
import { PermissionProvider } from '@/context/PermissionContext';
import { Provider } from "../provider";
export default function RootLayout() {
@ -30,6 +31,7 @@ export default function RootLayout() {
return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<PermissionProvider>
<Provider>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
@ -43,6 +45,7 @@ export default function RootLayout() {
<Stack.Screen name="+not-found" />
</Stack>
</Provider>
</PermissionProvider>
<StatusBar style="auto" />
</ThemeProvider>
);

BIN
assets/icons/png/app.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

61
assets/icons/svg/app.svg Normal file
View File

@ -0,0 +1,61 @@
<svg width="578" height="577" viewBox="0 0 578 577" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_d_215_188)">
<g clip-path="url(#clip0_215_188)">
<rect x="3" width="572.333" height="572.333" rx="111.784" fill="white"/>
<rect x="3" width="572.333" height="572.333" fill="#AC7E35"/>
<path d="M34.4206 192.01C32.8885 178.266 65.8095 177.448 82.4616 178.758L56.5555 209.322C48.4291 212.492 35.9527 205.754 34.4206 192.01Z" fill="#FFDBA3"/>
<path d="M41.5631 191.094C39.1999 179.937 62.1246 182.378 73.8823 184.994L60.656 199.713C55.2763 201.489 43.9263 202.252 41.5631 191.094Z" fill="#AC7E35"/>
<path d="M198.913 27.5173C185.168 25.9852 184.351 58.9062 185.661 75.5583L216.225 49.6522C219.395 41.5258 212.657 29.0493 198.913 27.5173Z" fill="#FFDBA3"/>
<path d="M197.997 34.6598C186.84 32.2966 189.281 55.2212 191.897 66.979L206.616 53.7527C208.392 48.373 209.154 37.0229 197.997 34.6598Z" fill="#AC7E35"/>
<path d="M30.7421 448.573C-38.1574 191.436 197.139 -43.8603 454.275 25.0392L629.664 72.0346C886.801 140.934 972.926 462.355 784.689 650.592L656.295 778.986C468.058 967.223 146.637 881.099 77.7375 623.962L30.7421 448.573Z" fill="#FFD18A"/>
<rect x="217.479" y="240.655" width="13.6147" height="19.0606" rx="6.80735" transform="rotate(135 217.479 240.655)" fill="#4C320C"/>
<rect x="252.138" y="205.996" width="13.6147" height="19.0606" rx="6.80735" transform="rotate(135 252.138 205.996)" fill="#4C320C"/>
<path d="M192.499 462.813C162.296 299.481 305.191 156.586 468.523 186.789L654.544 221.189C842.135 255.878 913.874 486.75 778.979 621.646L627.356 773.269C492.46 908.164 261.588 836.425 226.899 648.835L192.499 462.813Z" fill="#FFF8DE"/>
<g filter="url(#filter1_i_215_188)">
<ellipse cx="447.564" cy="174.232" rx="223.281" ry="159.292" transform="rotate(-45 447.564 174.232)" fill="#FFF8DE"/>
</g>
<g filter="url(#filter2_i_215_188)">
<ellipse cx="178.97" cy="442.827" rx="221.92" ry="159.292" transform="rotate(-45 178.97 442.827)" fill="#FFF8DE"/>
</g>
<ellipse cx="256.948" cy="253.172" rx="16.3376" ry="12.2532" transform="rotate(135 256.948 253.172)" fill="#FFB8B9"/>
<ellipse cx="38.9009" cy="15.5185" rx="38.9009" ry="15.5185" transform="matrix(0.934357 -0.356338 -0.356338 -0.934357 493.079 394.049)" fill="#FFD38D"/>
<ellipse cx="358.82" cy="530.763" rx="38.9009" ry="15.5185" transform="rotate(110.875 358.82 530.763)" fill="#FFD38D"/>
<path d="M264.909 264.467C264.366 262.443 266.219 260.59 268.243 261.132L272.799 262.353C274.824 262.896 275.502 265.426 274.02 266.909L270.685 270.244C269.203 271.726 266.672 271.048 266.129 269.023L264.909 264.467Z" fill="#4C320C"/>
</g>
</g>
<defs>
<filter id="filter0_d_215_188" x="0.764322" y="0" width="576.805" height="576.805" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="2.23568"/>
<feGaussianBlur stdDeviation="1.11784"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_215_188"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_215_188" result="shape"/>
</filter>
<filter id="filter1_i_215_188" x="248.485" y="-19.7266" width="393.038" height="415.794" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="-5.1201" dy="27.8761"/>
<feGaussianBlur stdDeviation="22.4643"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.713726 0 0 0 0 0.270588 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_215_188"/>
</filter>
<filter id="filter2_i_215_188" x="-14.2056" y="232.015" width="411.952" height="403.987" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="25.6005" dy="-17.6359"/>
<feGaussianBlur stdDeviation="14.8767"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.713974 0 0 0 0 0.272498 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_215_188"/>
</filter>
<clipPath id="clip0_215_188">
<rect x="3" width="572.333" height="572.333" rx="111.784" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5" />
</svg>

After

Width:  |  Height:  |  Size: 296 B

View File

@ -1,5 +1,5 @@
'use client';
import VoiceSvg from '@/assets/icons/svg/vioce.svg';
import SendSvg from '@/assets/icons/svg/send.svg';
import React, { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react';
import {
Keyboard,
@ -125,9 +125,12 @@ export default function SendMessage(props: Props) {
/>
<TouchableOpacity
style={styles.voiceButton}
onPress={handleSubmit}
className={`absolute right-0 top-1/2 -translate-y-1/2 `} // 使用绝对定位将按钮放在输入框内右侧
>
<VoiceSvg />
<View style={{ transform: [{ rotate: '330deg' }] }}>
<SendSvg color={'white'} width={24} height={24} />
</View>
</TouchableOpacity>
</View>
</View>
@ -156,6 +159,6 @@ const styles = StyleSheet.create({
backgroundColor: '#FF9500',
justifyContent: 'center',
alignItems: 'center',
marginRight: 8, // 添加一点右边距
marginRight: 8, // 添加一点
},
});

View File

@ -3,6 +3,7 @@ import * as Device from 'expo-device';
import * as Notifications from 'expo-notifications';
import { useEffect, useState } from 'react';
import { Button, Platform, Text, View } from 'react-native';
import { requestNotificationPermission } from '../owner/utils';
Notifications.setNotificationHandler({
handleNotification: async () => ({
@ -108,13 +109,13 @@ async function registerForPushNotificationsAsync() {
// 4. 如果尚未授予权限,则请求权限
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
const granted = await requestNotificationPermission();
finalStatus = granted ? Notifications.PermissionStatus.GRANTED : Notifications.PermissionStatus.DENIED;
}
// 5. 如果权限被拒绝,显示警告并返回
if (finalStatus !== 'granted') {
alert('Failed to get push token for push notification!');
console.log('用户拒绝了通知权限');
return;
}

View File

@ -0,0 +1,111 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Pressable, StyleSheet, Text, View } from 'react-native';
interface PermissionAlertProps {
visible: boolean;
onConfirm: () => void;
onCancel: () => void;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
}
const PermissionAlert: React.FC<PermissionAlertProps> = ({ visible, onConfirm, onCancel, title, message, confirmText, cancelText }) => {
const { t } = useTranslation();
if (!visible) {
return null;
}
return (
<View style={styles.overlay}>
<View style={styles.centeredView}>
<View style={styles.modalView}>
<Text style={styles.modalTitle}>{title}</Text>
<Text style={styles.modalMessage}>{message}</Text>
<View style={styles.buttonContainer}>
<Pressable style={[styles.button, styles.cancelButton]} onPress={onCancel}>
<Text style={styles.buttonText}>{cancelText || t('cancel', { ns: 'permission' })}</Text>
</Pressable>
<Pressable style={[styles.button, styles.confirmButton]} onPress={onConfirm}>
<Text style={styles.confirmButtonText}>{confirmText || t('goToSettings', { ns: 'permission' })}</Text>
</Pressable>
</View>
</View>
</View>
</View>
);
};
const styles = StyleSheet.create({
overlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 99,
},
centeredView: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0,0,0,0.5)',
zIndex: 9999,
},
modalView: {
width: '80%',
backgroundColor: 'white',
borderRadius: 16,
padding: 24,
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 5,
},
modalTitle: {
fontSize: 20,
fontWeight: 'bold',
color: '#4C320C',
marginBottom: 12,
},
modalMessage: {
fontSize: 16,
color: '#4C320C',
textAlign: 'center',
marginBottom: 24,
},
buttonContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
width: '100%',
},
button: {
borderRadius: 20,
paddingVertical: 12,
flex: 1,
alignItems: 'center',
},
cancelButton: {
backgroundColor: '#F5F5F5',
marginRight: 8,
},
confirmButton: {
backgroundColor: '#E2793F',
marginLeft: 8,
},
buttonText: {
color: '#4C320C',
fontWeight: '600',
},
confirmButtonText: {
color: 'white',
fontWeight: '600',
},
});
export default PermissionAlert;

View File

@ -4,7 +4,8 @@ import { uploadFileWithProgress } from '@/lib/background-uploader/uploader';
import { compressImage } from '@/lib/image-process/imageCompress';
import { createVideoThumbnailFile } from '@/lib/video-process/videoThumbnail';
import * as ImagePicker from 'expo-image-picker';
import * as Location from 'expo-location';
import { requestLocationPermission, requestMediaLibraryPermission } from '@/components/owner/utils';
import { PermissionService } from '@/lib/PermissionService';
import * as MediaLibrary from 'expo-media-library';
import React, { useEffect, useState } from 'react';
import { Alert, Button, Platform, TouchableOpacity, View } from 'react-native';
@ -26,23 +27,6 @@ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
const [files, setFiles] = useState<FileUploadItem[]>([]);
const [uploadQueue, setUploadQueue] = useState<FileUploadItem[]>([]);
// 请求权限
const requestPermissions = async () => {
if (Platform.OS !== 'web') {
const { status: mediaStatus } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (mediaStatus !== 'granted') {
Alert.alert('需要媒体库权限', '请允许访问媒体库以选择图片');
return false;
}
const { status: locationStatus } = await Location.requestForegroundPermissionsAsync();;
if (locationStatus !== 'granted') {
Alert.alert('需要位置权限', '需要位置权限才能获取图片位置信息');
}
}
return true;
};
// 处理单个资源
const processSingleAsset = async (asset: ImagePicker.ImagePickerAsset): Promise<UploadResult | null> => {
console.log("asset111111", asset);
@ -261,9 +245,13 @@ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
const pickImage = async () => {
try {
setIsLoading(true);
const hasPermission = await requestPermissions();
console.log("hasPermission", hasPermission);
if (!hasPermission) return;
const hasMediaPermission = await requestMediaLibraryPermission();
if (!hasMediaPermission) {
setIsLoading(false);
return;
}
// 请求位置权限,但不强制要求
await requestLocationPermission();
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: fileType,
@ -290,13 +278,13 @@ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
}
}));
} catch (error) {
Alert.alert('错误', '部分文件处理失败,请重试');
PermissionService.show({ title: '错误', message: '部分文件处理失败,请重试' });
} finally {
setIsLoading(false);
}
} catch (error) {
Alert.alert('错误', '选择图片时出错,请重试');
PermissionService.show({ title: '错误', message: '选择图片时出错,请重试' });
} finally {
setIsLoading(false);
}

View File

@ -1,6 +1,8 @@
import * as MediaLibrary from 'expo-media-library';
import React, { useState } from 'react';
import { ActivityIndicator, Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import i18n from '@/i18n';
import { PermissionService } from '@/lib/PermissionService';
interface MediaStats {
total: number;
@ -46,7 +48,7 @@ const MediaStatsScreen = () => {
// 1. 请求媒体库权限
const { status } = await MediaLibrary.requestPermissionsAsync();
if (status !== 'granted') {
Alert.alert('权限被拒绝', '需要访问媒体库权限来获取统计信息');
PermissionService.show({ title: i18n.t('permission:title.permissionDenied'), message: i18n.t('permission:message.getStatsPermissionRequired') });
return;
}
@ -116,7 +118,7 @@ const MediaStatsScreen = () => {
setStats(stats);
} catch (error) {
console.error('获取媒体库统计信息失败:', error);
Alert.alert('错误', '获取媒体库统计信息失败');
PermissionService.show({ title: i18n.t('permission:title.error'), message: i18n.t('permission:message.getStatsFailed') });
} finally {
setIsLoading(false);
}

View File

@ -1,11 +1,12 @@
import { requestLocationPermission, requestMediaLibraryPermission } from '@/components/owner/utils';
import { PermissionService } from '@/lib/PermissionService';
import { fetchApi } from '@/lib/server-api-util';
import { ConfirmUpload, defaultExifData, ExifData, FileStatus, ImagesPickerProps, UploadResult, UploadUrlResponse } from '@/types/upload';
import * as ImageManipulator from 'expo-image-manipulator';
import * as ImagePicker from 'expo-image-picker';
import * as Location from 'expo-location';
import * as MediaLibrary from 'expo-media-library';
import React, { useEffect, useState } from 'react';
import { Alert, Button, Platform, TouchableOpacity, View } from 'react-native';
import { Button, Platform, TouchableOpacity, View } from 'react-native';
import * as Progress from 'react-native-progress';
export const ImagesPicker: React.FC<ImagesPickerProps> = ({
@ -24,17 +25,13 @@ export const ImagesPicker: React.FC<ImagesPickerProps> = ({
// 请求权限
const requestPermissions = async () => {
if (Platform.OS !== 'web') {
const { status: mediaStatus } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (mediaStatus !== 'granted') {
Alert.alert('需要媒体库权限', '请允许访问媒体库以选择图片');
const hasMediaPermission = await requestMediaLibraryPermission();
if (!hasMediaPermission) {
setIsLoading(false);
return false;
}
const { status: locationStatus } = await Location.requestForegroundPermissionsAsync();;
if (locationStatus !== 'granted') {
Alert.alert('需要位置权限', '需要位置权限才能获取图片位置信息');
}
// 请求位置权限,但不强制要求
await requestLocationPermission();
}
return true;
};
@ -118,7 +115,7 @@ export const ImagesPicker: React.FC<ImagesPickerProps> = ({
// 使用函数更新文件状态,确保每次更新都是原子的
const updateFileStatus = (updates: Partial<FileStatus>) => {
setCurrentFileStatus((original) => ({ ...original, ...updates }))
setCurrentFileStatus((original: FileStatus) => ({ ...original, ...updates } as FileStatus))
};
// 上传文件
const uploadFile = async (file: File, metadata: Record<string, any> = {}): Promise<ConfirmUpload> => {
@ -262,9 +259,11 @@ export const ImagesPicker: React.FC<ImagesPickerProps> = ({
originalUrl: undefined,
compressedUrl: '',
file: compressedFile,
exifData,
exif: exifData,
originalFile: {} as ConfirmUpload,
compressedFile: {} as ConfirmUpload,
thumbnail: '',
thumbnailFile: compressedFile,
};
try {
@ -288,17 +287,17 @@ export const ImagesPicker: React.FC<ImagesPickerProps> = ({
await new Promise(resolve => setTimeout(resolve, 300));
// 更新状态为成功
await updateFileStatus({ status: 'success', progress: 100, id: uploadResults.originalFile?.file_id });
// 调用上传完成回调
onUploadComplete?.(uploadResults);
// 调用上传完成回调 - 暂时注释,因为类型不匹配
// onUploadComplete?.(uploadResults);
} catch (error) {
updateFileStatus({ status: 'error', progress: 0, id: uploadResults.originalFile?.file_id });
throw error; // 重新抛出错误,让外层 catch 处理
}
} catch (error) {
Alert.alert('错误', '处理图片时出错');
PermissionService.show({ title: '错误', message: '处理图片时出错' });
}
} catch (error) {
Alert.alert('错误', '选择图片时出错,请重试');
PermissionService.show({ title: '错误', message: '选择图片时出错,请重试' });
} finally {
setIsLoading(false);
}

View File

@ -11,13 +11,19 @@ const AlbumComponent = ({ setModalVisible, style }: CategoryProps) => {
const { t } = useTranslation();
return (
<View style={[styles.container, style]}>
<TouchableOpacity style={{ flex: 3 }}>
<TouchableOpacity style={{ flex: 3, opacity: 0 }}>
<ThemedText style={styles.text}>{t('generalSetting.album', { ns: 'personal' })}</ThemedText>
</TouchableOpacity>
<TouchableOpacity style={{ flex: 3 }}>
<TouchableOpacity style={{ flex: 3, opacity: 0 }}>
<ThemedText style={styles.text}>{t('generalSetting.shareProfile', { ns: 'personal' })}</ThemedText>
</TouchableOpacity>
<TouchableOpacity onPress={() => setModalVisible(true)} style={[styles.text, { flex: 1, alignItems: "center", paddingVertical: 6 }]}>
<TouchableOpacity
onPress={() => {
setModalVisible(true);
}}
activeOpacity={0.7}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
style={[styles.text, { flex: 1, alignItems: "center", paddingVertical: 6, zIndex: 999 }]}>
<SettingSvg />
</TouchableOpacity>
</View>

204
components/owner/delete.tsx Normal file
View File

@ -0,0 +1,204 @@
import { useAuth } from '@/contexts/auth-context';
import { fetchApi } from '@/lib/server-api-util';
import { useRouter } from 'expo-router';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Modal, Pressable, StyleSheet, View } from 'react-native';
import { ThemedText } from '../ThemedText';
const DeleteModal = (props: { modalVisible: boolean, setModalVisible: (visible: boolean) => void, setSettingModalVisible: (visible: boolean) => void }) => {
const { modalVisible, setModalVisible, setSettingModalVisible } = props;
const { logout } = useAuth();
const { t } = useTranslation();
const router = useRouter();
// 注销账号
const handleDeleteAccount = () => {
fetchApi("/iam/delete-user", {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
})
.then(async (res) => {
await logout();
setModalVisible(false);
setSettingModalVisible(false);
router.replace('/login');
})
.catch(() => {
console.error("jwt has expired.");
});
};
return (
<Modal
animationType="fade"
transparent={true}
visible={modalVisible}
onRequestClose={() => setModalVisible(false)}
>
<View style={styles.centeredView}>
<View style={styles.modalView}>
<ThemedText style={styles.modalTitle}>
{t("generalSetting.delete", { ns: "personal" })}
</ThemedText>
<View style={styles.buttonContainer}>
<Pressable
style={[styles.button, styles.cancelButton]}
onPress={() => setModalVisible(false)}
>
<ThemedText style={styles.cancelButtonText}>
{t("generalSetting.cancel", { ns: "personal" })}
</ThemedText>
</Pressable>
<Pressable
style={[styles.button, styles.deleteButton]}
onPress={handleDeleteAccount}
>
<ThemedText style={styles.deleteButtonText}>
{t("generalSetting.deleteAccount", { ns: "personal" })}
</ThemedText>
</Pressable>
</View>
</View>
</View>
</Modal>
);
};
const styles = StyleSheet.create({
centeredView: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0,0,0,0.5)',
},
modalView: {
width: '80%',
backgroundColor: 'white',
borderRadius: 16,
padding: 24,
alignItems: 'center',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 5,
marginBottom: 20,
},
buttonContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
width: '100%',
marginTop: 20,
},
button: {
borderRadius: 20,
paddingVertical: 12,
paddingHorizontal: 20,
elevation: 2,
minWidth: 100,
alignItems: 'center',
},
cancelButton: {
backgroundColor: '#F5F5F5',
marginRight: 12,
},
deleteButton: {
backgroundColor: '#E2793F',
},
cancelButtonText: {
color: '#4C320C',
fontWeight: '600',
},
deleteButtonText: {
color: 'white',
fontWeight: '600',
},
modalTitle: {
fontSize: 20,
fontWeight: 'bold',
color: '#4C320C',
},
closeButton: {
fontSize: 28,
color: '#4C320C',
padding: 10,
},
modalContent: {
flex: 1,
},
modalText: {
fontSize: 16,
color: '#4C320C',
},
premium: {
backgroundColor: "#FAF9F6",
padding: 16,
borderRadius: 24,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
content: {
flex: 1,
flexDirection: 'column',
gap: 4,
backgroundColor: '#FAF9F6',
borderRadius: 24,
paddingVertical: 8
},
item: {
paddingHorizontal: 16,
paddingVertical: 8,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
itemText: {
fontSize: 14,
fontWeight: '600',
color: '#4C320C',
},
upgradeButton: {
backgroundColor: '#E2793F',
borderRadius: 20,
paddingHorizontal: 16,
paddingVertical: 8,
},
upgradeButtonText: {
color: '#fff',
fontSize: 14,
fontWeight: "600"
},
switchContainer: {
width: 50,
height: 30,
borderRadius: 15,
justifyContent: 'center',
paddingHorizontal: 2,
},
switchOn: {
backgroundColor: '#E2793F',
alignItems: 'flex-end',
},
switchOff: {
backgroundColor: '#E5E5E5',
alignItems: 'flex-start',
},
switchCircle: {
width: 26,
height: 26,
borderRadius: 13,
},
switchCircleOn: {
backgroundColor: 'white',
},
switchCircleOff: {
backgroundColor: '#A5A5A5',
},
});
export default DeleteModal;

View File

@ -42,9 +42,10 @@ const PrivacyModal = (props: { modalVisible: boolean, setModalVisible: (visible:
})
}
};
if (type) {
loadArticle();
}, []);
}
}, [type]);
if (!article) {
return (

View File

@ -1,3 +1,4 @@
import DeleteSvg from '@/assets/icons/svg/delete.svg';
import LogoutSvg from '@/assets/icons/svg/logout.svg';
import RightArrowSvg from '@/assets/icons/svg/rightArrow.svg';
import { useAuth } from '@/contexts/auth-context';
@ -9,6 +10,7 @@ import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Linking, Modal, Pressable, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { ThemedText } from '../ThemedText';
import DeleteModal from './delete';
import LcensesModal from './qualification/lcenses';
import PrivacyModal from './qualification/privacy';
import CustomSwitch from './switch';
@ -17,6 +19,7 @@ import { checkNotificationPermission, getLocationPermission, getPermissions, req
const SettingModal = (props: { modalVisible: boolean, setModalVisible: (visible: boolean) => void, userInfo: User }) => {
const { modalVisible, setModalVisible, userInfo } = props;
const { t } = useTranslation();
const [modalType, setModalType] = useState<'ai' | 'terms' | 'privacy' | 'user'>('ai');
// 协议弹窗
@ -24,6 +27,8 @@ const SettingModal = (props: { modalVisible: boolean, setModalVisible: (visible:
// 许可证弹窗
const [lcensesModalVisible, setLcensesModalVisible] = useState(false);
// 删除弹窗
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const { logout } = useAuth();
const router = useRouter();
// 打开设置
@ -32,28 +37,31 @@ const SettingModal = (props: { modalVisible: boolean, setModalVisible: (visible:
};
// 通知消息权限开关
const [notificationsEnabled, setNotificationsEnabled] = useState(false);
const toggleNotifications = () => {
const toggleNotifications = async () => {
if (notificationsEnabled) {
// 引导去设置关闭权限
openAppSettings()
} else {
console.log('请求通知权限');
requestNotificationPermission().then((res) => {
setNotificationsEnabled(res as boolean);
})
requestNotificationPermission()
.then((granted) => {
setNotificationsEnabled(granted);
});
setModalVisible(false);
}
};
// 相册权限
const [albumEnabled, setAlbumEnabled] = useState(false);
const toggleAlbum = () => {
const toggleAlbum = async () => {
if (albumEnabled) {
// 引导去设置关闭权限
openAppSettings()
} else {
requestMediaLibraryPermission().then((res) => {
setAlbumEnabled(res as boolean);
})
requestMediaLibraryPermission()
.then((granted) => {
setAlbumEnabled(granted);
});
setModalVisible(false);
}
}
@ -62,12 +70,14 @@ const SettingModal = (props: { modalVisible: boolean, setModalVisible: (visible:
// 位置权限更改
const toggleLocation = async () => {
if (locationEnabled) {
// 引导去设置关闭权限
openAppSettings()
// 如果权限已开启,点击则引导用户去设置关闭
openAppSettings();
} else {
requestLocationPermission().then((res) => {
setLocationEnabled(res as boolean);
})
requestLocationPermission()
.then((granted) => {
setLocationEnabled(granted);
});
setModalVisible(false);
}
};
// 正在获取位置信息
@ -88,16 +98,18 @@ const SettingModal = (props: { modalVisible: boolean, setModalVisible: (visible:
let currentStatus = await getLocationPermission();
console.log('当前权限状态:', currentStatus);
// 2. 如果没有权限,则请求权限
// 2. 如果没有权限,则跳过获取位置
if (!currentStatus) {
const newStatus = await requestLocationPermission();
setLocationEnabled(newStatus);
currentStatus = newStatus;
if (!currentStatus) {
alert('需要位置权限才能继续');
console.log('没有权限,跳过获取位置')
return;
}
// const newStatus = await requestLocationPermission();
// setLocationEnabled(newStatus);
// currentStatus = newStatus;
// if (!currentStatus) {
// // alert('需要位置权限才能继续');
// return;
// }
}
// 3. 确保位置服务已启用
@ -117,7 +129,9 @@ const SettingModal = (props: { modalVisible: boolean, setModalVisible: (visible:
// 地理位置逆编码
const address = await reverseGeocode(location.coords.latitude, location.coords.longitude);
// 5. 更新位置状态
setCurrentLocation(address as Address);
if (address) {
setCurrentLocation(address);
}
return location;
} catch (error: any) {
@ -157,10 +171,12 @@ const SettingModal = (props: { modalVisible: boolean, setModalVisible: (visible:
if (modalVisible) {
// 位置权限
getLocationPermission().then((res) => {
console.log('位置权限:', res);
setLocationEnabled(res);
})
// 媒体库权限
getPermissions().then((res) => {
console.log('媒体库权限:', res);
setAlbumEnabled(res);
})
// 通知权限
@ -172,12 +188,13 @@ const SettingModal = (props: { modalVisible: boolean, setModalVisible: (visible:
}, [modalVisible])
return (
<>
<Modal
animationType="slide"
transparent={true}
visible={modalVisible}
onRequestClose={() => {
setModalVisible(!modalVisible);
setModalVisible(false);
}}>
<Pressable
style={styles.centeredView}
@ -358,16 +375,20 @@ const SettingModal = (props: { modalVisible: boolean, setModalVisible: (visible:
<ThemedText style={{ color: '#E2793F', fontSize: 14, fontWeight: '600' }}>{t('generalSetting.logout', { ns: 'personal' })}</ThemedText>
<LogoutSvg />
</TouchableOpacity>
{/* 注销账号 */}
<TouchableOpacity style={[styles.premium, { marginVertical: 8 }]} onPress={() => setDeleteModalVisible(true)}>
<ThemedText style={{ color: '#E2793F', fontSize: 14, fontWeight: '600' }}>{t('generalSetting.deleteAccount', { ns: 'personal' })}</ThemedText>
<DeleteSvg />
</TouchableOpacity>
</ScrollView>
</Pressable>
</Pressable>
{/* 协议弹窗 */}
<PrivacyModal modalVisible={privacyModalVisible} setModalVisible={setPrivacyModalVisible} type={modalType} />
{/* 许可证弹窗 */}
<LcensesModal modalVisible={lcensesModalVisible} setModalVisible={setLcensesModalVisible} />
{/* 通知 */}
{/* <AuthNotifications setNotificationsEnabled={setNotificationsEnabled} notificationsEnabled={notificationsEnabled} /> */}
<DeleteModal modalVisible={deleteModalVisible} setModalVisible={setDeleteModalVisible} setSettingModalVisible={setModalVisible} />
</Modal>
</>
);
};

View File

@ -73,7 +73,7 @@ const UserInfo = (props: UserInfoProps) => {
useEffect(() => {
if (modalVisible) {
getLocation();
if (Object.keys(currentLocation).length === 0) {
if (currentLocation && Object?.keys(currentLocation)?.length === 0) {
getCurrentLocation();
}
}

View File

@ -1,16 +1,13 @@
// 地理位置逆编码
import i18n from '@/i18n';
import { PermissionService } from '@/lib/PermissionService';
import { fetchApi } from '@/lib/server-api-util';
import { Address } from '@/types/user';
import * as ImagePicker from 'expo-image-picker';
import * as Location from 'expo-location';
import * as Notifications from 'expo-notifications';
import * as SecureStore from 'expo-secure-store';
import { Alert, Linking, Platform } from 'react-native';
interface Address {
id: number;
name: string;
// Add other address properties as needed
}
import { Linking, Platform } from 'react-native';
// 配置通知处理器
Notifications.setNotificationHandler({
@ -24,7 +21,7 @@ Notifications.setNotificationHandler({
});
// 逆编码
export const reverseGeocode = async (latitude: number, longitude: number) => {
export const reverseGeocode = async (latitude: number, longitude: number): Promise<Address | undefined> => {
try {
const addressResults = await fetchApi<Address[]>(`/area/gecoding?latitude=${latitude}&longitude=${longitude}`);
console.log('地址:', addressResults);
@ -37,8 +34,10 @@ export const reverseGeocode = async (latitude: number, longitude: number) => {
}
return address;
}
return undefined;
} catch (error) {
console.log('逆地理编码失败:', error);
return undefined;
}
};
@ -68,27 +67,13 @@ export const requestLocationPermission = async () => {
// 3. 如果用户之前选择了"拒绝且不再询问"
if (status === 'denied' && !canAskAgain) {
// 显示提示,引导用户去设置
const openSettings = await new Promise(resolve => {
Alert.alert(
'需要位置权限',
'您之前拒绝了位置权限。要使用此功能,请在设置中启用位置权限。',
[
{
text: '取消',
style: 'cancel',
onPress: () => resolve(false)
},
{
text: '去设置',
onPress: () => resolve(true)
}
]
);
const confirmed = await PermissionService.show({
title: i18n.t('permission:title.locationPermissionRequired'),
message: i18n.t('permission:message.locationPreviouslyDenied'),
});
if (openSettings) {
// 打开应用设置
await Linking.openSettings();
if (confirmed) {
openAppSettings();
}
return false;
}
@ -99,24 +84,25 @@ export const requestLocationPermission = async () => {
console.log('新权限状态:', newStatus);
if (newStatus !== 'granted') {
Alert.alert('需要位置权限', '请允许访问位置以使用此功能');
return false;
}
return true;
} catch (error) {
console.error('请求位置权限时出错:', error);
Alert.alert('错误', '请求位置权限时出错');
return false;
}
};
export const openAppSettings = () => {
Linking.openSettings();
};
// 获取媒体库权限
export const getPermissions = async () => {
if (Platform.OS !== 'web') {
const { status: mediaStatus } = await ImagePicker.getMediaLibraryPermissionsAsync();
if (mediaStatus !== 'granted') {
// Alert.alert('需要媒体库权限', '请允许访问媒体库以继续');
return false;
}
return true;
@ -129,7 +115,6 @@ export const requestPermissions = async () => {
if (Platform.OS !== 'web') {
const mediaStatus = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!mediaStatus.granted) {
// Alert.alert('需要媒体库权限', '请允许访问媒体库以继续');
return false;
}
return true;
@ -147,13 +132,13 @@ export const checkMediaLibraryPermission = async (): Promise<{
status: ImagePicker.PermissionStatus;
}> => {
if (Platform.OS === 'web') {
return { hasPermission: true, canAskAgain: true, status: 'granted' };
return { hasPermission: true, canAskAgain: true, status: ImagePicker.PermissionStatus.GRANTED };
}
const { status, canAskAgain } = await ImagePicker.getMediaLibraryPermissionsAsync();
return {
hasPermission: status === 'granted',
hasPermission: status === ImagePicker.PermissionStatus.GRANTED,
canAskAgain,
status
};
@ -181,20 +166,10 @@ export const requestMediaLibraryPermission = async (showAlert: boolean = true):
// 3. 如果之前被拒绝且不能再次询问
if (existingStatus === 'denied' && !canAskAgain) {
if (showAlert) {
const openSettings = await new Promise<boolean>(resolve => {
Alert.alert(
'需要媒体库权限',
'您之前拒绝了媒体库访问权限。要选择照片,请在设置中启用媒体库权限。',
[
{ text: '取消', style: 'cancel', onPress: () => resolve(false) },
{ text: '去设置', onPress: () => resolve(true) }
]
);
await PermissionService.show({
title: i18n.t('permission:title.mediaLibraryPermissionRequired'),
message: i18n.t('permission:message.mediaLibraryPreviouslyDenied'),
});
if (openSettings) {
await Linking.openSettings();
}
}
return false;
}
@ -203,14 +178,20 @@ export const requestMediaLibraryPermission = async (showAlert: boolean = true):
const { status: newStatus } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (newStatus !== 'granted' && showAlert) {
Alert.alert('需要媒体库权限', '请允许访问媒体库以方便后续操作');
await PermissionService.show({
title: i18n.t('permission:title.mediaLibraryPermissionRequired'),
message: i18n.t('permission:message.mediaLibraryPermissionRequired'),
});
}
return newStatus === 'granted';
} catch (error) {
console.error('请求媒体库权限时出错:', error);
if (showAlert) {
Alert.alert('错误', '请求媒体库权限时出错');
await PermissionService.show({
title: i18n.t('permission:title.error'),
message: i18n.t('permission:message.requestPermissionError'),
});
}
return false;
}
@ -239,28 +220,10 @@ export const requestNotificationPermission = async () => {
// 3. 如果用户之前选择了"拒绝且不再询问"
if (status === 'denied' && !canAskAgain) {
// 显示提示,引导用户去设置
const openSettings = await new Promise(resolve => {
Alert.alert(
'需要通知权限',
'您之前拒绝了通知权限。要使用此功能,请在设置中启用通知权限。',
[
{
text: '取消',
style: 'cancel',
onPress: () => resolve(false)
},
{
text: '去设置',
onPress: () => resolve(true)
}
]
);
await PermissionService.show({
title: i18n.t('permission:title.notificationPermissionRequired'),
message: i18n.t('permission:message.notificationPreviouslyDenied'),
});
if (openSettings) {
// 打开应用设置
await Linking.openSettings();
}
return false;
}
@ -270,14 +233,17 @@ export const requestNotificationPermission = async () => {
console.log('新通知权限状态:', newStatus);
if (newStatus !== 'granted') {
Alert.alert('需要通知权限', '请允许通知以使用此功能');
PermissionService.show({
title: '需要通知权限',
message: '请允许通知以使用此功能',
});
return false;
}
return true;
} catch (error) {
console.error('请求通知权限时出错:', error);
Alert.alert('错误', '请求通知权限时出错');
PermissionService.show({ title: '错误', message: '请求通知权限时出错' });
return false;
}
};

View File

@ -0,0 +1,82 @@
import PermissionAlert from '@/components/common/PermissionAlert';
import i18n from '@/i18n';
import { PermissionService } from '@/lib/PermissionService';
import React, { createContext, ReactNode, useCallback, useContext, useEffect, useState } from 'react';
import { Linking } from 'react-native';
interface PermissionAlertOptions {
title: string;
message: string;
confirmText?: string;
cancelText?: string;
}
interface PermissionContextType {
showPermissionAlert: (options: PermissionAlertOptions) => Promise<boolean>;
}
interface AlertData {
options: PermissionAlertOptions;
resolve: (value: boolean) => void;
}
const PermissionContext = createContext<PermissionContextType | undefined>(undefined);
export const PermissionProvider = ({ children }: { children: ReactNode }) => {
const [alertData, setAlertData] = useState<AlertData | null>(null);
const showPermissionAlert = useCallback((options: PermissionAlertOptions) => {
return new Promise<boolean>((resolve) => {
setAlertData({ options, resolve });
});
}, []);
useEffect(() => {
PermissionService.set(showPermissionAlert);
// Cleanup on unmount
return () => {
PermissionService.set(null as any); // or a no-op function
};
}, [showPermissionAlert]);
const handleConfirm = () => {
Linking.openSettings();
if (alertData?.resolve) {
alertData.resolve(true);
}
setAlertData(null);
};
const handleCancel = () => {
if (alertData?.resolve) {
alertData.resolve(false);
}
setAlertData(null);
};
return (
<PermissionContext.Provider value={{ showPermissionAlert }}>
{children}
{alertData && (
<PermissionAlert
visible={!!alertData}
title={alertData.options.title}
message={alertData.options.message}
confirmText={alertData.options.confirmText || i18n.t('permission:button.confirm')}
cancelText={alertData.options.cancelText || i18n.t('permission:button.cancel')}
onConfirm={handleConfirm}
onCancel={handleCancel}
/>
)}
</PermissionContext.Provider>
);
};
export const usePermission = (): PermissionContextType => {
const context = useContext(PermissionContext);
if (!context) {
throw new Error('usePermission must be used within a PermissionProvider');
}
return context;
};

View File

@ -7,6 +7,7 @@ import * as path from 'path';
function generateImports() {
const localesPath = path.join(__dirname, 'locales');
const namespaces = ['common', 'home', 'login', 'settings', 'upload', 'chat', 'me', 'permission'];
const languages = fs.readdirSync(localesPath);
let imports = '';
let translationsMap = 'const translations = {\n';

View File

@ -32,7 +32,7 @@ i18n
resources: translations,
// 支持命名空间
ns: ['common', 'example', 'download'],
ns: ['common', 'example', 'download', 'permission'],
defaultNS: 'common',
// 设置默认语言为中文
@ -96,14 +96,16 @@ export const preloadCommonTranslations = async () => {
// 预加载 common 和 example 命名空间
await Promise.all([
loadNamespaceForLanguage(currentLng, 'common'),
loadNamespaceForLanguage(currentLng, 'example')
loadNamespaceForLanguage(currentLng, 'example'),
loadNamespaceForLanguage(currentLng, 'permission')
]);
// 如果当前语言不是英语,也预加载英语作为备用
if (currentLng !== 'en') {
await Promise.all([
loadNamespaceForLanguage('en', 'common'),
loadNamespaceForLanguage('en', 'example')
loadNamespaceForLanguage('en', 'example'),
loadNamespaceForLanguage('en', 'permission')
]);
}
};

View File

@ -11,13 +11,15 @@
"common": {
"search": "Search...",
"title": "MemoWake - Home Video Memory, Powered by AI",
"name":"MemoWake",
"homepage":"HomePage",
"signup":"Sign up",
"login":"Login",
"trade":"copyright 2025 MemoWake - All rights reserved",
"logout":"Logout",
"self":"Personal Center"
"name": "MemoWake",
"homepage": "HomePage",
"signup": "Sign up",
"login": "Login",
"trade": "沪ICP备2025133004号-2A",
"logout": "Logout",
"self": "Personal Center",
"goToSettings": "Go to Settings",
"cancel": "Cancel"
},
"welcome": {
"welcome": "Welcome to MemoWake~",

View File

@ -0,0 +1,29 @@
{
"title": {
"permissionDenied": "Permission Denied",
"locationPermissionRequired": "Location Permission Required",
"mediaLibraryPermissionRequired": "Media Library Permission Required",
"notificationPermissionRequired": "Notification Permission Required",
"success": "✅ Success",
"error": "❌ Error",
"getMediaFailed": "Failed to Get Media"
},
"message": {
"locationPreviouslyDenied": "You have previously denied location permissions. To use this feature, please enable it in settings.",
"mediaLibraryPreviouslyDenied": "You have previously denied media library permissions. To use this feature, please enable it in settings.",
"notificationPreviouslyDenied": "You have previously denied notification permissions. To use this feature, please enable it in settings.",
"saveToAlbumPermissionRequired": "Permission is required to save images to the album.",
"qrCodeSaved": "QR code has been saved to the album!",
"saveImageFailed": "Failed to save the image, please try again.",
"getStatsPermissionRequired": "Permission to access the media library is required to get statistics.",
"getStatsFailed": "Failed to get media library statistics.",
"noMediaFound": "Could not retrieve any media. Please check permissions or your media library.",
"uploadError": "An error occurred during the upload process."
},
"button": {
"cancel": "Cancel",
"goToSettings": "Go to Settings",
"ok": "OK",
"confirm": "Go to Settings"
}
}

View File

@ -83,6 +83,10 @@
"videoLength": "Video Duration",
"storiesCreated": "Stories Created",
"conversationsWithMemo": "Conversations with Memo",
"setting": "Settings"
"setting": "Settings",
"premium": "Upgrade to Premium",
"unlock": "Unlock more memory magic",
"delete": "Are you sure you want to delete your account?",
"cancel": "Cancel"
}
}

View File

@ -0,0 +1,8 @@
{
"title": "Support & Help",
"description": "If you encounter any issues or have any suggestions, please contact us through the following methods.",
"onlineSupport": "Online Support",
"emailSupport": "Email Support",
"pageTitle": "Support & Help - MemoWake",
"tabTitle": "Support"
}

View File

@ -11,13 +11,15 @@
"common": {
"search": "搜索...",
"title": "MemoWake - AI驱动的家庭「视频记忆」",
"name":"MemoWake",
"homepage":"首页",
"signup":"注册",
"login":"登录",
"trade": "沪ICP备2023032876号-4",
"logout":"退出登录",
"self":"个人中心"
"name": "MemoWake",
"homepage": "首页",
"signup": "注册",
"login": "登录",
"trade": "沪ICP备2025133004号-2A",
"logout": "退出登录",
"self": "个人中心",
"goToSettings": "去设置",
"cancel": "取消"
},
"welcome": {
"welcome": "欢迎来到 MemoWake~",

View File

@ -0,0 +1,29 @@
{
"title": {
"permissionDenied": "权限被拒绝",
"locationPermissionRequired": "需要位置权限",
"mediaLibraryPermissionRequired": "需要媒体库权限",
"notificationPermissionRequired": "需要通知权限",
"success": "✅ 成功",
"error": "❌ 失败",
"getMediaFailed": "获取媒体资源失败"
},
"message": {
"locationPreviouslyDenied": "您之前拒绝了位置权限。要使用此功能,请在设置中启用位置权限。",
"mediaLibraryPreviouslyDenied": "您之前拒绝了媒体库权限。要使用此功能,请在设置中启用它。",
"notificationPreviouslyDenied": "您之前拒绝了通知权限。要使用此功能,请在设置中启用它。",
"saveToAlbumPermissionRequired": "需要保存图片到相册的权限",
"qrCodeSaved": "二维码已保存到相册!",
"saveImageFailed": "无法保存图片,请重试",
"getStatsPermissionRequired": "需要访问媒体库权限来获取统计信息",
"getStatsFailed": "获取媒体库统计信息失败",
"noMediaFound": "未能获取到任何媒体资源,请检查权限或媒体库。",
"uploadError": "上传过程中出现错误。"
},
"button": {
"cancel": "取消",
"goToSettings": "去设置",
"ok": "好的",
"confirm": "去设置"
}
}

View File

@ -83,6 +83,10 @@
"videoLength": "视频时长",
"storiesCreated": "创作视频",
"conversationsWithMemo": "Memo对话",
"setting": "设置"
"setting": "设置",
"premium": "升级至会员",
"unlock": "解锁更多记忆魔法",
"delete": "确定要注销账号吗?",
"cancel": "取消"
}
}

View File

@ -0,0 +1,8 @@
{
"title": "支持与帮助",
"description": "如果您在使用中遇到任何问题,或有任何建议,请通过以下方式联系我们。",
"onlineSupport": "在线客服",
"emailSupport": "邮件联系",
"pageTitle": "支持与帮助 - MemoWake",
"tabTitle": "支持"
}

View File

@ -8,7 +8,9 @@ import enExample from './locales/en/example.json';
import enFairclip from './locales/en/fairclip.json';
import enLanding from './locales/en/landing.json';
import enLogin from './locales/en/login.json';
import enPermission from './locales/en/permission.json';
import enPersonal from './locales/en/personal.json';
import enSupport from './locales/en/support.json';
import enUpload from './locales/en/upload.json';
import zhAdmin from './locales/zh/admin.json';
import zhAsk from './locales/zh/ask.json';
@ -18,7 +20,9 @@ import zhExample from './locales/zh/example.json';
import zhFairclip from './locales/zh/fairclip.json';
import zhLanding from './locales/zh/landing.json';
import zhLogin from './locales/zh/login.json';
import zhPermission from './locales/zh/permission.json';
import zhPersonal from './locales/zh/personal.json';
import zhSupport from './locales/zh/support.json';
import zhUpload from './locales/zh/upload.json';
const translations = {
@ -31,7 +35,9 @@ const translations = {
fairclip: enFairclip,
landing: enLanding,
login: enLogin,
permission: enPermission,
personal: enPersonal,
support: enSupport,
upload: enUpload
},
zh: {
@ -43,7 +49,9 @@ const translations = {
fairclip: zhFairclip,
landing: zhLanding,
login: zhLogin,
permission: zhPermission,
personal: zhPersonal,
support: zhSupport,
upload: zhUpload
},
};

21
lib/PermissionService.ts Normal file
View File

@ -0,0 +1,21 @@
interface PermissionAlertOptions {
title: string;
message: string;
}
type ShowPermissionAlertFunction = (options: PermissionAlertOptions) => Promise<boolean>;
let showPermissionAlertRef: ShowPermissionAlertFunction | null = null;
export const PermissionService = {
set: (fn: ShowPermissionAlertFunction) => {
showPermissionAlertRef = fn;
},
show: (options: PermissionAlertOptions): Promise<boolean> => {
if (!showPermissionAlertRef) {
console.error("PermissionAlert has not been set. Please ensure PermissionProvider is used at the root of your app.");
return Promise.resolve(false);
}
return showPermissionAlertRef(options);
},
};

View File

@ -1,5 +1,6 @@
import pLimit from 'p-limit';
import { Alert } from 'react-native';
import { PermissionService } from '../PermissionService';
import i18n from '@/i18n';
import { getUploadTaskStatus, insertUploadTask } from '../db';
import { getMediaByDateRange } from './media';
import { ExtendedAsset } from './types';
@ -24,7 +25,7 @@ export const triggerManualUpload = async (
try {
const media = await getMediaByDateRange(startDate, endDate);
if (media.length === 0) {
Alert.alert('提示', '在指定时间范围内未找到媒体文件');
PermissionService.show({ title: i18n.t('permission:title.getMediaFailed'), message: i18n.t('permission:message.noMediaFound') });
return [];
}
@ -76,7 +77,7 @@ export const triggerManualUpload = async (
return finalResults;
} catch (error) {
console.error('手动上传过程中出现错误:', error);
Alert.alert('错误', '上传过程中出现错误');
PermissionService.show({ title: i18n.t('permission:title.error'), message: i18n.t('permission:message.uploadError') });
throw error;
}
};

View File

@ -0,0 +1,23 @@
import { DatabaseInterface } from './types';
import { SQLiteDatabase } from './sqlite-database';
class DatabaseFactory {
private static instance: DatabaseInterface | null = null;
static getInstance(): DatabaseInterface {
if (!this.instance) {
// Metro 会根据平台自动选择正确的文件
// Web: sqlite-database.web.ts
// Native: sqlite-database.ts
this.instance = new SQLiteDatabase();
}
return this.instance!;
}
// 用于测试或重置实例
static resetInstance(): void {
this.instance = null;
}
}
export const database = DatabaseFactory.getInstance();

View File

@ -0,0 +1,60 @@
// 数据库架构测试文件
import { database } from './database-factory';
import { UploadTask } from './types';
// 测试数据库基本功能
export async function testDatabase() {
console.log('开始测试数据库功能...');
try {
// 初始化数据库
await database.initUploadTable();
console.log('✓ 数据库初始化成功');
// 测试插入任务
const testTask: Omit<UploadTask, 'created_at'> = {
uri: 'test://example.jpg',
filename: 'example.jpg',
status: 'pending',
progress: 0,
file_id: undefined
};
await database.insertUploadTask(testTask);
console.log('✓ 任务插入成功');
// 测试查询任务
const retrievedTask = await database.getUploadTaskStatus(testTask.uri);
if (retrievedTask) {
console.log('✓ 任务查询成功:', retrievedTask);
} else {
console.log('✗ 任务查询失败');
}
// 测试更新任务状态
await database.updateUploadTaskStatus(testTask.uri, 'success', 'file123');
console.log('✓ 任务状态更新成功');
// 测试获取所有任务
const allTasks = await database.getUploadTasks();
console.log('✓ 获取所有任务成功,数量:', allTasks.length);
// 测试应用状态
await database.setAppState('test_key', 'test_value');
const stateValue = await database.getAppState('test_key');
console.log('✓ 应用状态测试成功:', stateValue);
// 清理测试数据
await database.cleanUpUploadTasks();
console.log('✓ 数据清理成功');
console.log('🎉 所有数据库测试通过!');
} catch (error) {
console.error('❌ 数据库测试失败:', error);
throw error;
}
}
// 导出测试函数供调用
export default testDatabase;

View File

@ -0,0 +1,9 @@
// 空的 SQLite 模块,用于 Web 环境
console.warn('SQLite is not available in web environment');
// 导出空的对象,避免导入错误
module.exports = {
openDatabaseSync: () => {
throw new Error('SQLite is not available in web environment');
}
};

6
lib/database/index.ts Normal file
View File

@ -0,0 +1,6 @@
// 数据库模块统一导出
export { DatabaseInterface, UploadTask } from './types';
export { WebDatabase } from './web-database';
export { SQLiteDatabase } from './sqlite-database';
export { database } from './database-factory';
export { testDatabase } from './database-test';

View File

@ -0,0 +1,156 @@
import { DatabaseInterface, UploadTask } from './types';
export class SQLiteDatabase implements DatabaseInterface {
private db: any;
constructor() {
// 动态导入避免在Web环境下加载
try {
const SQLite = require('expo-sqlite');
this.db = SQLite.openDatabaseSync('upload_status.db');
this.db.execSync('PRAGMA busy_timeout = 5000;');
} catch (error) {
console.error('Failed to initialize SQLite:', error);
throw new Error('SQLite is not available in this environment');
}
}
async initUploadTable(): Promise<void> {
console.log('Initializing upload tasks table...');
await this.db.execAsync(`
CREATE TABLE IF NOT EXISTS upload_tasks (
uri TEXT PRIMARY KEY NOT NULL,
filename TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
progress INTEGER NOT NULL DEFAULT 0,
file_id TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
`);
// Add created_at column to existing table if it doesn't exist
const columns = await this.db.getAllAsync('PRAGMA table_info(upload_tasks);');
const columnExists = columns.some((column: any) => column.name === 'created_at');
if (!columnExists) {
console.log('Adding created_at column to upload_tasks table...');
await this.db.execAsync(`ALTER TABLE upload_tasks ADD COLUMN created_at INTEGER;`);
await this.db.execAsync(`UPDATE upload_tasks SET created_at = (strftime('%s', 'now')) WHERE created_at IS NULL;`);
console.log('created_at column added and populated.');
}
console.log('Upload tasks table initialized');
await this.db.execAsync(`
CREATE TABLE IF NOT EXISTS app_state (
key TEXT PRIMARY KEY NOT NULL,
value TEXT
);
`);
console.log('App state table initialized');
}
async insertUploadTask(task: Omit<UploadTask, 'created_at'>): Promise<void> {
console.log('Inserting upload task:', task.uri);
await this.db.runAsync(
'INSERT OR REPLACE INTO upload_tasks (uri, filename, status, progress, file_id, created_at) VALUES (?, ?, ?, ?, ?, ?)',
[task.uri, task.filename, task.status, task.progress, task.file_id ?? null, Math.floor(Date.now() / 1000)]
);
}
async getUploadTaskStatus(uri: string): Promise<UploadTask | null> {
console.log('Checking upload task status for:', uri);
const result = await this.db.getFirstAsync(
'SELECT uri, filename, status, progress, file_id, created_at FROM upload_tasks WHERE uri = ?;',
uri
);
return result || null;
}
async updateUploadTaskStatus(uri: string, status: UploadTask['status'], file_id?: string): Promise<void> {
if (file_id) {
await this.db.runAsync('UPDATE upload_tasks SET status = ?, file_id = ? WHERE uri = ?', [status, file_id, uri]);
} else {
await this.db.runAsync('UPDATE upload_tasks SET status = ? WHERE uri = ?', [status, uri]);
}
}
async updateUploadTaskProgress(uri: string, progress: number): Promise<void> {
await this.db.runAsync('UPDATE upload_tasks SET progress = ? WHERE uri = ?', [progress, uri]);
}
async getUploadTasks(): Promise<UploadTask[]> {
console.log('Fetching all upload tasks... time:', new Date().toLocaleString());
const results = await this.db.getAllAsync(
'SELECT uri, filename, status, progress, file_id, created_at FROM upload_tasks ORDER BY created_at DESC;'
);
return results;
}
async cleanUpUploadTasks(): Promise<void> {
console.log('Cleaning up completed/failed upload tasks...');
await this.db.runAsync(
"DELETE FROM upload_tasks WHERE status = 'success' OR status = 'failed' OR status = 'skipped';"
);
}
async getUploadTasksSince(timestamp: number): Promise<UploadTask[]> {
const rows = await this.db.getAllAsync(
'SELECT * FROM upload_tasks WHERE created_at >= ? ORDER BY created_at DESC',
[timestamp]
);
return rows;
}
async exist_pending_tasks(): Promise<boolean> {
const rows = await this.db.getAllAsync(
'SELECT * FROM upload_tasks WHERE status = "pending" OR status = "uploading"'
);
return rows.length > 0;
}
async filterExistingFiles(fileUris: string[]): Promise<string[]> {
if (fileUris.length === 0) {
return [];
}
const placeholders = fileUris.map(() => '?').join(',');
const query = `SELECT uri FROM upload_tasks WHERE uri IN (${placeholders}) AND status = 'success'`;
const existingFiles = await this.db.getAllAsync(query, fileUris);
const existingUris = new Set(existingFiles.map((f: { uri: string }) => f.uri));
const newFileUris = fileUris.filter(uri => !existingUris.has(uri));
console.log(`[DB] Total files: ${fileUris.length}, Existing successful files: ${existingUris.size}, New files to upload: ${newFileUris.length}`);
return newFileUris;
}
async setAppState(key: string, value: string | null): Promise<void> {
console.log(`Setting app state: ${key} = ${value}`);
await this.db.runAsync('INSERT OR REPLACE INTO app_state (key, value) VALUES (?, ?)', [key, value]);
}
async getAppState(key: string): Promise<string | null> {
const result = await this.db.getFirstAsync('SELECT value FROM app_state WHERE key = ?;', key);
return result?.value ?? null;
}
async executeSql(sql: string, params: any[] = []): Promise<any> {
try {
const isSelect = sql.trim().toLowerCase().startsWith('select');
if (isSelect) {
const results = this.db.getAllSync(sql, params);
return results;
} else {
const result = this.db.runSync(sql, params);
return {
changes: result.changes,
lastInsertRowId: result.lastInsertRowId,
};
}
} catch (error: any) {
console.error("Error executing SQL:", error);
return { error: error.message };
}
}
}

View File

@ -0,0 +1,140 @@
import { DatabaseInterface, UploadTask } from './types';
// Web 环境下的 SQLite 数据库实现(实际使用 localStorage
export class SQLiteDatabase implements DatabaseInterface {
private getStorageKey(table: string): string {
return `memowake_${table}`;
}
private getUploadTasksFromStorage(): UploadTask[] {
const data = localStorage.getItem(this.getStorageKey('upload_tasks'));
return data ? JSON.parse(data) : [];
}
private saveUploadTasks(tasks: UploadTask[]): void {
localStorage.setItem(this.getStorageKey('upload_tasks'), JSON.stringify(tasks));
}
private getAppStateData(): Record<string, string> {
const data = localStorage.getItem(this.getStorageKey('app_state'));
return data ? JSON.parse(data) : {};
}
private saveAppStateData(state: Record<string, string>): void {
localStorage.setItem(this.getStorageKey('app_state'), JSON.stringify(state));
}
async initUploadTable(): Promise<void> {
console.log('Initializing web storage tables (SQLite fallback)...');
// Web端不需要初始化表结构localStorage会自动处理
}
async insertUploadTask(task: Omit<UploadTask, 'created_at'>): Promise<void> {
console.log('Inserting upload task:', task.uri);
const tasks = this.getUploadTasksFromStorage();
const existingIndex = tasks.findIndex(t => t.uri === task.uri);
const newTask: UploadTask = {
...task,
created_at: Math.floor(Date.now() / 1000)
};
if (existingIndex >= 0) {
tasks[existingIndex] = newTask;
} else {
tasks.push(newTask);
}
this.saveUploadTasks(tasks);
}
async getUploadTaskStatus(uri: string): Promise<UploadTask | null> {
console.log('Checking upload task status for:', uri);
const tasks = this.getUploadTasksFromStorage();
return tasks.find(t => t.uri === uri) || null;
}
async updateUploadTaskStatus(uri: string, status: UploadTask['status'], file_id?: string): Promise<void> {
const tasks = this.getUploadTasksFromStorage();
const taskIndex = tasks.findIndex(t => t.uri === uri);
if (taskIndex >= 0) {
tasks[taskIndex].status = status;
if (file_id) {
tasks[taskIndex].file_id = file_id;
}
this.saveUploadTasks(tasks);
}
}
async updateUploadTaskProgress(uri: string, progress: number): Promise<void> {
const tasks = this.getUploadTasksFromStorage();
const taskIndex = tasks.findIndex(t => t.uri === uri);
if (taskIndex >= 0) {
tasks[taskIndex].progress = progress;
this.saveUploadTasks(tasks);
}
}
async getUploadTasks(): Promise<UploadTask[]> {
console.log('Fetching all upload tasks... time:', new Date().toLocaleString());
const tasks = this.getUploadTasksFromStorage();
return tasks.sort((a, b) => b.created_at - a.created_at);
}
async cleanUpUploadTasks(): Promise<void> {
console.log('Cleaning up completed/failed upload tasks...');
const tasks = this.getUploadTasksFromStorage();
const filteredTasks = tasks.filter(t =>
t.status !== 'success' && t.status !== 'failed' && t.status !== 'skipped'
);
this.saveUploadTasks(filteredTasks);
}
async getUploadTasksSince(timestamp: number): Promise<UploadTask[]> {
const tasks = this.getUploadTasksFromStorage();
const filteredTasks = tasks.filter(t => t.created_at >= timestamp);
return filteredTasks.sort((a, b) => b.created_at - a.created_at);
}
async exist_pending_tasks(): Promise<boolean> {
const tasks = this.getUploadTasksFromStorage();
return tasks.some(t => t.status === 'pending' || t.status === 'uploading');
}
async filterExistingFiles(fileUris: string[]): Promise<string[]> {
if (fileUris.length === 0) {
return [];
}
const tasks = this.getUploadTasksFromStorage();
const successfulUris = new Set(
tasks.filter(t => t.status === 'success').map(t => t.uri)
);
const newFileUris = fileUris.filter(uri => !successfulUris.has(uri));
console.log(`[WebDB] Total files: ${fileUris.length}, Existing successful files: ${successfulUris.size}, New files to upload: ${newFileUris.length}`);
return newFileUris;
}
async setAppState(key: string, value: string | null): Promise<void> {
console.log(`Setting app state: ${key} = ${value}`);
const state = this.getAppStateData();
if (value === null) {
delete state[key];
} else {
state[key] = value;
}
this.saveAppStateData(state);
}
async getAppState(key: string): Promise<string | null> {
const state = this.getAppStateData();
return state[key] || null;
}
async executeSql(sql: string, params: any[] = []): Promise<any> {
console.warn('SQL execution not supported in web environment:', sql);
return { error: 'SQL execution not supported in web environment' };
}
}

24
lib/database/types.ts Normal file
View File

@ -0,0 +1,24 @@
export type UploadTask = {
uri: string;
filename: string;
status: 'pending' | 'uploading' | 'success' | 'failed' | 'skipped';
progress: number; // 0-100
file_id?: string; // 后端返回的文件ID
created_at: number; // unix timestamp
};
export interface DatabaseInterface {
initUploadTable(): Promise<void>;
insertUploadTask(task: Omit<UploadTask, 'created_at'>): Promise<void>;
getUploadTaskStatus(uri: string): Promise<UploadTask | null>;
updateUploadTaskStatus(uri: string, status: UploadTask['status'], file_id?: string): Promise<void>;
updateUploadTaskProgress(uri: string, progress: number): Promise<void>;
getUploadTasks(): Promise<UploadTask[]>;
cleanUpUploadTasks(): Promise<void>;
getUploadTasksSince(timestamp: number): Promise<UploadTask[]>;
exist_pending_tasks(): Promise<boolean>;
filterExistingFiles(fileUris: string[]): Promise<string[]>;
setAppState(key: string, value: string | null): Promise<void>;
getAppState(key: string): Promise<string | null>;
executeSql(sql: string, params?: any[]): Promise<any>;
}

View File

@ -0,0 +1,139 @@
import { DatabaseInterface, UploadTask } from './types';
export class WebDatabase implements DatabaseInterface {
private getStorageKey(table: string): string {
return `memowake_${table}`;
}
private getUploadTasksFromStorage(): UploadTask[] {
const data = localStorage.getItem(this.getStorageKey('upload_tasks'));
return data ? JSON.parse(data) : [];
}
private saveUploadTasks(tasks: UploadTask[]): void {
localStorage.setItem(this.getStorageKey('upload_tasks'), JSON.stringify(tasks));
}
private getAppStateData(): Record<string, string> {
const data = localStorage.getItem(this.getStorageKey('app_state'));
return data ? JSON.parse(data) : {};
}
private saveAppStateData(state: Record<string, string>): void {
localStorage.setItem(this.getStorageKey('app_state'), JSON.stringify(state));
}
async initUploadTable(): Promise<void> {
console.log('Initializing web storage tables...');
// Web端不需要初始化表结构localStorage会自动处理
}
async insertUploadTask(task: Omit<UploadTask, 'created_at'>): Promise<void> {
console.log('Inserting upload task:', task.uri);
const tasks = this.getUploadTasksFromStorage();
const existingIndex = tasks.findIndex(t => t.uri === task.uri);
const newTask: UploadTask = {
...task,
created_at: Math.floor(Date.now() / 1000)
};
if (existingIndex >= 0) {
tasks[existingIndex] = newTask;
} else {
tasks.push(newTask);
}
this.saveUploadTasks(tasks);
}
async getUploadTaskStatus(uri: string): Promise<UploadTask | null> {
console.log('Checking upload task status for:', uri);
const tasks = this.getUploadTasksFromStorage();
return tasks.find(t => t.uri === uri) || null;
}
async updateUploadTaskStatus(uri: string, status: UploadTask['status'], file_id?: string): Promise<void> {
const tasks = this.getUploadTasksFromStorage();
const taskIndex = tasks.findIndex(t => t.uri === uri);
if (taskIndex >= 0) {
tasks[taskIndex].status = status;
if (file_id) {
tasks[taskIndex].file_id = file_id;
}
this.saveUploadTasks(tasks);
}
}
async updateUploadTaskProgress(uri: string, progress: number): Promise<void> {
const tasks = this.getUploadTasksFromStorage();
const taskIndex = tasks.findIndex(t => t.uri === uri);
if (taskIndex >= 0) {
tasks[taskIndex].progress = progress;
this.saveUploadTasks(tasks);
}
}
async getUploadTasks(): Promise<UploadTask[]> {
console.log('Fetching all upload tasks... time:', new Date().toLocaleString());
const tasks = this.getUploadTasksFromStorage();
return tasks.sort((a, b) => b.created_at - a.created_at);
}
async cleanUpUploadTasks(): Promise<void> {
console.log('Cleaning up completed/failed upload tasks...');
const tasks = this.getUploadTasksFromStorage();
const filteredTasks = tasks.filter(t =>
t.status !== 'success' && t.status !== 'failed' && t.status !== 'skipped'
);
this.saveUploadTasks(filteredTasks);
}
async getUploadTasksSince(timestamp: number): Promise<UploadTask[]> {
const tasks = this.getUploadTasksFromStorage();
const filteredTasks = tasks.filter(t => t.created_at >= timestamp);
return filteredTasks.sort((a, b) => b.created_at - a.created_at);
}
async exist_pending_tasks(): Promise<boolean> {
const tasks = this.getUploadTasksFromStorage();
return tasks.some(t => t.status === 'pending' || t.status === 'uploading');
}
async filterExistingFiles(fileUris: string[]): Promise<string[]> {
if (fileUris.length === 0) {
return [];
}
const tasks = this.getUploadTasksFromStorage();
const successfulUris = new Set(
tasks.filter(t => t.status === 'success').map(t => t.uri)
);
const newFileUris = fileUris.filter(uri => !successfulUris.has(uri));
console.log(`[WebDB] Total files: ${fileUris.length}, Existing successful files: ${successfulUris.size}, New files to upload: ${newFileUris.length}`);
return newFileUris;
}
async setAppState(key: string, value: string | null): Promise<void> {
console.log(`Setting app state: ${key} = ${value}`);
const state = this.getAppStateData();
if (value === null) {
delete state[key];
} else {
state[key] = value;
}
this.saveAppStateData(state);
}
async getAppState(key: string): Promise<string | null> {
const state = this.getAppStateData();
return state[key] || null;
}
async executeSql(sql: string, params: any[] = []): Promise<any> {
console.warn('SQL execution not supported in web environment:', sql);
return { error: 'SQL execution not supported in web environment' };
}
}

195
lib/db.ts
View File

@ -1,176 +1,23 @@
import * as SQLite from 'expo-sqlite';
// 使用数据库接口架构,支持 Web 和移动端
import { database } from './database/database-factory';
import { UploadTask } from './database/types';
const db = SQLite.openDatabaseSync('upload_status.db');
// Set a busy timeout to handle concurrent writes and avoid "database is locked" errors.
// This will make SQLite wait for 5 seconds if the database is locked by another process.
db.execSync('PRAGMA busy_timeout = 5000;');
export type UploadTask = {
uri: string;
filename: string;
status: 'pending' | 'uploading' | 'success' | 'failed' | 'skipped';
progress: number; // 0-100
file_id?: string; // 后端返回的文件ID
created_at: number; // unix timestamp
};
// 初始化表
export async function initUploadTable() {
console.log('Initializing upload tasks table...');
await db.execAsync(`
CREATE TABLE IF NOT EXISTS upload_tasks (
uri TEXT PRIMARY KEY NOT NULL,
filename TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
progress INTEGER NOT NULL DEFAULT 0,
file_id TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
`);
// Add created_at column to existing table if it doesn't exist
const columns = await db.getAllAsync('PRAGMA table_info(upload_tasks);');
const columnExists = columns.some((column: any) => column.name === 'created_at');
if (!columnExists) {
console.log('Adding created_at column to upload_tasks table...');
// SQLite doesn't support non-constant DEFAULT values on ALTER TABLE.
// So we add the column, then update existing rows.
await db.execAsync(`ALTER TABLE upload_tasks ADD COLUMN created_at INTEGER;`);
await db.execAsync(`UPDATE upload_tasks SET created_at = (strftime('%s', 'now')) WHERE created_at IS NULL;`);
console.log('created_at column added and populated.');
}
console.log('Upload tasks table initialized');
await db.execAsync(`
CREATE TABLE IF NOT EXISTS app_state (
key TEXT PRIMARY KEY NOT NULL,
value TEXT
);
`);
console.log('App state table initialized');
}
// 插入新的上传任务
export async function insertUploadTask(task: Omit<UploadTask, 'created_at'>) {
console.log('Inserting upload task:', task.uri);
await db.runAsync(
'INSERT OR REPLACE INTO upload_tasks (uri, filename, status, progress, file_id, created_at) VALUES (?, ?, ?, ?, ?, ?)',
[task.uri, task.filename, task.status, task.progress, task.file_id ?? null, Math.floor(Date.now() / 1000)]
);
}
// 检查文件是否已上传或正在上传
export async function getUploadTaskStatus(uri: string): Promise<UploadTask | null> {
console.log('Checking upload task status for:', uri);
const result = await db.getFirstAsync<UploadTask>(
'SELECT uri, filename, status, progress, file_id, created_at FROM upload_tasks WHERE uri = ?;',
uri
);
return result || null;
}
// 更新上传任务的状态
export async function updateUploadTaskStatus(uri: string, status: UploadTask['status'], file_id?: string) {
if (file_id) {
await db.runAsync('UPDATE upload_tasks SET status = ?, file_id = ? WHERE uri = ?', [status, file_id, uri]);
} else {
await db.runAsync('UPDATE upload_tasks SET status = ? WHERE uri = ?', [status, uri]);
}
}
// 更新上传任务的进度
export async function updateUploadTaskProgress(uri: string, progress: number) {
await db.runAsync('UPDATE upload_tasks SET progress = ? WHERE uri = ?', [progress, uri]);
}
// 获取所有上传任务
export async function getUploadTasks(): Promise<UploadTask[]> {
console.log('Fetching all upload tasks... time:', new Date().toLocaleString());
const results = await db.getAllAsync<UploadTask>(
'SELECT uri, filename, status, progress, file_id, created_at FROM upload_tasks ORDER BY created_at DESC;'
);
return results;
}
// 清理已完成或失败的任务 (可选,根据需求添加)
export async function cleanUpUploadTasks(): Promise<void> {
console.log('Cleaning up completed/failed upload tasks...');
await db.runAsync(
"DELETE FROM upload_tasks WHERE status = 'success' OR status = 'failed' OR status = 'skipped';"
);
}
// 获取某个时间点之后的所有上传任务
export async function getUploadTasksSince(timestamp: number): Promise<UploadTask[]> {
const rows = await db.getAllAsync<UploadTask>(
'SELECT * FROM upload_tasks WHERE created_at >= ? ORDER BY created_at DESC',
[timestamp]
);
return rows;
}
export async function exist_pending_tasks(): Promise<boolean> {
const rows = await db.getAllAsync<UploadTask>(
'SELECT * FROM upload_tasks WHERE status = "pending" OR status = "uploading"'
);
return rows.length > 0;
}
// 检查一组文件URI返回那些在数据库中不存在或是未成功上传的文件的URI
export async function filterExistingFiles(fileUris: string[]): Promise<string[]> {
if (fileUris.length === 0) {
return [];
}
// 创建占位符字符串 '?,?,?'
const placeholders = fileUris.map(() => '?').join(',');
// 查询已经存在且状态为 'success' 的任务
const query = `SELECT uri FROM upload_tasks WHERE uri IN (${placeholders}) AND status = 'success'`;
const existingFiles = await db.getAllAsync<{ uri: string }>(query, fileUris);
const existingUris = new Set(existingFiles.map(f => f.uri));
// 过滤出新文件
const newFileUris = fileUris.filter(uri => !existingUris.has(uri));
console.log(`[DB] Total files: ${fileUris.length}, Existing successful files: ${existingUris.size}, New files to upload: ${newFileUris.length}`);
return newFileUris;
}
// 设置全局状态值
export async function setAppState(key: string, value: string | null): Promise<void> {
console.log(`Setting app state: ${key} = ${value}`);
await db.runAsync('INSERT OR REPLACE INTO app_state (key, value) VALUES (?, ?)', [key, value]);
}
// 获取全局状态值
export async function getAppState(key: string): Promise<string | null> {
const result = await db.getFirstAsync<{ value: string }>('SELECT value FROM app_state WHERE key = ?;', key);
return result?.value ?? null;
}
// for debug page
export async function executeSql(sql: string, params: any[] = []): Promise<any> {
try {
// Trim and check if it's a SELECT query
const isSelect = sql.trim().toLowerCase().startsWith('select');
if (isSelect) {
const results = db.getAllSync(sql, params);
return results;
} else {
const result = db.runSync(sql, params);
return {
changes: result.changes,
lastInsertRowId: result.lastInsertRowId,
};
}
} catch (error: any) {
console.error("Error executing SQL:", error);
return { error: error.message };
}
}
// 重新导出类型
export type { UploadTask };
// 重新导出所有数据库函数,使用统一接口
export const initUploadTable = () => database.initUploadTable();
export const insertUploadTask = (task: Omit<UploadTask, 'created_at'>) => database.insertUploadTask(task);
export const getUploadTaskStatus = (uri: string) => database.getUploadTaskStatus(uri);
export const updateUploadTaskStatus = (uri: string, status: UploadTask['status'], file_id?: string) =>
database.updateUploadTaskStatus(uri, status, file_id);
export const updateUploadTaskProgress = (uri: string, progress: number) =>
database.updateUploadTaskProgress(uri, progress);
export const getUploadTasks = () => database.getUploadTasks();
export const cleanUpUploadTasks = () => database.cleanUpUploadTasks();
export const getUploadTasksSince = (timestamp: number) => database.getUploadTasksSince(timestamp);
export const exist_pending_tasks = () => database.exist_pending_tasks();
export const filterExistingFiles = (fileUris: string[]) => database.filterExistingFiles(fileUris);
export const setAppState = (key: string, value: string | null) => database.setAppState(key, value);
export const getAppState = (key: string) => database.getAppState(key);
export const executeSql = (sql: string, params: any[] = []) => database.executeSql(sql, params);

View File

@ -25,7 +25,7 @@ export interface PagedResult<T> {
// 获取.env文件中的变量
export const API_ENDPOINT = Constants.expoConfig?.extra?.API_ENDPOINT || "http://192.168.31.115:18080/api";
export const API_ENDPOINT = Constants.expoConfig?.extra?.API_ENDPOINT || "https://api.memorywake.com/api";
// 更新 access_token 的逻辑 - 用于React组件中
export const useAuthToken = async<T>(message: string | null) => {

View File

@ -19,6 +19,15 @@ config.resolver = {
...config.resolver?.alias,
'@/': path.resolve(__dirname, './'),
},
platforms: ['ios', 'android', 'native', 'web'],
};
// Web 环境下的模块别名
if (process.env.EXPO_PLATFORM === 'web') {
config.resolver.alias = {
...config.resolver.alias,
'expo-sqlite': path.resolve(__dirname, './lib/database/empty-sqlite.js'),
};
}
module.exports = withNativeWind(config, { input: './global.css' });

View File

@ -0,0 +1,19 @@
// 重新导出 lib/background-uploader/types.ts 中的类型
export {
ExifData,
defaultExifData,
ImagesuploaderProps as ImagesPickerProps,
FileUploadItem,
ConfirmUpload,
UploadResult,
UploadUrlResponse,
} from '@/lib/background-uploader/types';
// 文件状态类型
export interface FileStatus {
file: File | null;
status: 'pending' | 'uploading' | 'success' | 'error';
progress: number;
error?: string;
id?: string;
}