feat(service): add foreground service for background message sync

Implement Android foreground service to keep app alive in background for real-time message sync:
- Add withForegroundService expo config plugin for notification channel setup
- Add BackgroundSyncManager with REALTIME, BATTERY_SAVER, and DISABLED modes
- Add ForegroundServiceModule for native Android foreground service binding
- Refactor backgroundService to integrate foreground service and expo-background-task

Also refactor auth and profile screens to use dynamic theme colors:
- Replace hardcoded THEME_COLORS with colors.primary.main, colors.background.paper, etc.
- Add useResolvedColorScheme for dynamic StatusBar styling
- Update NotificationSettingsScreen with sync mode selection UI

BREAKING CHANGE: Switched from expo-background-fetch to expo-background-task, minimum background sync interval changed from 900s to 15min in config
This commit is contained in:
lafay
2026-04-11 04:27:22 +08:00
parent 9bbed8cf5e
commit 6f84e17772
22 changed files with 1271 additions and 394 deletions

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "萝卜社区",
"slug": "qojo",
"version": "1.0.12",
"version": "1.0.13",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
@@ -36,7 +36,7 @@
},
"predictiveBackGestureEnabled": false,
"package": "skin.carrot.bbs",
"versionCode": 6,
"versionCode": 7,
"permissions": [
"VIBRATE",
"RECORD_AUDIO",
@@ -63,6 +63,16 @@
},
"plugins": [
"./plugins/withMainActivityConfigChange",
[
"./plugins/withForegroundService",
{
"notificationTitle": "萝卜社区",
"notificationBody": "正在后台同步消息",
"notificationIcon": "ic_notification",
"channelId": "carrot_sync_foreground",
"channelName": "消息同步"
}
],
[
"expo-router",
{
@@ -86,9 +96,9 @@
}
],
[
"expo-background-fetch",
"expo-background-task",
{
"minimumInterval": 900
"minimumInterval": 15
}
],
[

View File

@@ -1,6 +1,6 @@
{
"name": "carrot_bbs",
"version": "1.0.2",
"version": "1.0.3",
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
@@ -25,7 +25,6 @@
"axios": "^1.13.6",
"date-fns": "^4.1.0",
"expo": "~55.0.4",
"expo-background-fetch": "~55.0.10",
"expo-background-task": "~55.0.10",
"expo-camera": "^55.0.10",
"expo-constants": "~55.0.7",

View File

@@ -0,0 +1,467 @@
const { withAndroidManifest, withDangerousMod } = require('@expo/config-plugins');
const fs = require('fs');
const path = require('path');
const FOREGROUND_SERVICE_PERMISSION = 'android.permission.FOREGROUND_SERVICE';
const FOREGROUND_SERVICE_DATA_SYNC = 'android.permission.FOREGROUND_SERVICE_DATA_SYNC';
/**
* Expo Config Plugin为 Android 添加前台服务保活
*
* 功能:
* 1. 添加 FOREGROUND_SERVICE 相关权限
* 2. 在 AndroidManifest 中声明 Service
* 3. 生成 Kotlin 原生服务代码
* 4. 生成 React Native Bridge 模块
*
* 参考Element Android 的 GuardAndroidService
*/
const withForegroundService = (config, options = {}) => {
const {
notificationTitle = '萝卜社区',
notificationBody = '正在后台同步消息',
notificationIcon = 'ic_notification',
channelId = 'carrot_sync_foreground',
channelName = '消息同步',
} = options;
// 1. 添加权限和 Service 声明到 AndroidManifest
config = withAndroidManifest(config, (config) => {
const manifest = config.modResults;
// 添加权限声明
const permissions = [
FOREGROUND_SERVICE_PERMISSION,
FOREGROUND_SERVICE_DATA_SYNC,
];
permissions.forEach((permission) => {
const existingPermission = manifest.manifest['uses-permission']?.find(
(p) => p.$['android:name'] === permission
);
if (!existingPermission) {
if (!manifest.manifest['uses-permission']) {
manifest.manifest['uses-permission'] = [];
}
manifest.manifest['uses-permission'].push({
$: { 'android:name': permission },
});
}
});
// 添加 Service 声明
if (!manifest.manifest.application[0].service) {
manifest.manifest.application[0].service = [];
}
const existingService = manifest.manifest.application[0].service.find(
(s) => s.$['android:name'] === '.SyncForegroundService'
);
if (!existingService) {
manifest.manifest.application[0].service.push({
$: {
'android:name': '.SyncForegroundService',
'android:enabled': 'true',
'android:exported': 'false',
'android:foregroundServiceType': 'dataSync',
},
});
}
return config;
});
// 2. 生成 Kotlin 原生代码
config = withDangerousMod(config, [
'android',
async (config) => {
const projectRoot = config.modRequest.projectRoot;
const packageName = config.android.package;
const kotlinPath = path.join(
projectRoot,
'android/app/src/main/java',
packageName.replace(/\./g, '/')
);
// 确保目录存在
if (!fs.existsSync(kotlinPath)) {
fs.mkdirSync(kotlinPath, { recursive: true });
}
// 生成 SyncForegroundService.kt
const serviceCode = generateServiceCode(packageName, {
notificationTitle,
notificationBody,
notificationIcon,
channelId,
channelName,
});
fs.writeFileSync(
path.join(kotlinPath, 'SyncForegroundService.kt'),
serviceCode
);
// 生成 ForegroundServiceModule.kt (React Native Bridge)
const moduleCode = generateModuleCode(packageName);
fs.writeFileSync(
path.join(kotlinPath, 'ForegroundServiceModule.kt'),
moduleCode
);
// 生成 ForegroundServicePackage.kt
const packageCode = generatePackageCode(packageName);
fs.writeFileSync(
path.join(kotlinPath, 'ForegroundServicePackage.kt'),
packageCode
);
// 修改 MainApplication.kt 注册模块
const mainAppPath = path.join(kotlinPath, 'MainApplication.kt');
if (fs.existsSync(mainAppPath)) {
let content = fs.readFileSync(mainAppPath, 'utf-8');
// 添加 import
if (!content.includes('ForegroundServicePackage')) {
content = content.replace(
/package .*\n/,
`$&\nimport ${packageName}.ForegroundServicePackage\n`
);
}
// 在 packages 列表中添加
if (!content.includes('ForegroundServicePackage()')) {
// 查找 PackageList(this).packages.apply 块
content = content.replace(
/(PackageList\(this\)\.packages\.apply\s*\{)/,
`$1\n // 前台服务模块\n add(ForegroundServicePackage())`
);
}
fs.writeFileSync(mainAppPath, content);
}
// 创建通知图标目录和文件
const drawablePath = path.join(projectRoot, 'android/app/src/main/res/drawable');
if (!fs.existsSync(drawablePath)) {
fs.mkdirSync(drawablePath, { recursive: true });
}
const iconPath = path.join(drawablePath, 'ic_notification.xml');
if (!fs.existsSync(iconPath)) {
fs.writeFileSync(iconPath, generateNotificationIcon());
}
return config;
},
]);
return config;
};
/**
* 生成前台服务 Kotlin 代码
*/
function generateServiceCode(packageName, options) {
const { notificationTitle, notificationBody, notificationIcon, channelId, channelName } = options;
return `package ${packageName}
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
/**
* 前台同步服务
*
* 在通知栏显示常驻通知,防止应用进程被系统杀死
* 类似 Element Android 的 GuardAndroidService 和 V2Ray 的保活机制
*/
class SyncForegroundService : Service() {
companion object {
const val NOTIFICATION_ID = 1001
const val CHANNEL_ID = "${channelId}"
const val CHANNEL_NAME = "${channelName}"
const val ACTION_START = "${packageName}.foreground.START"
const val ACTION_STOP = "${packageName}.foreground.STOP"
const val ACTION_UPDATE = "${packageName}.foreground.UPDATE"
const val EXTRA_TITLE = "title"
const val EXTRA_BODY = "body"
/**
* 启动前台服务
*/
fun start(context: Context, title: String = "${notificationTitle}", body: String = "${notificationBody}") {
val intent = Intent(context, SyncForegroundService::class.java).apply {
action = ACTION_START
putExtra(EXTRA_TITLE, title)
putExtra(EXTRA_BODY, body)
}
try {
ContextCompat.startForegroundService(context, intent)
} catch (e: Exception) {
android.util.Log.e("SyncForegroundService", "启动前台服务失败", e)
}
}
/**
* 停止前台服务
*/
fun stop(context: Context) {
val intent = Intent(context, SyncForegroundService::class.java).apply {
action = ACTION_STOP
}
context.startService(intent)
}
/**
* 更新通知内容
*/
fun update(context: Context, title: String, body: String) {
val intent = Intent(context, SyncForegroundService::class.java).apply {
action = ACTION_UPDATE
putExtra(EXTRA_TITLE, title)
putExtra(EXTRA_BODY, body)
}
context.startService(intent)
}
}
private var notificationTitle: String = "${notificationTitle}"
private var notificationBody: String = "${notificationBody}"
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START -> {
notificationTitle = intent.getStringExtra(EXTRA_TITLE) ?: "${notificationTitle}"
notificationBody = intent.getStringExtra(EXTRA_BODY) ?: "${notificationBody}"
startForegroundCompat()
}
ACTION_STOP -> {
stopForegroundCompat()
stopSelf()
}
ACTION_UPDATE -> {
notificationTitle = intent.getStringExtra(EXTRA_TITLE) ?: notificationTitle
notificationBody = intent.getStringExtra(EXTRA_BODY) ?: notificationBody
updateNotification()
}
}
// START_STICKY: 服务被杀后自动重启
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
/**
* 启动前台服务(兼容 Android Q+
*/
private fun startForegroundCompat() {
val notification = buildNotification()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
/**
* 停止前台服务(兼容 Android N+
*/
private fun stopForegroundCompat() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
@Suppress("DEPRECATION")
stopForeground(true)
}
}
/**
* 创建通知渠道
*/
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_MIN // 最低重要性,不打扰用户
).apply {
description = "后台消息同步服务"
setSound(null, null)
setShowBadge(false)
enableLights(false)
enableVibration(false)
}
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
}
}
/**
* 构建通知
*/
private fun buildNotification(): Notification {
// 点击通知打开应用
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
val pendingIntent = PendingIntent.getActivity(
this,
0,
launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(notificationTitle)
.setContentText(notificationBody)
.setSmallIcon(R.drawable.${notificationIcon})
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_MIN)
.setOngoing(true) // 不可滑动清除
.setShowWhen(false)
.setContentIntent(pendingIntent)
.build()
}
/**
* 更新通知内容
*/
private fun updateNotification() {
val manager = getSystemService(NotificationManager::class.java)
manager.notify(NOTIFICATION_ID, buildNotification())
}
}
`;
}
/**
* 生成 React Native Bridge 模块代码
*/
function generateModuleCode(packageName) {
return `package ${packageName}
import android.content.Context
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.Promise
/**
* React Native Bridge: 前台服务控制模块
*/
class ForegroundServiceModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
override fun getName(): String = "ForegroundService"
/**
* 启动前台服务
*/
@ReactMethod
fun start(title: String, body: String, promise: Promise) {
try {
SyncForegroundService.start(reactApplicationContext, title, body)
promise.resolve(true)
} catch (e: Exception) {
promise.reject("FOREGROUND_SERVICE_ERROR", e.message)
}
}
/**
* 停止前台服务
*/
@ReactMethod
fun stop(promise: Promise) {
try {
SyncForegroundService.stop(reactApplicationContext)
promise.resolve(true)
} catch (e: Exception) {
promise.reject("FOREGROUND_SERVICE_ERROR", e.message)
}
}
/**
* 更新通知内容
*/
@ReactMethod
fun update(title: String, body: String, promise: Promise) {
try {
SyncForegroundService.update(reactApplicationContext, title, body)
promise.resolve(true)
} catch (e: Exception) {
promise.reject("FOREGROUND_SERVICE_ERROR", e.message)
}
}
}
`;
}
/**
* 生成 React Native Package 代码
*/
function generatePackageCode(packageName) {
return `package ${packageName}
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class ForegroundServicePackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(ForegroundServiceModule(reactContext))
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
}
`;
}
/**
* 生成通知图标 XML
*/
function generateNotificationIcon() {
return `<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- 背景圆 -->
<path
android:fillColor="#4CAF50"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2z"/>
<!-- 对勾符号 -->
<path
android:fillColor="#FFFFFF"
android:pathData="M10,17l-5,-5 1.41,-1.41L10,14.17l7.59,-7.59L19,8l-9,9z"/>
</vector>
`;
}
module.exports = withForegroundService;

View File

@@ -125,12 +125,12 @@ function createSystemMessageStyles(colors: AppColors) {
flexDirection: 'row',
alignItems: 'flex-start',
padding: 16,
backgroundColor: '#fff',
backgroundColor: colors.background.paper,
borderBottomWidth: 1,
borderBottomColor: colors.divider || '#E5E5EA',
},
unreadContainer: {
backgroundColor: '#FFF8F6',
backgroundColor: colors.primary.main + '0A',
},
unreadIndicator: {
position: 'absolute',
@@ -140,7 +140,7 @@ function createSystemMessageStyles(colors: AppColors) {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: '#FF6B35',
backgroundColor: colors.primary.main,
},
iconContainer: {
marginRight: 14,
@@ -165,7 +165,7 @@ function createSystemMessageStyles(colors: AppColors) {
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: '#fff',
borderColor: colors.background.paper,
},
content: {
flex: 1,
@@ -220,7 +220,7 @@ function createSystemMessageStyles(colors: AppColors) {
flexDirection: 'row',
alignItems: 'center',
marginTop: 8,
backgroundColor: '#F5F5F7',
backgroundColor: colors.background.default,
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 8,
@@ -318,13 +318,6 @@ const getSystemMessageTitle = (message: SystemMessageResponse): string => {
}
};
// 胡萝卜橙主题色
const THEME_COLORS = {
primary: '#FF6B35',
primaryLight: '#FF8C5A',
primaryDark: '#E55A2B',
};
const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
message,
onPress,

View File

@@ -26,19 +26,13 @@ import {
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useAppColors, type AppColors } from '../../theme';
import { useAppColors, useResolvedColorScheme, type AppColors } from '../../theme';
import { authService, resolveAuthApiError } from '../../services/authService';
import { showPrompt } from '../../services/promptService';
// 胡萝卜橙主题色
const THEME_COLORS = {
primary: '#FF6B35',
primaryLight: '#FF8C5A',
primaryDark: '#E55A2B',
};
export const ForgotPasswordScreen: React.FC = () => {
const colors = useAppColors();
const resolved = useResolvedColorScheme();
const styles = useMemo(() => createForgotPasswordStyles(colors), [colors]);
const router = useRouter();
@@ -156,7 +150,7 @@ export const ForgotPasswordScreen: React.FC = () => {
return (
<SafeAreaView style={styles.container}>
<StatusBar barStyle="dark-content" backgroundColor="#fff" />
<StatusBar barStyle={resolved === 'dark' ? 'light-content' : 'dark-content'} backgroundColor={colors.background.paper} />
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardView}
@@ -198,7 +192,7 @@ export const ForgotPasswordScreen: React.FC = () => {
keyboardType="email-address"
/>
{email.length > 0 && validateEmail(email) && (
<MaterialCommunityIcons name="check-circle" size={20} color={THEME_COLORS.primary} style={styles.checkIcon} />
<MaterialCommunityIcons name="check-circle" size={20} color={colors.primary.main} style={styles.checkIcon} />
)}
</View>
</View>
@@ -225,7 +219,7 @@ export const ForgotPasswordScreen: React.FC = () => {
activeOpacity={0.8}
>
{sendingCode ? (
<ActivityIndicator size="small" color={THEME_COLORS.primary} />
<ActivityIndicator size="small" color={colors.primary.main} />
) : (
<Text style={styles.sendCodeButtonText}>{countdown > 0 ? `${countdown}s` : '获取验证码'}</Text>
)}
@@ -295,7 +289,7 @@ export const ForgotPasswordScreen: React.FC = () => {
activeOpacity={0.9}
>
{loading ? (
<ActivityIndicator size="small" color="#fff" />
<ActivityIndicator size="small" color={colors.text.inverse} />
) : (
<Text style={styles.submitButtonText}></Text>
)}
@@ -307,7 +301,7 @@ export const ForgotPasswordScreen: React.FC = () => {
onPress={() => router.back()}
activeOpacity={0.8}
>
<MaterialCommunityIcons name="arrow-left" size={18} color={THEME_COLORS.primary} style={{ marginRight: 4 }} />
<MaterialCommunityIcons name="arrow-left" size={18} color={colors.primary.main} style={{ marginRight: 4 }} />
<Text style={styles.backButtonText}></Text>
</TouchableOpacity>
</View>
@@ -322,7 +316,7 @@ function createForgotPasswordStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
backgroundColor: colors.background.paper,
},
keyboardView: {
flex: 1,
@@ -354,7 +348,7 @@ function createForgotPasswordStyles(colors: AppColors) {
underline: {
width: 40,
height: 4,
backgroundColor: THEME_COLORS.primary,
backgroundColor: colors.primary.main,
borderRadius: 2,
marginTop: 12,
marginBottom: 16,
@@ -382,7 +376,7 @@ function createForgotPasswordStyles(colors: AppColors) {
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#F5F5F7',
backgroundColor: colors.background.default,
borderRadius: 14,
paddingHorizontal: 18,
height: 56,
@@ -412,7 +406,7 @@ function createForgotPasswordStyles(colors: AppColors) {
sendCodeButton: {
height: 56,
paddingHorizontal: 16,
backgroundColor: 'rgba(255, 107, 53, 0.1)',
backgroundColor: colors.primary.light + '20',
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
@@ -424,14 +418,14 @@ function createForgotPasswordStyles(colors: AppColors) {
sendCodeButtonText: {
fontSize: 14,
fontWeight: '600',
color: THEME_COLORS.primary,
color: colors.primary.main,
},
// 提交按钮
submitButton: {
height: 56,
borderRadius: 14,
backgroundColor: THEME_COLORS.primary,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
marginTop: 8,
@@ -440,7 +434,7 @@ function createForgotPasswordStyles(colors: AppColors) {
opacity: 0.7,
},
submitButtonText: {
color: '#fff',
color: colors.text.inverse,
fontSize: 17,
fontWeight: '600',
},
@@ -453,7 +447,7 @@ function createForgotPasswordStyles(colors: AppColors) {
marginTop: 24,
},
backButtonText: {
color: THEME_COLORS.primary,
color: colors.primary.main,
fontSize: 15,
fontWeight: '500',
},

View File

@@ -26,21 +26,17 @@ import {
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useAppColors, type AppColors } from '../../theme';
import { useAppColors, useResolvedColorScheme, type AppColors } from '../../theme';
import { useAuthStore } from '../../stores';
import * as hrefs from '../../navigation/hrefs';
import { useResponsive } from '../../hooks';
import { showPrompt } from '../../services/promptService';
// 胡萝卜橙主题色
const THEME_COLORS = {
primary: '#FF6B35',
primaryLight: '#FF8C5A',
primaryDark: '#E55A2B',
};
export const LoginScreen: React.FC = () => {
const colors = useAppColors();
const resolved = useResolvedColorScheme();
const styles = useMemo(() => createLoginStyles(colors), [colors]);
const router = useRouter();
const login = useAuthStore((state) => state.login);
@@ -129,7 +125,7 @@ export const LoginScreen: React.FC = () => {
return (
<SafeAreaView style={styles.container}>
<StatusBar barStyle="dark-content" backgroundColor="#fff" />
<StatusBar barStyle={resolved === 'dark' ? 'light-content' : 'dark-content'} backgroundColor={colors.background.paper} />
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardView}
@@ -175,7 +171,7 @@ export const LoginScreen: React.FC = () => {
returnKeyType="next"
/>
{username.length > 0 && (
<MaterialCommunityIcons name="check-circle" size={20} color={THEME_COLORS.primary} style={styles.checkIcon} />
<MaterialCommunityIcons name="check-circle" size={20} color={colors.primary.main} style={styles.checkIcon} />
)}
</View>
</View>
@@ -218,17 +214,17 @@ export const LoginScreen: React.FC = () => {
{/* 服务条款勾选 */}
<View style={styles.termsContainer}>
<TouchableOpacity
onPress={() => setAgreedToTerms(!agreedToTerms)}
activeOpacity={0.8}
style={styles.checkboxWrapper}
>
<MaterialCommunityIcons
name={agreedToTerms ? 'checkbox-marked' : 'checkbox-blank-outline'}
size={22}
color={agreedToTerms ? THEME_COLORS.primary : colors.text.hint}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => setAgreedToTerms(!agreedToTerms)}
activeOpacity={0.8}
style={styles.checkboxWrapper}
>
<MaterialCommunityIcons
name={agreedToTerms ? 'checkbox-marked' : 'checkbox-blank-outline'}
size={22}
color={agreedToTerms ? colors.primary.main : colors.text.hint}
/>
</TouchableOpacity>
<Text style={styles.termsText}>
<Text
@@ -255,7 +251,7 @@ export const LoginScreen: React.FC = () => {
activeOpacity={0.9}
>
{loading ? (
<ActivityIndicator size="small" color="#FFF" />
<ActivityIndicator size="small" color={colors.text.inverse} />
) : (
<Text style={styles.loginButtonText}> </Text>
)}
@@ -282,7 +278,7 @@ function createLoginStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
backgroundColor: colors.background.paper,
},
keyboardView: {
flex: 1,
@@ -317,7 +313,7 @@ function createLoginStyles(colors: AppColors) {
underline: {
width: 40,
height: 4,
backgroundColor: THEME_COLORS.primary,
backgroundColor: colors.primary.main,
borderRadius: 2,
marginTop: 12,
marginBottom: 16,
@@ -345,7 +341,7 @@ function createLoginStyles(colors: AppColors) {
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#F5F5F7',
backgroundColor: colors.background.default,
borderRadius: 14,
paddingHorizontal: 18,
height: 56,
@@ -372,7 +368,7 @@ function createLoginStyles(colors: AppColors) {
},
forgotPasswordText: {
fontSize: 14,
color: THEME_COLORS.primary,
color: colors.primary.main,
fontWeight: '500',
},
@@ -380,7 +376,7 @@ function createLoginStyles(colors: AppColors) {
loginButton: {
height: 56,
borderRadius: 14,
backgroundColor: THEME_COLORS.primary,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
},
@@ -390,7 +386,7 @@ function createLoginStyles(colors: AppColors) {
loginButtonText: {
fontSize: 17,
fontWeight: '600',
color: '#FFFFFF',
color: colors.text.inverse,
},
// 底部
@@ -406,7 +402,7 @@ function createLoginStyles(colors: AppColors) {
},
registerLink: {
fontSize: 15,
color: THEME_COLORS.primary,
color: colors.primary.main,
fontWeight: '600',
marginLeft: 6,
},
@@ -419,7 +415,7 @@ function createLoginStyles(colors: AppColors) {
color: colors.text.hint,
},
policyLink: {
color: THEME_COLORS.primary,
color: colors.primary.main,
fontWeight: '500',
},
termsContainer: {
@@ -439,7 +435,7 @@ function createLoginStyles(colors: AppColors) {
lineHeight: 22,
},
termsLink: {
color: THEME_COLORS.primary,
color: colors.primary.main,
fontWeight: '500',
},
});

View File

@@ -25,7 +25,7 @@ import {
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useAppColors, type AppColors } from '../../theme';
import { useAppColors, useResolvedColorScheme, type AppColors } from '../../theme';
import { authService, resolveAuthApiError } from '../../services/authService';
import { useAuthStore } from '../../stores';
import {
@@ -58,6 +58,7 @@ const STEP_COMPONENTS = [
export const RegisterScreen: React.FC = () => {
const colors = useAppColors();
const resolved = useResolvedColorScheme();
const styles = useMemo(() => createRegisterStyles(colors), [colors]);
const router = useRouter();
const register = useAuthStore((state) => state.register);
@@ -232,7 +233,7 @@ export const RegisterScreen: React.FC = () => {
return (
<SafeAreaView style={styles.container}>
<StatusBar barStyle="dark-content" backgroundColor="#fff" />
<StatusBar barStyle={resolved === 'dark' ? 'light-content' : 'dark-content'} backgroundColor={colors.background.paper} />
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardView}
@@ -295,7 +296,7 @@ function createRegisterStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
backgroundColor: colors.background.paper,
},
keyboardView: {
flex: 1,
@@ -316,7 +317,7 @@ function createRegisterStyles(colors: AppColors) {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#F5F5F7',
backgroundColor: colors.background.default,
alignItems: 'center',
justifyContent: 'center',
},
@@ -346,7 +347,7 @@ function createRegisterStyles(colors: AppColors) {
},
loginLink: {
fontSize: 15,
color: '#FF6B35',
color: colors.primary.main,
fontWeight: '600',
marginLeft: 6,
},

View File

@@ -15,12 +15,6 @@ import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useAppColors, type AppColors } from '../../theme';
import { RegisterStepProps } from './types';
const THEME_COLORS = {
primary: '#FF6B35',
primaryLight: '#FF8C5A',
primaryDark: '#E55A2B',
};
export const RegisterStep1Email: React.FC<RegisterStepProps> = ({
formData,
updateFormData,
@@ -91,7 +85,7 @@ export const RegisterStep1Email: React.FC<RegisterStepProps> = ({
<MaterialCommunityIcons
name="check-circle"
size={20}
color={THEME_COLORS.primary}
color={colors.primary.main}
style={styles.checkIcon}
/>
)}
@@ -145,7 +139,7 @@ function createStyles(colors: AppColors) {
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#F5F5F7',
backgroundColor: colors.background.default,
borderRadius: 14,
paddingHorizontal: 18,
height: 56,
@@ -173,7 +167,7 @@ function createStyles(colors: AppColors) {
actionButton: {
height: 56,
borderRadius: 14,
backgroundColor: '#FF6B35',
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
marginTop: 8,
@@ -184,7 +178,7 @@ function createStyles(colors: AppColors) {
actionButtonText: {
fontSize: 17,
fontWeight: '600',
color: '#FFFFFF',
color: colors.text.inverse,
},
});
}

View File

@@ -1,8 +1,3 @@
/**
* 注册步骤2填写验证码
* 使用 VerificationCodeInput 组件
*/
import React, { useState } from 'react';
import {
View,
@@ -15,12 +10,6 @@ import { useAppColors, type AppColors } from '../../theme';
import { VerificationCodeInput } from '../../components/common';
import { RegisterStepProps } from './types';
const THEME_COLORS = {
primary: '#FF6B35',
primaryLight: '#FF8C5A',
primaryDark: '#E55A2B',
};
export const RegisterStep2Verification: React.FC<RegisterStepProps> = ({
formData,
updateFormData,
@@ -175,13 +164,13 @@ function createStyles(colors: AppColors) {
},
resendText: {
fontSize: 14,
color: THEME_COLORS.primary,
color: colors.primary.main,
fontWeight: '500',
},
actionButton: {
height: 56,
borderRadius: 14,
backgroundColor: '#FF6B35',
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
width: '100%',
@@ -193,7 +182,7 @@ function createStyles(colors: AppColors) {
actionButtonText: {
fontSize: 17,
fontWeight: '600',
color: '#FFFFFF',
color: colors.text.inverse,
},
backButton: {
flexDirection: 'row',

View File

@@ -1,8 +1,3 @@
/**
* 注册步骤3设置用户信息
* 包含用户名、昵称、密码等
*/
import React, { useState } from 'react';
import {
View,
@@ -18,12 +13,6 @@ import { useAppColors, type AppColors } from '../../theme';
import { RegisterStepProps } from './types';
import * as hrefs from '../../navigation/hrefs';
const THEME_COLORS = {
primary: '#FF6B35',
primaryLight: '#FF8C5A',
primaryDark: '#E55A2B',
};
export const RegisterStep3Profile: React.FC<RegisterStepProps> = ({
formData,
updateFormData,
@@ -208,7 +197,7 @@ export const RegisterStep3Profile: React.FC<RegisterStepProps> = ({
<MaterialCommunityIcons
name={agreedToTerms ? 'checkbox-marked' : 'checkbox-blank-outline'}
size={22}
color={agreedToTerms ? THEME_COLORS.primary : colors.text?.hint || '#999'}
color={agreedToTerms ? colors.primary.main : colors.text?.hint || '#999'}
/>
</TouchableOpacity>
<Text style={styles.termsText}>
@@ -288,7 +277,7 @@ function createStyles(colors: AppColors) {
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#F5F5F7',
backgroundColor: colors.background.default,
borderRadius: 14,
paddingHorizontal: 18,
height: 52,
@@ -331,13 +320,13 @@ function createStyles(colors: AppColors) {
lineHeight: 22,
},
termsLink: {
color: '#FF6B35',
color: colors.primary.main,
fontWeight: '500',
},
actionButton: {
height: 56,
borderRadius: 14,
backgroundColor: '#FF6B35',
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
},
@@ -347,7 +336,7 @@ function createStyles(colors: AppColors) {
actionButtonText: {
fontSize: 17,
fontWeight: '600',
color: '#FFFFFF',
color: colors.text.inverse,
},
backButton: {
flexDirection: 'row',

View File

@@ -26,12 +26,6 @@ import { showPrompt } from '../../services/promptService';
import { uploadService } from '../../services/uploadService';
import type { UserIdentity } from '../../types/dto';
const THEME_COLORS = {
primary: '#FF6B35',
primaryLight: '#FF8C5A',
primaryDark: '#E55A2B',
};
const IDENTITY_CONFIG: Record<string, { title: string; idField: string; idLabel: string }> = {
student: { title: '在校学生', idField: 'student_id', idLabel: '学号' },
teacher: { title: '教职工', idField: 'employee_id', idLabel: '工号' },
@@ -230,7 +224,7 @@ export const VerificationFormScreen: React.FC = () => {
<MaterialCommunityIcons
name="information-outline"
size={20}
color={THEME_COLORS.primary}
color={colors.primary.main}
/>
<Text style={styles.infoText}>
@@ -303,7 +297,7 @@ export const VerificationFormScreen: React.FC = () => {
disabled={isLoading || uploading}
>
{(isLoading || uploading) ? (
<ActivityIndicator size="small" color="#FFF" />
<ActivityIndicator size="small" color={colors.text.inverse} />
) : (
<Text style={styles.submitButtonText}></Text>
)}
@@ -317,7 +311,7 @@ function createStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
backgroundColor: colors.background.paper,
},
header: {
flexDirection: 'row',
@@ -349,7 +343,7 @@ function createStyles(colors: AppColors) {
infoCard: {
flexDirection: 'row',
alignItems: 'flex-start',
backgroundColor: '#FFF5F0',
backgroundColor: colors.primary.main + '15',
borderRadius: 12,
padding: 16,
marginBottom: 24,
@@ -374,7 +368,7 @@ function createStyles(colors: AppColors) {
marginBottom: 8,
},
required: {
color: THEME_COLORS.primary,
color: colors.primary.main,
},
hintText: {
fontSize: 13,
@@ -382,7 +376,7 @@ function createStyles(colors: AppColors) {
marginBottom: 8,
},
inputWrapper: {
backgroundColor: '#F5F5F7',
backgroundColor: colors.background.default,
borderRadius: 12,
paddingHorizontal: 16,
height: 50,
@@ -404,7 +398,7 @@ function createStyles(colors: AppColors) {
marginTop: 6,
},
uploadButton: {
backgroundColor: '#F5F5F7',
backgroundColor: colors.background.default,
borderRadius: 12,
overflow: 'hidden',
minHeight: 200,
@@ -433,7 +427,7 @@ function createStyles(colors: AppColors) {
submitButton: {
height: 56,
borderRadius: 14,
backgroundColor: THEME_COLORS.primary,
backgroundColor: colors.primary.main,
justifyContent: 'center',
alignItems: 'center',
},
@@ -443,7 +437,7 @@ function createStyles(colors: AppColors) {
submitButtonText: {
fontSize: 17,
fontWeight: '600',
color: '#FFFFFF',
color: colors.text.inverse,
},
});
}

View File

@@ -1,8 +1,3 @@
/**
* 注册后身份认证引导页面
* 可跳过的认证界面,引导用户完成身份认证
*/
import React, { useState } from 'react';
import {
View,
@@ -10,7 +5,6 @@ import {
StyleSheet,
TouchableOpacity,
ScrollView,
Image,
Alert,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
@@ -19,12 +13,6 @@ import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useAppColors, type AppColors } from '../../theme';
import * as hrefs from '../../navigation/hrefs';
const THEME_COLORS = {
primary: '#FF6B35',
primaryLight: '#FF8C5A',
primaryDark: '#E55A2B',
};
// 身份类型选项
const IDENTITY_OPTIONS = [
{
@@ -98,7 +86,7 @@ export const VerificationGuideScreen: React.FC = () => {
<MaterialCommunityIcons
name={option.icon as any}
size={32}
color={isSelected ? THEME_COLORS.primary : colors.text?.secondary || '#666'}
color={isSelected ? colors.primary.main : colors.text?.secondary || '#666'}
/>
</View>
<View style={styles.identityContent}>
@@ -112,7 +100,7 @@ export const VerificationGuideScreen: React.FC = () => {
<MaterialCommunityIcons
name="check-circle"
size={24}
color={THEME_COLORS.primary}
color={colors.primary.main}
/>
)}
</View>
@@ -132,7 +120,7 @@ export const VerificationGuideScreen: React.FC = () => {
<MaterialCommunityIcons
name="shield-check-outline"
size={48}
color={THEME_COLORS.primary}
color={colors.primary.main}
/>
</View>
<Text style={styles.title}></Text>
@@ -147,7 +135,7 @@ export const VerificationGuideScreen: React.FC = () => {
<MaterialCommunityIcons
name="check-decagram"
size={20}
color={THEME_COLORS.primary}
color={colors.primary.main}
/>
<Text style={styles.benefitText}></Text>
</View>
@@ -155,7 +143,7 @@ export const VerificationGuideScreen: React.FC = () => {
<MaterialCommunityIcons
name="lock-open-outline"
size={20}
color={THEME_COLORS.primary}
color={colors.primary.main}
/>
<Text style={styles.benefitText}></Text>
</View>
@@ -163,7 +151,7 @@ export const VerificationGuideScreen: React.FC = () => {
<MaterialCommunityIcons
name="account-group-outline"
size={20}
color={THEME_COLORS.primary}
color={colors.primary.main}
/>
<Text style={styles.benefitText}></Text>
</View>
@@ -198,7 +186,7 @@ function createStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
backgroundColor: colors.background.paper,
},
scrollContent: {
flexGrow: 1,
@@ -214,7 +202,7 @@ function createStyles(colors: AppColors) {
width: 100,
height: 100,
borderRadius: 50,
backgroundColor: '#FFF5F0',
backgroundColor: colors.primary.main + '15',
justifyContent: 'center',
alignItems: 'center',
marginBottom: 20,
@@ -258,15 +246,15 @@ function createStyles(colors: AppColors) {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
backgroundColor: '#F5F5F7',
backgroundColor: colors.background.default,
borderRadius: 14,
marginBottom: 12,
borderWidth: 2,
borderColor: 'transparent',
},
identityCardSelected: {
backgroundColor: '#FFF5F0',
borderColor: THEME_COLORS.primary,
backgroundColor: colors.primary.main + '15',
borderColor: colors.primary.main,
},
identityIconContainer: {
width: 48,
@@ -285,7 +273,7 @@ function createStyles(colors: AppColors) {
marginBottom: 4,
},
identityTitleSelected: {
color: THEME_COLORS.primary,
color: colors.primary.main,
},
identityDescription: {
fontSize: 13,
@@ -303,17 +291,17 @@ function createStyles(colors: AppColors) {
continueButton: {
height: 56,
borderRadius: 14,
backgroundColor: THEME_COLORS.primary,
backgroundColor: colors.primary.main,
justifyContent: 'center',
alignItems: 'center',
},
continueButtonDisabled: {
backgroundColor: '#ccc',
backgroundColor: colors.background.disabled,
},
continueButtonText: {
fontSize: 17,
fontWeight: '600',
color: '#fff',
color: colors.text.inverse,
},
skipButton: {
height: 56,

View File

@@ -33,7 +33,7 @@ import { useNavigation, useRouter } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { Text, ImageGallery, ImageGridItem } from '../../components/common';
import { useAppColors } from '../../theme';
import { useAppColors, useResolvedColorScheme } from '../../theme';
import * as hrefs from '../../navigation/hrefs';
import { messageManager, callStore } from '../../stores';
import { useBreakpointGTE } from '../../hooks/useResponsive';
@@ -56,6 +56,7 @@ export const ChatScreen: React.FC = () => {
const router = useRouter();
const insets = useSafeAreaInsets();
const colors = useAppColors();
const resolved = useResolvedColorScheme();
const styles = useChatScreenStyles();
// 响应式布局
@@ -406,7 +407,7 @@ export const ChatScreen: React.FC = () => {
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
>
<StatusBar style="dark" backgroundColor="#FFFFFF" />
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} backgroundColor={colors.background.paper} />
{/* 顶部栏 */}
<ChatHeader
@@ -499,7 +500,7 @@ export const ChatScreen: React.FC = () => {
>
<View
style={{
backgroundColor: 'rgba(255,255,255,0.9)',
backgroundColor: colors.background.paper + 'E6',
borderRadius: 12,
paddingHorizontal: 10,
paddingVertical: 6,

View File

@@ -28,7 +28,7 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { useIsFocused } from '@react-navigation/native';
import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { spacing, borderRadius, useAppColors, type AppColors } from '../../theme';
import { spacing, borderRadius, useAppColors, useResolvedColorScheme, type AppColors } from '../../theme';
import { SystemMessageResponse } from '../../types/dto';
import { messageService } from '../../services/messageService';
import { commentService } from '../../services/commentService';
@@ -49,18 +49,12 @@ const MESSAGE_TYPES = [
{ key: 'announcement', title: '公告' },
];
// 胡萝卜橙主题色
const THEME_COLORS = {
primary: '#FF6B35',
primaryLight: '#FF8C5A',
primaryDark: '#E55A2B',
};
const LIKE_SYSTEM_TYPES = new Set(['like_post', 'like_comment', 'like_reply', 'favorite_post']);
const GROUP_SYSTEM_TYPES = new Set(['group_invite', 'group_join_apply', 'group_join_approved', 'group_join_rejected']);
export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack }) => {
const colors = useAppColors();
const resolved = useResolvedColorScheme();
const styles = useMemo(() => createNotificationsStyles(colors), [colors]);
const isFocused = useIsFocused();
const { setSystemUnreadCount, decrementSystemUnreadCount } = useMessageManagerSystemUnreadCount();
@@ -424,9 +418,9 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
if (!isHydrated) {
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<StatusBar barStyle="dark-content" backgroundColor="#fff" />
<StatusBar barStyle={resolved === 'dark' ? 'light-content' : 'dark-content'} backgroundColor={colors.background.paper} />
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={THEME_COLORS.primary} />
<ActivityIndicator size="large" color={colors.primary.main} />
</View>
</SafeAreaView>
);
@@ -434,7 +428,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<StatusBar barStyle="dark-content" backgroundColor="#fff" />
<StatusBar barStyle={resolved === 'dark' ? 'light-content' : 'dark-content'} backgroundColor={colors.background.paper} />
<Animated.View
style={[
styles.content,
@@ -499,7 +493,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
{/* 消息列表 */}
{isLoading && messages.length === 0 ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={THEME_COLORS.primary} />
<ActivityIndicator size="large" color={colors.primary.main} />
</View>
) : (
<FlatList
@@ -519,8 +513,8 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={[THEME_COLORS.primary]}
tintColor={THEME_COLORS.primary}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
/>
@@ -580,7 +574,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
{/* 消息列表 */}
{isLoading && messages.length === 0 ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={THEME_COLORS.primary} />
<ActivityIndicator size="large" color={colors.primary.main} />
</View>
) : (
<FlatList
@@ -600,8 +594,8 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={[THEME_COLORS.primary]}
tintColor={THEME_COLORS.primary}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
/>
@@ -617,7 +611,7 @@ function createNotificationsStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
backgroundColor: colors.background.paper,
},
content: {
flex: 1,
@@ -629,7 +623,7 @@ function createNotificationsStyles(colors: AppColors) {
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingVertical: 16,
backgroundColor: '#fff',
backgroundColor: colors.background.paper,
},
headerWide: {
paddingHorizontal: 32,
@@ -650,7 +644,7 @@ function createNotificationsStyles(colors: AppColors) {
fontSize: 20,
},
unreadBadge: {
backgroundColor: THEME_COLORS.primary,
backgroundColor: colors.primary.main,
borderRadius: 10,
paddingHorizontal: 6,
paddingVertical: 2,
@@ -660,7 +654,7 @@ function createNotificationsStyles(colors: AppColors) {
justifyContent: 'center',
},
unreadBadgeText: {
color: '#fff',
color: colors.text.inverse,
fontSize: 11,
fontWeight: '600',
},
@@ -674,7 +668,7 @@ function createNotificationsStyles(colors: AppColors) {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#F5F5F7',
backgroundColor: colors.background.default,
justifyContent: 'center',
alignItems: 'center',
},
@@ -686,7 +680,7 @@ function createNotificationsStyles(colors: AppColors) {
flexDirection: 'row',
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: '#fff',
backgroundColor: colors.background.paper,
borderBottomWidth: 1,
borderBottomColor: colors.divider || '#E5E5EA',
},
@@ -702,7 +696,7 @@ function createNotificationsStyles(colors: AppColors) {
paddingVertical: 8,
marginRight: 8,
borderRadius: 14,
backgroundColor: '#F5F5F7',
backgroundColor: colors.background.default,
borderWidth: 1,
borderColor: 'transparent',
},
@@ -712,15 +706,15 @@ function createNotificationsStyles(colors: AppColors) {
marginRight: 10,
},
filterTagActive: {
backgroundColor: THEME_COLORS.primary,
borderColor: THEME_COLORS.primary,
backgroundColor: colors.primary.main,
borderColor: colors.primary.main,
},
filterTagTextActive: {
fontWeight: '600',
},
filterCount: {
marginLeft: 6,
backgroundColor: THEME_COLORS.primaryLight + '40',
backgroundColor: colors.primary.light + '40',
paddingHorizontal: 6,
paddingVertical: 1,
borderRadius: 8,
@@ -770,7 +764,7 @@ function createNotificationsStyles(colors: AppColors) {
width: 120,
height: 120,
borderRadius: 60,
backgroundColor: '#F5F5F7',
backgroundColor: colors.background.default,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 24,

View File

@@ -21,13 +21,6 @@ import * as hrefs from '../../navigation/hrefs';
const CONTENT_MAX_WIDTH = 720;
// 胡萝卜橙主题色
const THEME_COLORS = {
primary: '#FF6B35',
primaryLight: '#FF8C5A',
primaryDark: '#E55A2B',
};
function createAccountDeletionStyles(colors: AppColors) {
return StyleSheet.create({
container: {
@@ -114,7 +107,7 @@ function createAccountDeletionStyles(colors: AppColors) {
},
// 输入框 - 扁平化风格
input: {
backgroundColor: '#F5F5F7',
backgroundColor: colors.background.default,
borderRadius: 14,
padding: spacing.md,
fontSize: 16,
@@ -133,12 +126,12 @@ function createAccountDeletionStyles(colors: AppColors) {
flex: 1,
height: 56,
borderRadius: 14,
backgroundColor: THEME_COLORS.primary,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
},
primaryButtonText: {
color: '#fff',
color: colors.text.inverse,
fontSize: fontSizes.md,
fontWeight: '600',
},
@@ -166,7 +159,7 @@ function createAccountDeletionStyles(colors: AppColors) {
justifyContent: 'center',
},
dangerButtonText: {
color: '#fff',
color: colors.text.inverse,
fontSize: fontSizes.md,
fontWeight: '600',
},
@@ -392,9 +385,9 @@ export const AccountDeletionScreen: React.FC = () => {
onPress={handleRequestDeletion}
disabled={submitting || !password.trim()}
>
{submitting ? (
<ActivityIndicator size="small" color="#fff" />
) : (
{submitting ? (
<ActivityIndicator size="small" color={colors.text.inverse} />
) : (
<Text style={styles.dangerButtonText}></Text>
)}
</TouchableOpacity>

View File

@@ -16,13 +16,6 @@ import { useResponsive, useResponsiveSpacing } from '../../hooks';
// 内容最大宽度
const CONTENT_MAX_WIDTH = 720;
// 胡萝卜橙主题色
const THEME_COLORS = {
primary: '#FF6B35',
primaryLight: '#FF8C5A',
primaryDark: '#E55A2B',
};
import { authService, resolveAuthApiError } from '../../services/authService';
import { showPrompt } from '../../services/promptService';
import { useAuthStore } from '../../stores';
@@ -263,9 +256,9 @@ export const AccountSecurityScreen: React.FC = () => {
onPress={handleSendCode}
disabled={sendingCode || countdown > 0}
>
{sendingCode ? (
<ActivityIndicator size="small" color="#fff" />
) : (
{sendingCode ? (
<ActivityIndicator size="small" color={colors.text.inverse} />
) : (
<Text style={styles.sendCodeButtonText}>{countdown > 0 ? `${countdown}s` : '发送验证码'}</Text>
)}
</TouchableOpacity>
@@ -278,7 +271,7 @@ export const AccountSecurityScreen: React.FC = () => {
disabled={verifyingEmail}
>
{verifyingEmail ? (
<ActivityIndicator size="small" color="#fff" />
<ActivityIndicator size="small" color={colors.text.inverse} />
) : (
<Text style={styles.primaryButtonText}></Text>
)}
@@ -352,7 +345,7 @@ export const AccountSecurityScreen: React.FC = () => {
disabled={sendingChangePwdCode || changePwdCountdown > 0}
>
{sendingChangePwdCode ? (
<ActivityIndicator size="small" color="#fff" />
<ActivityIndicator size="small" color={colors.text.inverse} />
) : (
<Text style={styles.sendCodeButtonText}>
{changePwdCountdown > 0 ? `${changePwdCountdown}s` : '发送验证码'}
@@ -368,7 +361,7 @@ export const AccountSecurityScreen: React.FC = () => {
disabled={updatingPassword}
>
{updatingPassword ? (
<ActivityIndicator size="small" color="#fff" />
<ActivityIndicator size="small" color={colors.text.inverse} />
) : (
<Text style={styles.primaryButtonText}></Text>
)}
@@ -432,26 +425,26 @@ function createAccountSecurityStyles(colors: AppColors) {
borderRadius: borderRadius.sm,
},
statusVerified: {
backgroundColor: '#E8F5E9',
backgroundColor: colors.success.light + '30',
},
statusUnverified: {
backgroundColor: '#FFF3E0',
backgroundColor: colors.warning.light + '30',
},
statusText: {
fontSize: 13,
fontWeight: '600',
},
statusVerifiedText: {
color: '#2E7D32',
color: colors.success.dark,
},
statusUnverifiedText: {
color: '#E65100',
color: colors.warning.dark,
},
// 输入框样式 - 扁平化风格,与登录页一致
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#F5F5F7',
backgroundColor: colors.background.default,
borderRadius: 14,
paddingHorizontal: spacing.lg,
height: 56,
@@ -485,13 +478,13 @@ function createAccountSecurityStyles(colors: AppColors) {
height: 56,
minWidth: 110,
borderRadius: 14,
backgroundColor: THEME_COLORS.primary,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: spacing.sm,
},
sendCodeButtonText: {
color: '#fff',
color: colors.text.inverse,
fontSize: fontSizes.sm,
fontWeight: '600',
},
@@ -499,14 +492,14 @@ function createAccountSecurityStyles(colors: AppColors) {
primaryButton: {
height: 56,
borderRadius: 14,
backgroundColor: THEME_COLORS.primary,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
marginTop: spacing.xs,
marginHorizontal: spacing['2xl'],
},
primaryButtonText: {
color: '#fff',
color: colors.text.inverse,
fontSize: fontSizes.md,
fontWeight: '600',
},

View File

@@ -10,6 +10,8 @@ import {
StyleSheet,
Switch,
ScrollView,
Alert,
TouchableOpacity,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { MaterialCommunityIcons } from '@expo/vector-icons';
@@ -22,6 +24,7 @@ import {
setVibrationPreference,
} from '../../services/notificationPreferences';
import { useResponsive, useResponsiveSpacing } from '../../hooks';
import { backgroundSyncManager, BackgroundSyncMode } from '../../services/BackgroundSyncManager';
// 内容最大宽度
const CONTENT_MAX_WIDTH = 720;
@@ -41,6 +44,7 @@ export const NotificationSettingsScreen: React.FC = () => {
const [vibrationEnabled, setVibrationEnabledState] = useState(true);
const [pushEnabled, setPushEnabled] = useState(true);
const [soundEnabled, setSoundEnabled] = useState(true);
const [syncMode, setSyncMode] = useState<BackgroundSyncMode>(BackgroundSyncMode.BATTERY_SAVER);
const { isWideScreen, isMobile } = useResponsive();
const insets = useSafeAreaInsets();
@@ -55,6 +59,7 @@ export const NotificationSettingsScreen: React.FC = () => {
setVibrationEnabledState(prefs.vibrationEnabled);
setPushEnabled(prefs.pushEnabled);
setSoundEnabled(prefs.soundEnabled);
setSyncMode(backgroundSyncManager.getMode());
} catch (error) {
console.error('加载通知设置失败:', error);
}
@@ -89,6 +94,51 @@ export const NotificationSettingsScreen: React.FC = () => {
}
};
const handleSyncModeChange = async (mode: BackgroundSyncMode) => {
if (mode === BackgroundSyncMode.REALTIME) {
Alert.alert(
'实时模式',
'实时模式会在通知栏显示常驻通知以保持应用后台运行,确保消息即时同步。\n\n' +
'建议在系统设置中将应用加入电池优化白名单以获得最佳效果。\n\n' +
'注意:此模式可能增加电池消耗。',
[
{ text: '取消', style: 'cancel' },
{
text: '开启',
onPress: async () => {
await backgroundSyncManager.setMode(mode);
setSyncMode(mode);
},
},
]
);
} else {
await backgroundSyncManager.setMode(mode);
setSyncMode(mode);
}
};
const syncModeOptions: { mode: BackgroundSyncMode; title: string; subtitle: string; icon: string }[] = [
{
mode: BackgroundSyncMode.BATTERY_SAVER,
title: '省电模式',
subtitle: '系统后台任务,每 15 分钟检查一次',
icon: 'leaf',
},
{
mode: BackgroundSyncMode.REALTIME,
title: '实时模式',
subtitle: '通知栏常驻保活,即时同步消息',
icon: 'lightning-bolt',
},
{
mode: BackgroundSyncMode.DISABLED,
title: '禁用',
subtitle: '仅在应用打开时接收消息',
icon: 'close-circle-outline',
},
];
const settings: NotificationSettingItem[] = [
{
key: 'push',
@@ -164,6 +214,62 @@ export const NotificationSettingsScreen: React.FC = () => {
</Text>
</View>
{/* 后台同步模式 */}
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text variant="caption" style={styles.sectionTitle}>
</Text>
</View>
{syncModeOptions.map((option) => (
<TouchableOpacity
key={option.mode}
style={[
styles.settingItem,
syncMode === option.mode && styles.settingItemActive,
]}
onPress={() => handleSyncModeChange(option.mode)}
activeOpacity={0.7}
>
<View style={styles.iconContainer}>
<MaterialCommunityIcons
name={option.icon as any}
size={22}
color={syncMode === option.mode ? colors.primary.main : colors.text.secondary}
/>
</View>
<View style={styles.settingContent}>
<Text
variant="body"
color={syncMode === option.mode ? colors.primary.main : colors.text.primary}
>
{option.title}
</Text>
<Text variant="caption" color={colors.text.secondary} style={styles.subtitle}>
{option.subtitle}
</Text>
</View>
{syncMode === option.mode && (
<MaterialCommunityIcons
name="check"
size={20}
color={colors.primary.main}
/>
)}
</TouchableOpacity>
))}
</View>
{/* 实时模式说明 */}
{syncMode === BackgroundSyncMode.REALTIME && (
<View style={styles.tipContainer}>
<MaterialCommunityIcons name="shield-check-outline" size={16} color={colors.text.hint} />
<Text variant="caption" color={colors.text.hint} style={styles.tipText}>
</Text>
</View>
)}
</>
);
@@ -210,6 +316,9 @@ function createNotificationSettingsStyles(colors: AppColors) {
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.divider,
},
settingItemActive: {
backgroundColor: colors.primary.main + '0D',
},
iconContainer: {
width: 24,
height: 24,

View File

@@ -18,13 +18,6 @@ import type { PrivacySettingsDTO, VisibilityLevel } from '../../types/dto';
const CONTENT_MAX_WIDTH = 720;
// 胡萝卜橙主题色
const THEME_COLORS = {
primary: '#FF6B35',
primaryLight: '#FF8C5A',
primaryDark: '#E55A2B',
};
const VISIBILITY_OPTIONS: { value: VisibilityLevel; label: string; description: string }[] = [
{ value: 'everyone', label: '所有人可见', description: '任何人都可查看' },
{ value: 'following', label: '仅关注的人可见', description: '只有你关注的人可查看' },
@@ -74,7 +67,7 @@ function createPrivacySettingsStyles(colors: AppColors) {
},
// 隐私设置项 - 扁平化卡片风格
item: {
backgroundColor: '#F5F5F7',
backgroundColor: colors.background.default,
borderRadius: 14,
padding: spacing.lg,
marginHorizontal: spacing['2xl'],
@@ -111,15 +104,15 @@ function createPrivacySettingsStyles(colors: AppColors) {
backgroundColor: colors.background.paper,
},
optionButtonActive: {
backgroundColor: THEME_COLORS.primary,
borderColor: THEME_COLORS.primary,
backgroundColor: colors.primary.main,
borderColor: colors.primary.main,
},
optionText: {
fontSize: 13,
color: colors.text.primary,
},
optionTextActive: {
color: '#fff',
color: colors.text.inverse,
fontWeight: '500',
},
});

View File

@@ -26,12 +26,6 @@ import {
import { showPrompt } from '../../services/promptService';
import * as hrefs from '../../navigation/hrefs';
const THEME_COLORS = {
primary: '#FF6B35',
primaryLight: '#FF8C5A',
primaryDark: '#E55A2B',
};
const STATUS_CONFIG = {
not_submitted: {
title: '未认证',
@@ -99,7 +93,7 @@ export const VerificationSettingsScreen: React.FC = () => {
return (
<SafeAreaView style={styles.container}>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={THEME_COLORS.primary} />
<ActivityIndicator size="large" color={colors.primary.main} />
</View>
</SafeAreaView>
);
@@ -110,10 +104,11 @@ export const VerificationSettingsScreen: React.FC = () => {
const identityText = IDENTITY_TEXT[status?.identity || 'general'];
return (
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<SafeAreaView style={styles.container}>
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{/* 状态卡片 */}
<View style={styles.statusCard}>
<View style={[styles.statusIconContainer, { backgroundColor: statusConfig.color + '20' }]}>
@@ -128,7 +123,7 @@ export const VerificationSettingsScreen: React.FC = () => {
{status?.verification_status === 'approved' && (
<View style={styles.identityBadge}>
<MaterialCommunityIcons name="check-circle" size={16} color={THEME_COLORS.primary} />
<MaterialCommunityIcons name="check-circle" size={16} color={colors.primary.main} />
<Text style={styles.identityText}>{identityText}</Text>
</View>
)}
@@ -153,7 +148,7 @@ export const VerificationSettingsScreen: React.FC = () => {
<MaterialCommunityIcons
name="check-decagram"
size={24}
color={THEME_COLORS.primary}
color={colors.primary.main}
/>
</View>
<View style={styles.benefitContent}>
@@ -169,7 +164,7 @@ export const VerificationSettingsScreen: React.FC = () => {
<MaterialCommunityIcons
name="lock-open-outline"
size={24}
color={THEME_COLORS.primary}
color={colors.primary.main}
/>
</View>
<View style={styles.benefitContent}>
@@ -185,7 +180,7 @@ export const VerificationSettingsScreen: React.FC = () => {
<MaterialCommunityIcons
name="account-group-outline"
size={24}
color={THEME_COLORS.primary}
color={colors.primary.main}
/>
</View>
<View style={styles.benefitContent}>
@@ -208,7 +203,7 @@ export const VerificationSettingsScreen: React.FC = () => {
onPress={handleStartVerification}
activeOpacity={0.9}
>
<MaterialCommunityIcons name="shield-check-outline" size={20} color="#FFF" />
<MaterialCommunityIcons name="shield-check-outline" size={20} color={colors.primary.contrast} />
<Text style={styles.primaryButtonText}>
{status?.verification_status === 'rejected' ? '重新认证' : '开始认证'}
</Text>
@@ -221,7 +216,7 @@ export const VerificationSettingsScreen: React.FC = () => {
onPress={handleViewRecords}
activeOpacity={0.8}
>
<MaterialCommunityIcons name="file-document-outline" size={20} color={THEME_COLORS.primary} />
<MaterialCommunityIcons name="file-document-outline" size={20} color={colors.primary.main} />
<Text style={styles.secondaryButtonText}></Text>
</TouchableOpacity>
)}
@@ -262,6 +257,7 @@ export const VerificationSettingsScreen: React.FC = () => {
</Text>
</View>
</ScrollView>
</SafeAreaView>
);
};
@@ -269,7 +265,7 @@ function createStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
backgroundColor: colors.background.paper,
},
loadingContainer: {
flex: 1,
@@ -307,7 +303,7 @@ function createStyles(colors: AppColors) {
alignItems: 'center',
paddingVertical: 32,
paddingHorizontal: 24,
backgroundColor: '#F9F9F9',
backgroundColor: colors.background.default,
borderRadius: 16,
marginBottom: 24,
},
@@ -334,7 +330,7 @@ function createStyles(colors: AppColors) {
identityBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#FFF5F0',
backgroundColor: colors.primary.main + '15',
borderRadius: 20,
paddingVertical: 8,
paddingHorizontal: 16,
@@ -343,25 +339,25 @@ function createStyles(colors: AppColors) {
identityText: {
fontSize: 14,
fontWeight: '600',
color: THEME_COLORS.primary,
color: colors.primary.main,
marginLeft: 6,
},
rejectReasonContainer: {
marginTop: 16,
padding: 12,
backgroundColor: '#FFF3F3',
backgroundColor: colors.error.light + '20',
borderRadius: 8,
width: '100%',
},
rejectReasonLabel: {
fontSize: 13,
fontWeight: '600',
color: '#F44336',
color: colors.error.main,
marginBottom: 4,
},
rejectReasonText: {
fontSize: 13,
color: '#D32F2F',
color: colors.error.dark,
lineHeight: 18,
},
benefitsSection: {
@@ -379,7 +375,7 @@ function createStyles(colors: AppColors) {
benefitItem: {
flexDirection: 'row',
alignItems: 'flex-start',
backgroundColor: '#F9F9F9',
backgroundColor: colors.background.default,
borderRadius: 12,
padding: 16,
},
@@ -387,7 +383,7 @@ function createStyles(colors: AppColors) {
width: 48,
height: 48,
borderRadius: 12,
backgroundColor: '#FFF5F0',
backgroundColor: colors.primary.main + '15',
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
@@ -415,12 +411,12 @@ function createStyles(colors: AppColors) {
justifyContent: 'center',
height: 56,
borderRadius: 14,
backgroundColor: THEME_COLORS.primary,
backgroundColor: colors.primary.main,
},
primaryButtonText: {
fontSize: 17,
fontWeight: '600',
color: '#FFF',
color: colors.text.inverse,
marginLeft: 8,
},
secondaryButton: {
@@ -429,18 +425,18 @@ function createStyles(colors: AppColors) {
justifyContent: 'center',
height: 56,
borderRadius: 14,
backgroundColor: '#FFF5F0',
backgroundColor: colors.primary.main + '15',
borderWidth: 1,
borderColor: THEME_COLORS.primary,
borderColor: colors.primary.main,
},
secondaryButtonText: {
fontSize: 17,
fontWeight: '600',
color: THEME_COLORS.primary,
color: colors.primary.main,
marginLeft: 8,
},
verifiedInfo: {
backgroundColor: '#F9F9F9',
backgroundColor: colors.background.default,
borderRadius: 12,
padding: 16,
},

View File

@@ -0,0 +1,284 @@
/**
* 后台同步管理器
*
* 整合前台服务、后台同步、消息状态管理
* 参考 Element Android 的 BackgroundSyncStarter 设计
*/
import { AppState, AppStateStatus, Platform } from 'react-native';
import { ForegroundServiceModule } from './ForegroundServiceModule';
import { wsService } from './wsService';
import AsyncStorage from '@react-native-async-storage/async-storage';
/**
* 后台同步模式
*/
export enum BackgroundSyncMode {
/** 省电模式:使用 expo-background-task最小15分钟间隔 */
BATTERY_SAVER = 'battery_saver',
/** 实时模式:前台服务保活 + 持续同步 */
REALTIME = 'realtime',
/** 禁用后台同步 */
DISABLED = 'disabled',
}
const STORAGE_KEY = 'background_sync_mode';
const SYNC_INTERVAL_MS = 30 * 1000; // 30 秒同步间隔
class BackgroundSyncManager {
private mode: BackgroundSyncMode = BackgroundSyncMode.BATTERY_SAVER;
private appStateSubscription: ReturnType<typeof AppState.addEventListener> | null = null;
private syncInterval: ReturnType<typeof setInterval> | null = null;
private isInitialized: boolean = false;
private lastSyncAt: number = 0;
private syncMessagesCallback: (() => Promise<void>) | null = null;
/**
* 设置消息同步回调函数
* 由外部注入,避免循环依赖
*/
setSyncMessagesCallback(callback: () => Promise<void>): void {
this.syncMessagesCallback = callback;
}
/**
* 初始化后台同步
*/
async initialize(): Promise<void> {
if (this.isInitialized) return;
// 加载保存的模式
const savedMode = await this.loadMode();
this.mode = savedMode;
// 监听 AppState 变化
this.appStateSubscription = AppState.addEventListener(
'change',
this.handleAppStateChange
);
// 监听 WebSocket 断开
this.setupWSDisconnectListener();
this.isInitialized = true;
console.log('[BackgroundSyncManager] 初始化完成, 模式:', this.mode);
}
/**
* 切换后台同步模式
*/
async setMode(mode: BackgroundSyncMode): Promise<void> {
// 如果当前在后台,先停止旧模式的保活
if (AppState.currentState !== 'active') {
this.stopPeriodicSync();
if (this.mode === BackgroundSyncMode.REALTIME) {
await ForegroundServiceModule.stop();
}
}
// 更新模式
this.mode = mode;
await this.saveMode(mode);
// 根据新模式立即执行相应操作
if (mode === BackgroundSyncMode.REALTIME) {
// 切换到实时模式:立即启动前台服务(无论前台还是后台)
const started = await ForegroundServiceModule.start({
title: '萝卜社区',
body: '正在后台同步消息',
});
if (started) {
this.startPeriodicSync();
}
} else {
// 切换到其他模式:立即停止前台服务和定时同步
this.stopPeriodicSync();
await ForegroundServiceModule.stop();
}
console.log('[BackgroundSyncManager] 模式已切换为:', mode);
}
/**
* 获取当前模式
*/
getMode(): BackgroundSyncMode {
return this.mode;
}
/**
* AppState 变化处理
*/
private handleAppStateChange = async (nextState: AppStateStatus) => {
console.log('[BackgroundSyncManager] AppState 变化:', nextState);
if (nextState === 'background' || nextState === 'inactive') {
// 进入后台
await this.startForBackground();
} else if (nextState === 'active') {
// 回到前台
await this.stopForForeground();
}
};
/**
* 进入后台时的处理
*/
private async startForBackground(): Promise<void> {
if (this.mode === BackgroundSyncMode.DISABLED) return;
console.log('[BackgroundSyncManager] 进入后台');
if (this.mode === BackgroundSyncMode.REALTIME) {
// 确保前台服务正在运行
await ForegroundServiceModule.start({
title: '萝卜社区',
body: '正在后台同步消息',
});
// 启动定时同步
this.startPeriodicSync();
}
}
/**
* 回到前台时的处理
*/
private async stopForForeground(): Promise<void> {
console.log('[BackgroundSyncManager] 回到前台');
// 停止定时同步(前台通过 WebSocket 实时同步,不需要轮询)
this.stopPeriodicSync();
// 实时模式下:前台服务保持运行,只更新通知文字
if (this.mode === BackgroundSyncMode.REALTIME) {
await ForegroundServiceModule.update('萝卜社区', '正在后台同步消息');
}
// 立即同步一次(获取离线消息)
await this.syncMessages();
}
/**
* 启动定时同步
*/
private startPeriodicSync(): void {
this.stopPeriodicSync();
// 立即同步一次
this.syncMessages();
// 启动定时器
this.syncInterval = setInterval(() => {
this.syncMessages();
}, SYNC_INTERVAL_MS);
console.log('[BackgroundSyncManager] 定时同步已启动');
}
/**
* 停止定时同步
*/
private stopPeriodicSync(): void {
if (this.syncInterval) {
clearInterval(this.syncInterval);
this.syncInterval = null;
console.log('[BackgroundSyncManager] 定时同步已停止');
}
}
/**
* 设置 WebSocket 断开监听
*/
private setupWSDisconnectListener(): void {
// 监听 WebSocket 断开事件
wsService.onDisconnect(() => {
if (AppState.currentState !== 'active' && this.mode === BackgroundSyncMode.REALTIME) {
console.log('[BackgroundSyncManager] WebSocket 断开,尝试重连');
wsService.connect().catch((e) => {
console.error('[BackgroundSyncManager] WebSocket 重连失败:', e);
});
}
});
}
/**
* 执行消息同步
*/
async syncMessages(): Promise<void> {
const now = Date.now();
// 防止短时间内重复同步(最小 10 秒间隔)
if (now - this.lastSyncAt < 10000) {
return;
}
this.lastSyncAt = now;
// 调用外部注入的同步回调
if (this.syncMessagesCallback) {
try {
await this.syncMessagesCallback();
} catch (error) {
console.error('[BackgroundSyncManager] 同步失败:', error);
}
}
}
/**
* 更新前台服务通知内容
*/
async updateNotification(totalUnread: number): Promise<void> {
if (this.mode !== BackgroundSyncMode.REALTIME) return;
if (totalUnread > 0) {
await ForegroundServiceModule.update(
'萝卜社区',
`您有 ${totalUnread} 条未读消息`
);
} else {
await ForegroundServiceModule.update(
'萝卜社区',
'正在后台同步消息'
);
}
}
/**
* 保存模式到存储
*/
private async saveMode(mode: BackgroundSyncMode): Promise<void> {
try {
await AsyncStorage.setItem(STORAGE_KEY, mode);
} catch (error) {
console.error('[BackgroundSyncManager] 保存模式失败:', error);
}
}
/**
* 从存储加载模式
*/
private async loadMode(): Promise<BackgroundSyncMode> {
try {
const saved = await AsyncStorage.getItem(STORAGE_KEY);
return (saved as BackgroundSyncMode) || BackgroundSyncMode.BATTERY_SAVER;
} catch (error) {
return BackgroundSyncMode.BATTERY_SAVER;
}
}
/**
* 清理资源
*/
async cleanup(): Promise<void> {
this.stopPeriodicSync();
this.appStateSubscription?.remove();
this.appStateSubscription = null;
await ForegroundServiceModule.stop();
this.isInitialized = false;
}
}
export const backgroundSyncManager = new BackgroundSyncManager();

View File

@@ -0,0 +1,102 @@
/**
* 前台服务模块
*
* 提供 React Native 层对 Android 前台服务的控制接口
* 仅 Android 平台有效
*/
import { NativeModules, Platform } from 'react-native';
const { ForegroundService } = NativeModules;
export interface ForegroundServiceOptions {
title?: string;
body?: string;
}
/**
* 前台服务管理器
*
* 使用方式:
* - 前台服务启动后,会在通知栏显示一个常驻通知
* - 服务会防止应用进程被系统杀死
* - 类似 V2Ray 和 Element Android 的保活机制
*/
export const ForegroundServiceModule = {
/**
* 启动前台服务
* @param options 通知配置
*/
async start(options: ForegroundServiceOptions = {}): Promise<boolean> {
if (Platform.OS !== 'android') {
console.log('[ForegroundService] iOS 不支持前台服务保活');
return false;
}
if (!ForegroundService) {
console.error('[ForegroundService] 原生模块未注册');
return false;
}
try {
const result = await ForegroundService.start(
options.title || '萝卜社区',
options.body || '正在后台同步消息'
);
console.log('[ForegroundService] 服务已启动');
return result;
} catch (error) {
console.error('[ForegroundService] 启动失败:', error);
return false;
}
},
/**
* 停止前台服务
*/
async stop(): Promise<boolean> {
if (Platform.OS !== 'android') {
return false;
}
if (!ForegroundService) {
return false;
}
try {
const result = await ForegroundService.stop();
console.log('[ForegroundService] 服务已停止');
return result;
} catch (error) {
console.error('[ForegroundService] 停止失败:', error);
return false;
}
},
/**
* 更新通知内容
*/
async update(title: string, body: string): Promise<boolean> {
if (Platform.OS !== 'android') {
return false;
}
if (!ForegroundService) {
return false;
}
try {
return await ForegroundService.update(title, body);
} catch (error) {
console.error('[ForegroundService] 更新失败:', error);
return false;
}
},
/**
* 检查是否为 Android 平台
*/
isSupported(): boolean {
return Platform.OS === 'android' && !!ForegroundService;
},
};

View File

@@ -1,107 +1,92 @@
/**
* 后台保活服务
* 使用 expo-background-fetch 和 expo-task-manager 实现 App 后台保活
*
* 功能:
* - 定期后台任务保持 App 在内存中
* - 配合 SSE 服务保持长连接
* - 收到消息时触发震动提示
* 整合前台服务和后台同步
* - REALTIME 模式:使用 Android 前台服务保活(通知栏常驻)
* - BATTERY_SAVER 模式:使用 expo-background-task 定期同步最小15分钟间隔
*
* 参考Element Android 的 GuardAndroidService + BackgroundSyncStarter
*/
import { AppState, AppStateStatus, Platform } from 'react-native';
import * as BackgroundFetch from 'expo-background-fetch';
import { Platform } from 'react-native';
import * as BackgroundTask from 'expo-background-task';
import * as TaskManager from 'expo-task-manager';
import { wsService } from './wsService';
import {
triggerVibration,
vibrateOnMessage,
setVibrationConfig,
getVibrationConfig,
setVibrationEnabled,
} from './messageVibrationService';
export { triggerVibration, vibrateOnMessage, setVibrationConfig, getVibrationConfig, setVibrationEnabled };
// 后台任务名称
const BACKGROUND_FETCH_TASK = 'background-fetch-keepalive';
const REALTIME_KEEPALIVE_TASK = 'realtime-keepalive';
// 后台任务间隔Android 最小 15 分钟iOS 最小 15 分钟)
const BACKGROUND_INTERVAL = 15; // 15 分钟
// 后台服务状态
let isInitialized = false;
let appStateSubscription: any = null;
// 导入 api 用于检查认证状态
backgroundSyncManager,
BackgroundSyncMode,
} from './BackgroundSyncManager';
import { messageService } from './messageService';
import { api } from './api';
// 定义后台任务
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
// 后台任务名称
const BACKGROUND_SYNC_TASK = 'background-sync-task';
// 后台任务间隔(分钟)
const BACKGROUND_INTERVAL_MINUTES = 15;
// 服务状态
let isInitialized = false;
// 定义后台任务(使用 expo-background-task
TaskManager.defineTask(BACKGROUND_SYNC_TASK, async () => {
try {
// 检查是否已认证
const token = await api.getToken();
if (!token) {
return BackgroundFetch.BackgroundFetchResult.NoData;
return BackgroundTask.BackgroundTaskResult.Success;
}
// 检查 SSE 连接状态
if (!wsService.isConnected()) {
await wsService.connect();
}
// 返回收到新数据
return BackgroundFetch.BackgroundFetchResult.NewData;
// 执行同步
await syncMessages();
return BackgroundTask.BackgroundTaskResult.Success;
} catch (error) {
console.error('[BackgroundService] 后台任务执行失败:', error);
return BackgroundFetch.BackgroundFetchResult.Failed;
}
});
// SSE 保活任务
TaskManager.defineTask(REALTIME_KEEPALIVE_TASK, async () => {
try {
// 检查是否已认证
const token = await api.getToken();
if (!token) {
return BackgroundFetch.BackgroundFetchResult.NoData;
}
if (!wsService.isConnected()) {
await wsService.connect();
}
return BackgroundFetch.BackgroundFetchResult.NewData;
} catch (error) {
console.error('[BackgroundService] SSE 保活失败:', error);
return BackgroundFetch.BackgroundFetchResult.Failed;
return BackgroundTask.BackgroundTaskResult.Failed;
}
});
/**
* 注册后台任务
* 执行消息同步
*/
async function registerBackgroundTasks(): Promise<void> {
async function syncMessages(): Promise<void> {
try {
// 同步未读数
const unreadData = await messageService.getUnreadCount();
const totalUnread = unreadData?.total_unread_count ?? 0;
// 更新前台服务通知
await backgroundSyncManager.updateNotification(totalUnread);
console.log('[BackgroundService] 同步完成, 未读数:', totalUnread);
} catch (error) {
console.error('[BackgroundService] 同步失败:', error);
}
}
/**
* 注册后台任务expo-background-task
*/
async function registerBackgroundTask(): Promise<void> {
if (Platform.OS === 'web') {
return;
}
try {
// 检查是否已注册
const isRegistered = await TaskManager.isTaskRegisteredAsync(BACKGROUND_FETCH_TASK);
if (!isRegistered) {
await BackgroundFetch.registerTaskAsync(BACKGROUND_FETCH_TASK, {
minimumInterval: BACKGROUND_INTERVAL * 60, // 转换为秒
stopOnTerminate: false, // App 终止后继续运行
startOnBoot: true, // 设备启动后自动运行
});
// 检查后台任务状态
const status = await BackgroundTask.getStatusAsync();
if (status !== BackgroundTask.BackgroundTaskStatus.Available) {
console.warn('[BackgroundService] 后台任务不可用,状态:', status);
return;
}
// 注册 SSE 保活任务
const isSseKeepaliveRegistered = await TaskManager.isTaskRegisteredAsync(REALTIME_KEEPALIVE_TASK);
if (!isSseKeepaliveRegistered) {
await BackgroundFetch.registerTaskAsync(REALTIME_KEEPALIVE_TASK, {
minimumInterval: 60, // 1 分钟检查一次
stopOnTerminate: false,
startOnBoot: true,
// 检查是否已注册
const isRegistered = await TaskManager.isTaskRegisteredAsync(BACKGROUND_SYNC_TASK);
if (!isRegistered) {
await BackgroundTask.registerTaskAsync(BACKGROUND_SYNC_TASK, {
minimumInterval: BACKGROUND_INTERVAL_MINUTES, // 以分钟为单位
});
console.log('[BackgroundService] 后台任务已注册');
}
} catch (error) {
console.error('[BackgroundService] 注册后台任务失败:', error);
@@ -111,41 +96,22 @@ async function registerBackgroundTasks(): Promise<void> {
/**
* 取消后台任务
*/
async function unregisterBackgroundTasks(): Promise<void> {
async function unregisterBackgroundTask(): Promise<void> {
if (Platform.OS === 'web') {
return;
}
try {
await BackgroundFetch.unregisterTaskAsync(BACKGROUND_FETCH_TASK);
await BackgroundFetch.unregisterTaskAsync(REALTIME_KEEPALIVE_TASK);
const isRegistered = await TaskManager.isTaskRegisteredAsync(BACKGROUND_SYNC_TASK);
if (isRegistered) {
await BackgroundTask.unregisterTaskAsync(BACKGROUND_SYNC_TASK);
console.log('[BackgroundService] 后台任务已取消');
}
} catch (error) {
console.error('[BackgroundService] 取消后台任务失败:', error);
}
}
/**
* 设置 App 状态监听
*/
function setupAppStateListener(): void {
if (appStateSubscription) {
return;
}
appStateSubscription = AppState.addEventListener('change', async (nextAppState: AppStateStatus) => {
if (nextAppState === 'active') {
// 检查是否已认证
const token = await api.getToken();
if (!token) {
return;
}
// App 回到前台,确保连接
if (!wsService.isConnected()) {
wsService.connect();
}
}
});
}
/**
* 初始化后台保活服务
*/
@@ -155,26 +121,22 @@ export async function initBackgroundService(): Promise<boolean> {
}
if (Platform.OS === 'web') {
setupAppStateListener();
isInitialized = true;
return true;
}
try {
// 检查后台任务状态
const status = await BackgroundFetch.getStatusAsync();
if (status !== BackgroundFetch.BackgroundFetchStatus.Available) {
console.warn('[BackgroundService] 后台任务不可用,状态:', status);
// 即使后台任务不可用,也继续初始化其他功能
}
// 设置同步回调
backgroundSyncManager.setSyncMessagesCallback(syncMessages);
// 注册后台任务
await registerBackgroundTasks();
// 初始化后台同步管理器
await backgroundSyncManager.initialize();
// 设置 App 状态监听
setupAppStateListener();
// 注册 expo-background-task 任务(用于 BATTERY_SAVER 模式)
await registerBackgroundTask();
isInitialized = true;
console.log('[BackgroundService] 初始化完成');
return true;
} catch (error) {
console.error('[BackgroundService] 初始化失败:', error);
@@ -187,14 +149,10 @@ export async function initBackgroundService(): Promise<boolean> {
*/
export async function stopBackgroundService(): Promise<void> {
try {
await unregisterBackgroundTasks();
if (appStateSubscription) {
appStateSubscription.remove();
appStateSubscription = null;
}
await unregisterBackgroundTask();
await backgroundSyncManager.cleanup();
isInitialized = false;
console.log('[BackgroundService] 服务已停止');
} catch (error) {
console.error('[BackgroundService] 停止服务失败:', error);
}
@@ -207,34 +165,74 @@ export async function checkBackgroundStatus(): Promise<{
isAvailable: boolean;
isInitialized: boolean;
registeredTasks: string[];
mode: BackgroundSyncMode;
}> {
if (Platform.OS === 'web') {
return {
isAvailable: false,
isInitialized,
registeredTasks: [],
mode: backgroundSyncManager.getMode(),
};
}
const status = await BackgroundFetch.getStatusAsync();
const status = await BackgroundTask.getStatusAsync();
const registeredTasks = await TaskManager.getRegisteredTasksAsync();
return {
isAvailable: status === BackgroundFetch.BackgroundFetchStatus.Available,
isAvailable: status === BackgroundTask.BackgroundTaskStatus.Available,
isInitialized,
registeredTasks: registeredTasks.map(task => task.taskName),
registeredTasks: registeredTasks.map((task) => task.taskName),
mode: backgroundSyncManager.getMode(),
};
}
// 导出服务实例
export const backgroundService = {
init: initBackgroundService,
stop: stopBackgroundService,
vibrate: triggerVibration,
/**
* 设置后台同步模式
*/
export async function setBackgroundSyncMode(mode: BackgroundSyncMode): Promise<void> {
await backgroundSyncManager.setMode(mode);
}
/**
* 获取当前后台同步模式
*/
export function getBackgroundSyncMode(): BackgroundSyncMode {
return backgroundSyncManager.getMode();
}
/**
* 手动触发同步
*/
export async function syncNow(): Promise<void> {
await syncMessages();
}
// 导出震动相关功能(保持向后兼容)
export {
triggerVibration,
vibrateOnMessage,
setVibrationConfig,
getVibrationConfig,
setVibrationEnabled,
} from './messageVibrationService';
// 重新导出类型
export { BackgroundSyncMode };
// 后台服务实例
export const backgroundService = {
init: initBackgroundService,
stop: stopBackgroundService,
setMode: setBackgroundSyncMode,
getMode: getBackgroundSyncMode,
syncNow,
checkStatus: checkBackgroundStatus,
// 震动相关
vibrate: async () => {
const { triggerVibration } = await import('./messageVibrationService');
triggerVibration();
},
};
export default backgroundService;