Compare commits
2 Commits
9bbed8cf5e
...
4b5ce1ba21
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b5ce1ba21 | ||
|
|
6f84e17772 |
18
app.json
18
app.json
@@ -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
|
||||
}
|
||||
],
|
||||
[
|
||||
|
||||
@@ -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",
|
||||
|
||||
467
plugins/withForegroundService.js
Normal file
467
plugins/withForegroundService.js
Normal 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;
|
||||
@@ -13,6 +13,7 @@ import { useRouter } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||
import { blurActiveElement } from '../../infrastructure/platform';
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
|
||||
@@ -156,9 +157,8 @@ const QRCodeScannerWeb: React.FC<QRCodeScannerProps> = ({ visible, onClose }) =>
|
||||
const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && Platform.OS === 'web') {
|
||||
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
||||
active?.blur?.();
|
||||
if (visible) {
|
||||
blurActiveElement();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { useAppColors } from '../../../theme';
|
||||
import { spacing, borderRadius, fontSizes, shadows } from '../../../theme';
|
||||
import reportService, { ReportReason, ReportTargetType, REPORT_REASONS } from '../../../services/reportService';
|
||||
import { blurActiveElement } from '../../../infrastructure/platform';
|
||||
import Text from '../../common/Text';
|
||||
|
||||
export interface ReportDialogProps {
|
||||
@@ -63,9 +64,8 @@ const ReportDialog: React.FC<ReportDialogProps> = ({
|
||||
const targetConfig = TARGET_CONFIG[targetType];
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && Platform.OS === 'web') {
|
||||
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
||||
active?.blur?.();
|
||||
if (visible) {
|
||||
blurActiveElement();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { callStore } from '../../stores/callStore';
|
||||
import { blurActiveElement } from '../../infrastructure/platform';
|
||||
|
||||
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||
|
||||
@@ -26,10 +27,7 @@ const IncomingCallModal: React.FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (incomingCall) {
|
||||
if (Platform.OS === 'web') {
|
||||
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
||||
active?.blur?.();
|
||||
}
|
||||
blurActiveElement();
|
||||
const pulse = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulseAnim, {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
useBreakpointGTE,
|
||||
FineBreakpointKey,
|
||||
} from '../../hooks/useResponsive';
|
||||
import { blurActiveElement } from '../../infrastructure/platform';
|
||||
|
||||
export interface AdaptiveLayoutProps {
|
||||
/** 主内容区 */
|
||||
@@ -160,9 +161,8 @@ export function AdaptiveLayout({
|
||||
// 抽屉动画
|
||||
useEffect(() => {
|
||||
if (isDrawerOpen) {
|
||||
if (Platform.OS === 'web') {
|
||||
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
||||
active?.blur?.();
|
||||
if (isDrawerOpen) {
|
||||
blurActiveElement();
|
||||
}
|
||||
Animated.parallel([
|
||||
Animated.timing(slideAnim, {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { LinearGradient } from 'expo-linear-gradient';
|
||||
|
||||
import { bindDialogListener, DialogPayload } from '../../services/dialogService';
|
||||
import { borderRadius, shadows, spacing, useAppColors, type AppColors } from '../../theme';
|
||||
import { blurActiveElement } from '../../infrastructure/platform';
|
||||
|
||||
function createDialogHostStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
@@ -93,12 +94,6 @@ function createDialogHostStyles(colors: AppColors) {
|
||||
});
|
||||
}
|
||||
|
||||
const blurActiveElementOnWeb = () => {
|
||||
if (Platform.OS !== 'web') return;
|
||||
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
||||
active?.blur?.();
|
||||
};
|
||||
|
||||
const AppDialogHost: React.FC = () => {
|
||||
const [dialog, setDialog] = useState<DialogPayload | null>(null);
|
||||
const colors = useAppColors();
|
||||
@@ -113,7 +108,7 @@ const AppDialogHost: React.FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (dialog) {
|
||||
blurActiveElementOnWeb();
|
||||
blurActiveElement();
|
||||
}
|
||||
}, [dialog]);
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
import { File, Paths } from 'expo-file-system';
|
||||
import { spacing, borderRadius, fontSizes } from '../../theme';
|
||||
import { blurActiveElement } from '../../infrastructure/platform';
|
||||
|
||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||
|
||||
@@ -127,10 +128,7 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
||||
// 打开/关闭时重置状态
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
if (Platform.OS === 'web') {
|
||||
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
||||
active?.blur?.();
|
||||
}
|
||||
blurActiveElement();
|
||||
setCurrentIndex(initialIndex);
|
||||
setShowControls(true);
|
||||
setError(false);
|
||||
|
||||
@@ -18,6 +18,7 @@ import { VideoView, useVideoPlayer } from 'expo-video';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { spacing, fontSizes, borderRadius } from '../../theme';
|
||||
import { blurActiveElement } from '../../infrastructure/platform';
|
||||
|
||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||
|
||||
@@ -81,9 +82,8 @@ export const VideoPlayerModal: React.FC<VideoPlayerModalProps> = ({
|
||||
onClose,
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
if (visible && Platform.OS === 'web') {
|
||||
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
||||
active?.blur?.();
|
||||
if (visible) {
|
||||
blurActiveElement();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
|
||||
@@ -111,3 +111,16 @@ export type {
|
||||
UseDifferentialPostsResult,
|
||||
DiffUpdatesInfo,
|
||||
} from './useDifferentialPosts';
|
||||
|
||||
// ==================== 平台键盘 Hooks ====================
|
||||
export {
|
||||
usePlatformKeyboard,
|
||||
useKeyboardAvoidingProps,
|
||||
useKeyboardListener,
|
||||
} from './usePlatformKeyboard';
|
||||
|
||||
export type {
|
||||
KeyboardEventType,
|
||||
PlatformKeyboardOptions,
|
||||
PlatformKeyboardResult,
|
||||
} from './usePlatformKeyboard';
|
||||
|
||||
179
src/hooks/usePlatformKeyboard.ts
Normal file
179
src/hooks/usePlatformKeyboard.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* usePlatformKeyboard - 统一的跨平台键盘行为 Hook
|
||||
*
|
||||
* 消除散落在多个文件中的 Platform.OS 键盘事件判断
|
||||
*
|
||||
* 使用前(重复代码):
|
||||
* ```tsx
|
||||
* const showSub = Keyboard.addListener(
|
||||
* Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow',
|
||||
* handler
|
||||
* );
|
||||
* ```
|
||||
*
|
||||
* 使用后:
|
||||
* ```tsx
|
||||
* const { addListener } = usePlatformKeyboard();
|
||||
* const showSub = addListener('show', handler);
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { Keyboard, Platform, KeyboardEvent, EmitterSubscription } from 'react-native';
|
||||
|
||||
export type KeyboardEventType = 'show' | 'hide' | 'change';
|
||||
|
||||
export interface PlatformKeyboardOptions {
|
||||
onShow?: (event: KeyboardEvent) => void;
|
||||
onHide?: (event: KeyboardEvent) => void;
|
||||
onChange?: (height: number) => void;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface PlatformKeyboardResult {
|
||||
/** 键盘高度 */
|
||||
keyboardHeight: number;
|
||||
/** 键盘是否可见 */
|
||||
isKeyboardVisible: boolean;
|
||||
/** 平台最佳的 KeyboardAvoidingView behavior */
|
||||
keyboardBehavior: 'padding' | 'height' | undefined;
|
||||
/** 平台最佳的 keyboardVerticalOffset */
|
||||
keyboardVerticalOffset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取平台特定的键盘事件名称
|
||||
*/
|
||||
function getKeyboardEventName(type: KeyboardEventType): string {
|
||||
switch (type) {
|
||||
case 'show':
|
||||
return Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
||||
case 'hide':
|
||||
return Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
||||
case 'change':
|
||||
return Platform.OS === 'ios' ? 'keyboardWillChangeFrame' : 'keyboardDidShow';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 平台键盘 Hook
|
||||
*
|
||||
* 自动处理跨平台键盘事件差异
|
||||
*/
|
||||
export function usePlatformKeyboard(
|
||||
options: PlatformKeyboardOptions = {}
|
||||
): PlatformKeyboardResult {
|
||||
const { onShow, onHide, onChange, enabled = true } = options;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const subscriptions: EmitterSubscription[] = [];
|
||||
|
||||
if (onShow) {
|
||||
subscriptions.push(
|
||||
Keyboard.addListener(
|
||||
getKeyboardEventName('show') as any,
|
||||
onShow
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (onHide) {
|
||||
subscriptions.push(
|
||||
Keyboard.addListener(
|
||||
getKeyboardEventName('hide') as any,
|
||||
onHide
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (onChange) {
|
||||
const handleChange = (e: KeyboardEvent) => {
|
||||
onChange(e.endCoordinates.height);
|
||||
};
|
||||
subscriptions.push(
|
||||
Keyboard.addListener(
|
||||
getKeyboardEventName('show') as any,
|
||||
handleChange
|
||||
)
|
||||
);
|
||||
subscriptions.push(
|
||||
Keyboard.addListener(
|
||||
getKeyboardEventName('hide') as any,
|
||||
() => onChange(0)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
subscriptions.forEach(sub => sub.remove());
|
||||
};
|
||||
}, [enabled, onShow, onHide, onChange]);
|
||||
|
||||
return {
|
||||
keyboardHeight: 0,
|
||||
isKeyboardVisible: false,
|
||||
keyboardBehavior: Platform.OS === 'ios' ? 'padding' : undefined,
|
||||
keyboardVerticalOffset: Platform.OS === 'ios' ? 0 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取平台最佳的 KeyboardAvoidingView props
|
||||
*
|
||||
* 用于替代重复的 Platform.OS 三元判断:
|
||||
* ```tsx
|
||||
* // 之前
|
||||
* behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
*
|
||||
* // 之后
|
||||
* const { keyboardProps } = useKeyboardAvoidingProps();
|
||||
* <KeyboardAvoidingView {...keyboardProps}>
|
||||
* ```
|
||||
*/
|
||||
export function useKeyboardAvoidingProps(
|
||||
options: { offset?: number; defaultBehavior?: 'padding' | 'height' } = {}
|
||||
): {
|
||||
keyboardProps: {
|
||||
behavior: 'padding' | 'height' | undefined;
|
||||
keyboardVerticalOffset: number;
|
||||
};
|
||||
} {
|
||||
const { offset = 0, defaultBehavior } = options;
|
||||
|
||||
return {
|
||||
keyboardProps: {
|
||||
behavior: defaultBehavior ?? (Platform.OS === 'ios' ? 'padding' : undefined),
|
||||
keyboardVerticalOffset: Platform.OS === 'ios' ? offset : 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 键盘事件监听 Hook(简化版)
|
||||
*
|
||||
* 返回一个平台无关的 addListener 函数
|
||||
*/
|
||||
export function useKeyboardListener() {
|
||||
const subscriptionsRef = useRef<EmitterSubscription[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
subscriptionsRef.current.forEach(sub => sub.remove());
|
||||
subscriptionsRef.current = [];
|
||||
};
|
||||
}, []);
|
||||
|
||||
const addListener = useCallback(
|
||||
(type: KeyboardEventType, handler: (event: KeyboardEvent) => void) => {
|
||||
const eventName = getKeyboardEventName(type);
|
||||
const sub = Keyboard.addListener(eventName as any, handler);
|
||||
subscriptionsRef.current.push(sub);
|
||||
return sub;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return { addListener };
|
||||
}
|
||||
83
src/infrastructure/platform/domUtils.ts
Normal file
83
src/infrastructure/platform/domUtils.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Web Platform DOM Utilities
|
||||
*
|
||||
* 提供Web平台特定的DOM操作工具函数
|
||||
* 解决跨平台代码中需要访问Web DOM的补丁问题
|
||||
*/
|
||||
|
||||
import { Platform, Keyboard } from 'react-native';
|
||||
|
||||
/**
|
||||
* Blur当前激活的DOM元素(Web)并收起键盘(移动端)
|
||||
*
|
||||
* - Web: blur DOM active element
|
||||
* - iOS/Android: Keyboard.dismiss()
|
||||
*
|
||||
* 替代散落在多个文件中的重复代码
|
||||
*/
|
||||
export function blurActiveElement(): void {
|
||||
if (Platform.OS === 'web') {
|
||||
try {
|
||||
const activeElement = (globalThis as any)?.document?.activeElement as
|
||||
{ blur?: () => void } | undefined;
|
||||
|
||||
if (activeElement?.blur) {
|
||||
activeElement.blur();
|
||||
}
|
||||
} catch {
|
||||
// 安全忽略:某些环境下document可能不可访问
|
||||
}
|
||||
} else {
|
||||
Keyboard.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查当前是否有激活的DOM元素(仅Web平台)
|
||||
*/
|
||||
export function hasActiveElement(): boolean {
|
||||
if (Platform.OS !== 'web') return false;
|
||||
|
||||
try {
|
||||
const doc = (globalThis as any)?.document;
|
||||
return !!doc?.activeElement && doc.activeElement !== doc.body;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前激活元素的类型(仅Web平台)
|
||||
* 用于判断是否需要特殊处理(如输入框)
|
||||
*/
|
||||
export function getActiveElementType(): string | null {
|
||||
if (Platform.OS !== 'web') return null;
|
||||
|
||||
try {
|
||||
const activeElement = (globalThis as any)?.document?.activeElement as
|
||||
{ tagName?: string; type?: string } | undefined;
|
||||
|
||||
if (!activeElement) return null;
|
||||
|
||||
const tagName = activeElement.tagName?.toLowerCase();
|
||||
const type = activeElement.type?.toLowerCase();
|
||||
|
||||
return tagName === 'input' ? `input:${type || 'text'}` : tagName || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前激活元素是否为输入类型
|
||||
*/
|
||||
export function isInputFocused(): boolean {
|
||||
const elementType = getActiveElementType();
|
||||
if (!elementType) return false;
|
||||
|
||||
return (
|
||||
elementType.startsWith('input:') ||
|
||||
elementType === 'textarea' ||
|
||||
elementType === 'select'
|
||||
);
|
||||
}
|
||||
12
src/infrastructure/platform/index.ts
Normal file
12
src/infrastructure/platform/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Platform Infrastructure
|
||||
*
|
||||
* 平台相关的基础设施代码
|
||||
*/
|
||||
|
||||
export {
|
||||
blurActiveElement,
|
||||
hasActiveElement,
|
||||
getActiveElementType,
|
||||
isInputFocused,
|
||||
} from './domUtils';
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -38,6 +38,7 @@ import { processPostUseCase } from '../../core/usecases/ProcessPostUseCase';
|
||||
import { SearchScreen } from './SearchScreen';
|
||||
import { CreatePostScreen } from '../create/CreatePostScreen';
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
import { blurActiveElement } from '../../infrastructure/platform';
|
||||
|
||||
const TABS = ['关注', '最新', '热门'];
|
||||
const TAB_ICONS = ['account-heart-outline', 'clock-outline', 'fire'];
|
||||
@@ -203,9 +204,8 @@ export const HomeScreen: React.FC = () => {
|
||||
const [showCreatePost, setShowCreatePost] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (showCreatePost && Platform.OS === 'web') {
|
||||
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
||||
active?.blur?.();
|
||||
if (showCreatePost) {
|
||||
blurActiveElement();
|
||||
}
|
||||
}, [showCreatePost]);
|
||||
|
||||
|
||||
@@ -42,8 +42,35 @@ import { useCursorPagination } from '../../hooks/useCursorPagination';
|
||||
import { CommentItem, VoteCard, ReportDialog } from '../../components/business';
|
||||
import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout, AppBackButton } from '../../components/common';
|
||||
import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks/useResponsive';
|
||||
import { handleError } from '../../services/errorHandler';
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
|
||||
function togglePostField(
|
||||
post: Post,
|
||||
field: 'is_liked' | 'is_favorited',
|
||||
countField: 'likes_count' | 'favorites_count',
|
||||
): Post {
|
||||
const oldValue = post[field];
|
||||
const oldCount = post[countField];
|
||||
return {
|
||||
...post,
|
||||
[field]: !oldValue,
|
||||
[countField]: oldValue ? Math.max(0, oldCount - 1) : oldCount + 1,
|
||||
};
|
||||
}
|
||||
|
||||
function toggleCommentLikeInTree(comments: Comment[], commentId: string, isLiked: boolean, likesCount: number): Comment[] {
|
||||
return comments.map(c => {
|
||||
if (c.id === commentId) {
|
||||
return { ...c, is_liked: isLiked, likes_count: likesCount };
|
||||
}
|
||||
if (c.replies?.length) {
|
||||
return { ...c, replies: toggleCommentLikeInTree(c.replies, commentId, isLiked, likesCount) };
|
||||
}
|
||||
return c;
|
||||
});
|
||||
}
|
||||
|
||||
export const PostDetailScreen: React.FC = () => {
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createPostDetailStyles(colors), [colors]);
|
||||
@@ -217,20 +244,17 @@ export const PostDetailScreen: React.FC = () => {
|
||||
setPost(postData as unknown as Post);
|
||||
// 初始化关注状态
|
||||
if (postData.author) {
|
||||
setIsFollowing((postData.author as any).is_following || false);
|
||||
setIsFollowingMe((postData.author as any).is_following_me || false);
|
||||
setIsFollowing(postData.author.is_following || false);
|
||||
setIsFollowingMe(postData.author.is_following_me || false);
|
||||
}
|
||||
// 只在首次加载时记录浏览量
|
||||
if (recordView && !hasRecordedView.current) {
|
||||
if (!hasRecordedView.current) {
|
||||
hasRecordedView.current = true;
|
||||
// 异步记录浏览量,不阻塞加载
|
||||
postService.recordView(postId).catch(err => {
|
||||
console.error('记录浏览量失败:', err);
|
||||
});
|
||||
}
|
||||
|
||||
// 如果是投票帖子,立即加载投票数据
|
||||
if ((postData as any).is_vote) {
|
||||
if (postData.is_vote) {
|
||||
setIsVoteLoading(true);
|
||||
try {
|
||||
const voteData = await voteService.getVoteResult(postId);
|
||||
@@ -251,8 +275,8 @@ export const PostDetailScreen: React.FC = () => {
|
||||
setPost(updatedPost);
|
||||
// 初始化关注状态
|
||||
if (updatedPost.author) {
|
||||
setIsFollowing((updatedPost.author as any).is_following || false);
|
||||
setIsFollowingMe((updatedPost.author as any).is_following_me || false);
|
||||
setIsFollowing(updatedPost.author.is_following || false);
|
||||
setIsFollowingMe(updatedPost.author.is_following_me || false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -260,7 +284,7 @@ export const PostDetailScreen: React.FC = () => {
|
||||
// 加载评论(使用游标分页刷新)
|
||||
await refreshComments();
|
||||
} catch (error) {
|
||||
console.error('加载帖子详情失败:', error);
|
||||
handleError(error, { context: '加载帖子详情' });
|
||||
} finally {
|
||||
setIsPostInitialLoading(false);
|
||||
}
|
||||
@@ -458,65 +482,36 @@ export const PostDetailScreen: React.FC = () => {
|
||||
const handleLike = useCallback(async () => {
|
||||
if (!post) return;
|
||||
|
||||
// 先保存旧状态用于回滚
|
||||
const oldIsLiked = post.is_liked;
|
||||
const oldLikesCount = post.likes_count;
|
||||
|
||||
// 乐观更新本地状态
|
||||
setPost(prev => prev ? {
|
||||
...prev,
|
||||
is_liked: !prev.is_liked,
|
||||
likes_count: prev.is_liked ? prev.likes_count - 1 : prev.likes_count + 1
|
||||
} : null);
|
||||
const originalPost = post;
|
||||
setPost(prev => prev ? togglePostField(prev, 'is_liked', 'likes_count') : null);
|
||||
|
||||
try {
|
||||
if (oldIsLiked) {
|
||||
if (originalPost.is_liked) {
|
||||
await processPostUseCase.unlikePost(post.id);
|
||||
} else {
|
||||
await processPostUseCase.likePost(post.id);
|
||||
}
|
||||
// UseCase 会更新 store,本地状态已经是乐观更新的,无需再次更新
|
||||
} catch (error) {
|
||||
console.error('点赞操作失败:', error);
|
||||
// 失败时回滚状态
|
||||
setPost(prev => prev ? {
|
||||
...prev,
|
||||
is_liked: oldIsLiked,
|
||||
likes_count: oldLikesCount
|
||||
} : null);
|
||||
handleError(error, { context: '点赞' });
|
||||
setPost(originalPost);
|
||||
}
|
||||
}, [post]);
|
||||
|
||||
// 收藏帖子
|
||||
const handleFavorite = useCallback(async () => {
|
||||
if (!post) return;
|
||||
|
||||
// 先保存旧状态用于回滚
|
||||
const oldIsFavorited = post.is_favorited;
|
||||
const oldFavoritesCount = post.favorites_count;
|
||||
|
||||
// 乐观更新本地状态
|
||||
setPost(prev => prev ? {
|
||||
...prev,
|
||||
is_favorited: !prev.is_favorited,
|
||||
favorites_count: prev.is_favorited ? prev.favorites_count - 1 : prev.favorites_count + 1
|
||||
} : null);
|
||||
const originalPost = post;
|
||||
setPost(prev => prev ? togglePostField(prev, 'is_favorited', 'favorites_count') : null);
|
||||
|
||||
try {
|
||||
if (oldIsFavorited) {
|
||||
if (originalPost.is_favorited) {
|
||||
await processPostUseCase.unfavoritePost(post.id);
|
||||
} else {
|
||||
await processPostUseCase.favoritePost(post.id);
|
||||
}
|
||||
// UseCase 会更新 store,本地状态已经是乐观更新的,无需再次更新
|
||||
} catch (error) {
|
||||
console.error('收藏操作失败:', error);
|
||||
// 失败时回滚状态
|
||||
setPost(prev => prev ? {
|
||||
...prev,
|
||||
is_favorited: oldIsFavorited,
|
||||
favorites_count: oldFavoritesCount
|
||||
} : null);
|
||||
handleError(error, { context: '收藏' });
|
||||
setPost(originalPost);
|
||||
}
|
||||
}, [post]);
|
||||
|
||||
@@ -573,24 +568,19 @@ export const PostDetailScreen: React.FC = () => {
|
||||
setVoteResult(oldVoteResult);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('投票失败:', error);
|
||||
// 失败时回滚
|
||||
handleError(error, { context: '投票' });
|
||||
setVoteResult(oldVoteResult);
|
||||
} finally {
|
||||
setIsVoteLoading(false);
|
||||
}
|
||||
}, [post, voteResult, isVoteLoading]);
|
||||
|
||||
// 取消投票处理函数
|
||||
const handleUnvote = useCallback(async () => {
|
||||
if (!post || !voteResult || isVoteLoading || !voteResult.voted_option_id) return;
|
||||
|
||||
const votedOptionId = voteResult.voted_option_id;
|
||||
|
||||
// 保存旧状态用于回滚
|
||||
const oldVoteResult = { ...voteResult };
|
||||
|
||||
// 乐观更新
|
||||
setVoteResult((prev: VoteResultDTO | null) => {
|
||||
if (!prev) return null;
|
||||
return {
|
||||
@@ -611,12 +601,10 @@ export const PostDetailScreen: React.FC = () => {
|
||||
try {
|
||||
const success = await voteService.unvote(post.id);
|
||||
if (!success) {
|
||||
// 失败时回滚
|
||||
setVoteResult(oldVoteResult);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('取消投票失败:', error);
|
||||
// 失败时回滚
|
||||
handleError(error, { context: '取消投票' });
|
||||
setVoteResult(oldVoteResult);
|
||||
} finally {
|
||||
setIsVoteLoading(false);
|
||||
@@ -899,26 +887,15 @@ export const PostDetailScreen: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 点赞评论(包括回复)- 优化版
|
||||
const handleLikeComment = async (comment: Comment) => {
|
||||
const { id, is_liked, likes_count } = comment;
|
||||
|
||||
// 乐观更新:切换点赞状态
|
||||
const newIsLiked = !is_liked;
|
||||
const oldIsLiked = is_liked ?? false;
|
||||
const newIsLiked = !oldIsLiked;
|
||||
const newLikesCount = newIsLiked ? likes_count + 1 : Math.max(0, likes_count - 1);
|
||||
const originalComments = comments;
|
||||
|
||||
// 递归更新评论树中的点赞状态
|
||||
const updateLikeInTree = (c: Comment): Comment => {
|
||||
if (c.id === id) {
|
||||
return { ...c, is_liked: newIsLiked, likes_count: newLikesCount };
|
||||
}
|
||||
if (c.replies?.length) {
|
||||
return { ...c, replies: c.replies.map(r => updateLikeInTree(r)) };
|
||||
}
|
||||
return c;
|
||||
};
|
||||
|
||||
setComments(prev => prev.map(c => updateLikeInTree(c)));
|
||||
setComments(prev => toggleCommentLikeInTree(prev, id, newIsLiked, newLikesCount));
|
||||
|
||||
try {
|
||||
const success = newIsLiked
|
||||
@@ -929,18 +906,8 @@ export const PostDetailScreen: React.FC = () => {
|
||||
throw new Error('点赞操作失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('评论点赞操作失败:', error);
|
||||
// 回滚状态
|
||||
const rollbackLikeInTree = (c: Comment): Comment => {
|
||||
if (c.id === id) {
|
||||
return { ...c, is_liked, likes_count };
|
||||
}
|
||||
if (c.replies?.length) {
|
||||
return { ...c, replies: c.replies.map(r => rollbackLikeInTree(r)) };
|
||||
}
|
||||
return c;
|
||||
};
|
||||
setComments(prev => prev.map(c => rollbackLikeInTree(c)));
|
||||
handleError(error, { context: '评论点赞' });
|
||||
setComments(toggleCommentLikeInTree(originalComments, id, oldIsLiked, likes_count));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { blurActiveElement } from '../../infrastructure/platform';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { spacing, fontSizes, borderRadius, shadows, useAppColors, type AppColors } from '../../theme';
|
||||
import { groupService } from '../../services/groupService';
|
||||
@@ -47,9 +48,8 @@ const CreateGroupScreen: React.FC = () => {
|
||||
const [inviteModalVisible, setInviteModalVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (inviteModalVisible && Platform.OS === 'web') {
|
||||
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
||||
active?.blur?.();
|
||||
if (inviteModalVisible) {
|
||||
blurActiveElement();
|
||||
}
|
||||
}, [inviteModalVisible]);
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
JoinType,
|
||||
} from '../../types/dto';
|
||||
import { User } from '../../types';
|
||||
import { blurActiveElement } from '../../infrastructure/platform';
|
||||
import { firstRouteParam } from '../../navigation/paramUtils';
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
import { messageManager } from '../../stores/messageManager';
|
||||
@@ -119,9 +120,8 @@ const GroupInfoScreen: React.FC = () => {
|
||||
const [inviting, setInviting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if ((editModalVisible || announcementModalVisible || transferModalVisible || joinTypeModalVisible || inviteModalVisible) && Platform.OS === 'web') {
|
||||
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
||||
active?.blur?.();
|
||||
if (editModalVisible || announcementModalVisible || transferModalVisible || joinTypeModalVisible || inviteModalVisible) {
|
||||
blurActiveElement();
|
||||
}
|
||||
}, [editModalVisible, announcementModalVisible, transferModalVisible, joinTypeModalVisible, inviteModalVisible]);
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { blurActiveElement } from '../../infrastructure/platform';
|
||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||
import { useAuthStore } from '../../stores';
|
||||
import { groupService } from '../../services/groupService';
|
||||
@@ -133,9 +134,8 @@ const GroupMembersScreen: React.FC = () => {
|
||||
const [newNickname, setNewNickname] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if ((actionModalVisible || nicknameModalVisible) && Platform.OS === 'web') {
|
||||
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
||||
active?.blur?.();
|
||||
if (actionModalVisible || nicknameModalVisible) {
|
||||
blurActiveElement();
|
||||
}
|
||||
}, [actionModalVisible, nicknameModalVisible]);
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
} from '../../theme';
|
||||
import { ConversationResponse, UserDTO, MessageResponse, extractTextFromSegments } from '../../types/dto';
|
||||
import { authService } from '../../services';
|
||||
import { blurActiveElement } from '../../infrastructure/platform';
|
||||
import { useUserStore, useAuthStore } from '../../stores';
|
||||
// 【新架构】使用MessageManager hooks(会话列表数据源自 MessageManager 游标同步)
|
||||
import {
|
||||
@@ -138,9 +139,8 @@ export const MessageListScreen: React.FC = () => {
|
||||
const [actionMenuVisible, setActionMenuVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (actionMenuVisible && Platform.OS === 'web') {
|
||||
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
||||
active?.blur?.();
|
||||
if (actionMenuVisible) {
|
||||
blurActiveElement();
|
||||
}
|
||||
}, [actionMenuVisible]);
|
||||
const [scannerVisible, setScannerVisible] = useState(false);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -15,15 +15,10 @@ import { useResponsive, useBreakpointGTE } from '../../../../hooks/useResponsive
|
||||
import { EMOJIS } from './constants';
|
||||
import { CustomSticker, getCustomStickers, batchDeleteStickers, addStickerFromUrl } from '../../../../services/stickerService';
|
||||
import { useAppColors, spacing } from '../../../../theme';
|
||||
import { blurActiveElement } from '../../../../infrastructure/platform';
|
||||
|
||||
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||
|
||||
const blurActiveElementOnWeb = () => {
|
||||
if (Platform.OS !== 'web') return;
|
||||
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
||||
active?.blur?.();
|
||||
};
|
||||
|
||||
// 表情尺寸配置 - 固定宽度 40px,使用 flex 布局
|
||||
const EMOJI_SIZES = {
|
||||
mobile: { size: 24, itemWidth: 40, itemHeight: 40 },
|
||||
@@ -182,7 +177,7 @@ export const EmojiPanel: React.FC<EmojiPanelProps> = ({
|
||||
|
||||
// 打开管理界面
|
||||
const handleOpenManage = () => {
|
||||
blurActiveElementOnWeb();
|
||||
blurActiveElement();
|
||||
setShowManageModal(true);
|
||||
setManageMode(false);
|
||||
setSelectedStickers(new Set());
|
||||
|
||||
@@ -20,6 +20,7 @@ import { LongPressMenuProps } from './types';
|
||||
import { RECALL_TIME_LIMIT } from './constants';
|
||||
import { extractTextFromSegments, ImageSegmentData } from '../../../../types/dto';
|
||||
import { isStickerExists, addStickerFromUrl } from '../../../../services/stickerService';
|
||||
import { blurActiveElement } from '../../../../infrastructure/platform';
|
||||
|
||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||
|
||||
@@ -37,16 +38,11 @@ export const LongPressMenu: React.FC<LongPressMenuProps> = ({
|
||||
const styles = useChatScreenStyles();
|
||||
const scaleAnimation = useRef(new Animated.Value(0)).current;
|
||||
const opacityAnimation = useRef(new Animated.Value(0)).current;
|
||||
const blurActiveElementOnWeb = () => {
|
||||
if (Platform.OS !== 'web') return;
|
||||
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
||||
active?.blur?.();
|
||||
};
|
||||
|
||||
// 显示动画 - 缩放弹出
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
blurActiveElementOnWeb();
|
||||
blurActiveElement();
|
||||
Animated.parallel([
|
||||
Animated.spring(scaleAnimation, {
|
||||
toValue: 1,
|
||||
@@ -61,7 +57,7 @@ export const LongPressMenu: React.FC<LongPressMenuProps> = ({
|
||||
}),
|
||||
]).start();
|
||||
} else {
|
||||
blurActiveElementOnWeb();
|
||||
blurActiveElement();
|
||||
Animated.parallel([
|
||||
Animated.timing(scaleAnimation, {
|
||||
toValue: 0,
|
||||
@@ -251,12 +247,12 @@ export const LongPressMenu: React.FC<LongPressMenuProps> = ({
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="none"
|
||||
onShow={blurActiveElementOnWeb}
|
||||
onShow={blurActiveElement}
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
blurActiveElementOnWeb();
|
||||
blurActiveElement();
|
||||
onClose();
|
||||
}}
|
||||
style={styles.qqMenuOverlay}
|
||||
|
||||
@@ -31,7 +31,7 @@ const MAX_WIDTH_RATIO = {
|
||||
desktop: 0.55,
|
||||
};
|
||||
|
||||
export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
|
||||
message,
|
||||
index,
|
||||
currentUserId,
|
||||
@@ -481,11 +481,37 @@ const segmentStyles = StyleSheet.create({
|
||||
minWidth: 120,
|
||||
},
|
||||
pureImageBubble: {
|
||||
// 纯图片消息:去除内边距,让图片紧贴气泡边缘
|
||||
paddingHorizontal: 0,
|
||||
paddingVertical: 0,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
});
|
||||
|
||||
export const MessageBubble = React.memo(MessageBubbleInner, (prev, next) => {
|
||||
if (prev.message.id !== next.message.id) return false;
|
||||
if (prev.message.status !== next.message.status) return false;
|
||||
if (prev.message.segments !== next.message.segments) return false;
|
||||
if (prev.message.sender_id !== next.message.sender_id) return false;
|
||||
if (prev.message.is_system_notice !== next.message.is_system_notice) return false;
|
||||
if (prev.message.category !== next.message.category) return false;
|
||||
if (prev.message.seq !== next.message.seq) return false;
|
||||
if (prev.message.sender !== next.message.sender) return false;
|
||||
if (prev.message.notice_content !== next.message.notice_content) return false;
|
||||
if (prev.index !== next.index) return false;
|
||||
if (prev.currentUserId !== next.currentUserId) return false;
|
||||
if (prev.otherUserLastReadSeq !== next.otherUserLastReadSeq) return false;
|
||||
if (prev.selectedMessageId !== next.selectedMessageId) return false;
|
||||
if (prev.isGroupChat !== next.isGroupChat) return false;
|
||||
if (prev.groupMembers !== next.groupMembers) return false;
|
||||
if (prev.currentUser !== next.currentUser) return false;
|
||||
if (prev.otherUser !== next.otherUser) return false;
|
||||
if (prev.messageMap !== next.messageMap) return false;
|
||||
if (prev.onLongPress !== next.onLongPress) return false;
|
||||
if (prev.onImagePress !== next.onImagePress) return false;
|
||||
if (prev.onReplyPress !== next.onReplyPress) return false;
|
||||
if (prev.shouldShowTime !== next.shouldShowTime) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
export default MessageBubble;
|
||||
|
||||
@@ -165,7 +165,7 @@ const renderTextSegment = (data: TextSegmentData, isMe: boolean): React.ReactNod
|
||||
* 渲染图片 Segment
|
||||
*/
|
||||
// 图片Segment组件 - 使用Hook获取实际尺寸
|
||||
const ImageSegment: React.FC<{
|
||||
const ImageSegmentInner: React.FC<{
|
||||
data: ImageSegmentData;
|
||||
isMe: boolean;
|
||||
onImagePress?: (url: string) => void;
|
||||
@@ -284,7 +284,6 @@ const ImageSegment: React.FC<{
|
||||
style={{
|
||||
width: IMAGE_FALLBACK_SIZE.width,
|
||||
height: IMAGE_FALLBACK_SIZE.height,
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'rgba(0,0,0,0.1)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
@@ -312,7 +311,6 @@ const ImageSegment: React.FC<{
|
||||
style={{
|
||||
width: IMAGE_FALLBACK_SIZE.width,
|
||||
height: IMAGE_FALLBACK_SIZE.height,
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'rgba(0,0,0,0.1)',
|
||||
}}
|
||||
/>
|
||||
@@ -334,7 +332,6 @@ const ImageSegment: React.FC<{
|
||||
style={{
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
recyclingKey={stableImageKey}
|
||||
contentFit="cover"
|
||||
@@ -345,7 +342,6 @@ const ImageSegment: React.FC<{
|
||||
setLoadError(false);
|
||||
}}
|
||||
onError={() => {
|
||||
// 缩略图失败时自动回退原图,降低跳转定位后的白块/丢图概率
|
||||
if (data.thumbnail_url && data.url && imageUrl === data.thumbnail_url) {
|
||||
setCurrentImageUri(data.url);
|
||||
return;
|
||||
@@ -357,6 +353,17 @@ const ImageSegment: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
const ImageSegment = React.memo(ImageSegmentInner, (prev, next) => {
|
||||
if (prev.data.url !== next.data.url) return false;
|
||||
if (prev.data.thumbnail_url !== next.data.thumbnail_url) return false;
|
||||
if (prev.data.width !== next.data.width) return false;
|
||||
if (prev.data.height !== next.data.height) return false;
|
||||
if (prev.isMe !== next.isMe) return false;
|
||||
if (prev.onImagePress !== next.onImagePress) return false;
|
||||
if (prev.onImageLongPress !== next.onImageLongPress) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const renderImageSegment = (
|
||||
data: ImageSegmentData,
|
||||
props: Omit<SegmentRendererProps, 'segment'>
|
||||
@@ -705,7 +712,7 @@ export const ReplyPreviewSegment: React.FC<ReplyPreviewSegmentProps> = ({
|
||||
/**
|
||||
* 渲染完整的消息链(多个 Segment 组合)
|
||||
*/
|
||||
export const MessageSegmentsRenderer: React.FC<{
|
||||
const MessageSegmentsRendererInner: React.FC<{
|
||||
segments: MessageSegment[];
|
||||
isMe: boolean;
|
||||
currentUserId?: string;
|
||||
@@ -734,11 +741,11 @@ export const MessageSegmentsRenderer: React.FC<{
|
||||
const fontSize = useChatSettingsStore((s) => s.fontSize);
|
||||
const segStyles = useMemo(() => createSegmentStyles(themeColors, fontSize), [themeColors, fontSize]);
|
||||
|
||||
const replySegment = segments.find(s => s.type === 'reply');
|
||||
const otherSegments = segments.filter(s => s.type !== 'reply');
|
||||
const chunks = partitionMessageSegments(otherSegments);
|
||||
const replySegment = useMemo(() => segments.find(s => s.type === 'reply'), [segments]);
|
||||
const otherSegments = useMemo(() => segments.filter(s => s.type !== 'reply'), [segments]);
|
||||
const chunks = useMemo(() => partitionMessageSegments(otherSegments), [otherSegments]);
|
||||
|
||||
const renderProps = {
|
||||
const renderProps = useMemo(() => ({
|
||||
isMe,
|
||||
currentUserId,
|
||||
memberMap,
|
||||
@@ -747,7 +754,7 @@ export const MessageSegmentsRenderer: React.FC<{
|
||||
onImagePress,
|
||||
onImageLongPress,
|
||||
onLinkPress,
|
||||
};
|
||||
}), [isMe, currentUserId, memberMap, onAtPress, onReplyPress, onImagePress, onImageLongPress, onLinkPress]);
|
||||
|
||||
return (
|
||||
<SegmentStylesContext.Provider value={segStyles}>
|
||||
@@ -837,13 +844,16 @@ function createSegmentStyles(colors: AppColors, fontSize: number = 16) {
|
||||
},
|
||||
imagesChunk: {
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
imageStackItem: {
|
||||
width: '100%',
|
||||
marginBottom: 6,
|
||||
marginBottom: 4,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
imageStackItemLast: {
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
blockChunk: {
|
||||
width: '100%',
|
||||
@@ -864,14 +874,13 @@ function createSegmentStyles(colors: AppColors, fontSize: number = 16) {
|
||||
color: colors.chat.textPrimary,
|
||||
},
|
||||
|
||||
// @提及 - 微信/QQ 风格:更精致的高亮,支持动态字号
|
||||
// @提及 - 与普通文本字号一致,仅颜色区分
|
||||
atText: {
|
||||
fontSize,
|
||||
lineHeight: Math.round(fontSize * 1.4),
|
||||
fontWeight: '500',
|
||||
lineHeight: Math.round(fontSize * 1.43),
|
||||
},
|
||||
atTextMe: {
|
||||
color: '#1A5F9E', // 深蓝色,在绿色背景上更清晰
|
||||
color: '#1A5F9E',
|
||||
},
|
||||
atTextOther: {
|
||||
color: colors.chat.link,
|
||||
@@ -879,25 +888,15 @@ function createSegmentStyles(colors: AppColors, fontSize: number = 16) {
|
||||
atHighlight: {
|
||||
backgroundColor: 'rgba(74, 136, 199, 0.15)',
|
||||
borderRadius: 4,
|
||||
paddingHorizontal: 3,
|
||||
},
|
||||
|
||||
// 图片 - QQ风格:保持原始宽高比
|
||||
imageContainer: {
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
marginTop: 4,
|
||||
shadowColor: colors.chat.shadow,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 4,
|
||||
elevation: 4,
|
||||
},
|
||||
imageMe: {
|
||||
borderBottomRightRadius: 4,
|
||||
},
|
||||
imageOther: {
|
||||
borderBottomLeftRadius: 4,
|
||||
},
|
||||
// 移除固定的 image 样式,改为在组件中动态计算
|
||||
|
||||
@@ -1143,4 +1142,19 @@ function useSegmentStyles(): SegmentStyles {
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export const MessageSegmentsRenderer = React.memo(MessageSegmentsRendererInner, (prev, next) => {
|
||||
if (prev.segments !== next.segments) return false;
|
||||
if (prev.isMe !== next.isMe) return false;
|
||||
if (prev.currentUserId !== next.currentUserId) return false;
|
||||
if (prev.memberMap !== next.memberMap) return false;
|
||||
if (prev.replyMessage !== next.replyMessage) return false;
|
||||
if (prev.onAtPress !== next.onAtPress) return false;
|
||||
if (prev.onReplyPress !== next.onReplyPress) return false;
|
||||
if (prev.onImagePress !== next.onImagePress) return false;
|
||||
if (prev.onImageLongPress !== next.onImageLongPress) return false;
|
||||
if (prev.onLinkPress !== next.onLinkPress) return false;
|
||||
if (prev.getSenderInfo !== next.getSenderInfo) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
export default MessageSegmentsRenderer;
|
||||
@@ -174,9 +174,9 @@ export const useChatScreen = () => {
|
||||
status: (m.status || 'normal') as MessageStatus,
|
||||
category: m.category,
|
||||
created_at: m.created_at,
|
||||
sender: (m as any).sender,
|
||||
is_system_notice: (m as any).is_system_notice,
|
||||
notice_content: (m as any).notice_content,
|
||||
sender: m.sender,
|
||||
is_system_notice: m.is_system_notice,
|
||||
notice_content: m.notice_content,
|
||||
}));
|
||||
}, [messageManagerMessages]);
|
||||
|
||||
@@ -258,7 +258,7 @@ export const useChatScreen = () => {
|
||||
setOtherUser(prev => ({ ...(prev || {}), ...other }));
|
||||
}
|
||||
// 获取对方最后阅读位置
|
||||
setOtherUserLastReadSeq((conversation as any).other_last_read_seq || 0);
|
||||
setOtherUserLastReadSeq(conversation.other_last_read_seq || 0);
|
||||
}
|
||||
}, [conversation, isGroupChat, currentUserId]);
|
||||
|
||||
@@ -273,8 +273,8 @@ export const useChatScreen = () => {
|
||||
const detail = await userManager.getUserById(String(otherUserId), true);
|
||||
if (!detail || cancelled) return;
|
||||
setOtherUser(prev => ({ ...(prev || {}), ...detail }));
|
||||
if (typeof (detail as any).is_following_me === 'boolean') {
|
||||
setIsFollowedByOther((detail as any).is_following_me);
|
||||
if (typeof detail.is_following_me === 'boolean') {
|
||||
setIsFollowedByOther(detail.is_following_me);
|
||||
} else {
|
||||
setIsFollowedByOther(null);
|
||||
}
|
||||
@@ -360,7 +360,7 @@ export const useChatScreen = () => {
|
||||
}, [loading, loadingMore, messages.length, scrollToLatest]);
|
||||
|
||||
// 新消息跟随策略(Telegram/QQ 风格):
|
||||
// 仅当“最新端消息 seq 增长”且用户在底部附近时才跟随;
|
||||
// 仅当"最新端消息 seq 增长"且用户在底部附近时才跟随;
|
||||
// 历史加载只会增加旧消息,不会提升 latest seq,因此不会触发回底。
|
||||
useEffect(() => {
|
||||
const currentCount = messages.length;
|
||||
@@ -371,14 +371,14 @@ export const useChatScreen = () => {
|
||||
|
||||
if (loading || loadingMore) return;
|
||||
if (latestSeq <= prevLatestSeq) return;
|
||||
if (suppressAutoFollowRef.current) return;
|
||||
if (suppressAutoFollowRef.current || isBrowsingHistoryRef.current || isHistoryLoadingLocked()) return;
|
||||
if (!isNearBottom()) return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
scrollToLatest(false, false, 'new-message-follow');
|
||||
}, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}, [messages, loading, loadingMore, isNearBottom, scrollToLatest]);
|
||||
}, [messages, loading, loadingMore, isNearBottom, scrollToLatest, isHistoryLoadingLocked]);
|
||||
|
||||
// 获取当前用户信息
|
||||
useEffect(() => {
|
||||
@@ -559,10 +559,10 @@ export const useChatScreen = () => {
|
||||
if (!conversationId) return;
|
||||
if (!conversation) return;
|
||||
|
||||
const unreadCount = Number((conversation as any).unread_count || 0);
|
||||
const unreadCount = Number(conversation.unread_count || 0);
|
||||
if (unreadCount <= 0) return;
|
||||
|
||||
const latestFromConversation = Number((conversation as any).last_seq || 0);
|
||||
const latestFromConversation = Number(conversation.last_seq || 0);
|
||||
const latestFromMessages = messages.length > 0 ? Math.max(...messages.map(m => m.seq || 0)) : 0;
|
||||
const targetSeq = Math.max(latestFromConversation, latestFromMessages);
|
||||
if (targetSeq <= 0) return;
|
||||
@@ -1305,8 +1305,8 @@ export const useChatScreen = () => {
|
||||
|
||||
// 关闭所有面板
|
||||
const handleDismiss = useCallback(() => {
|
||||
Keyboard.dismiss();
|
||||
if (activePanel !== 'none') {
|
||||
Keyboard.dismiss();
|
||||
setActivePanel('none');
|
||||
}
|
||||
}, [activePanel]);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '
|
||||
import { authService } from '../../../services/authService';
|
||||
import { useAuthStore } from '../../../stores';
|
||||
import { Avatar, EmptyState, Loading, Text } from '../../../components/common';
|
||||
import { blurActiveElement } from '../../../infrastructure/platform';
|
||||
import { User } from '../../../types';
|
||||
|
||||
type MutualFollowSelectorModalProps = {
|
||||
@@ -57,10 +58,7 @@ const MutualFollowSelectorModal: React.FC<MutualFollowSelectorModalProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
if (Platform.OS === 'web') {
|
||||
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
||||
active?.blur?.();
|
||||
}
|
||||
blurActiveElement();
|
||||
if (!currentUserId) return;
|
||||
|
||||
const nextSelectedIds = new Set(stableInitialSelectedIds);
|
||||
|
||||
@@ -482,7 +482,6 @@ export const AboutScreen: React.FC = () => {
|
||||
const appInfoItems = [
|
||||
{ key: 'name', icon: 'information-outline', title: '应用名称', value: APP_NAME },
|
||||
{ key: 'version', icon: 'tag-outline', title: '版本号', value: `v${APP_VERSION} (${BUILD_NUMBER})` },
|
||||
{ key: 'developer', icon: 'office-building-outline', title: '开发者', value: '青春之旅电子信息科技' },
|
||||
];
|
||||
|
||||
const renderSettingItem = (item: { key: string; icon: string; title: string; value?: string; action?: () => void }, index: number, total: number, showArrow: boolean = false) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -22,6 +22,7 @@ import { PanGestureHandler, State } from 'react-native-gesture-handler';
|
||||
import type { PanGestureHandlerStateChangeEvent } from 'react-native-gesture-handler';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { blurActiveElement } from '../../infrastructure/platform';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import {
|
||||
@@ -214,9 +215,8 @@ export const ScheduleScreen: React.FC = () => {
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if ((isAddModalVisible || isSettingsModalVisible || isSyncModalVisible) && Platform.OS === 'web') {
|
||||
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
||||
active?.blur?.();
|
||||
if (isAddModalVisible || isSettingsModalVisible || isSyncModalVisible) {
|
||||
blurActiveElement();
|
||||
}
|
||||
}, [isAddModalVisible, isSettingsModalVisible, isSyncModalVisible]);
|
||||
|
||||
|
||||
284
src/services/BackgroundSyncManager.ts
Normal file
284
src/services/BackgroundSyncManager.ts
Normal 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();
|
||||
102
src/services/ForegroundServiceModule.ts
Normal file
102
src/services/ForegroundServiceModule.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
// 执行同步
|
||||
await syncMessages();
|
||||
|
||||
// 返回收到新数据
|
||||
return BackgroundFetch.BackgroundFetchResult.NewData;
|
||||
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;
|
||||
|
||||
196
src/services/errorHandler.ts
Normal file
196
src/services/errorHandler.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* 统一错误处理服务
|
||||
*
|
||||
* 提供标准化的错误处理流程:
|
||||
* 1. 错误分类(网络错误、认证错误、业务错误等)
|
||||
* 2. 日志记录
|
||||
* 3. 用户提示
|
||||
* 4. 错误上报
|
||||
*
|
||||
* 替代散落在代码中的 console.error + 手动 Alert 模式
|
||||
*/
|
||||
|
||||
import { showPrompt } from './promptService';
|
||||
|
||||
export enum AppErrorCode {
|
||||
NETWORK_ERROR = 'NETWORK_ERROR',
|
||||
AUTH_ERROR = 'AUTH_ERROR',
|
||||
FORBIDDEN = 'FORBIDDEN',
|
||||
NOT_FOUND = 'NOT_FOUND',
|
||||
VALIDATION_ERROR = 'VALIDATION_ERROR',
|
||||
SERVER_ERROR = 'SERVER_ERROR',
|
||||
TIMEOUT = 'TIMEOUT',
|
||||
UNKNOWN = 'UNKNOWN',
|
||||
}
|
||||
|
||||
export interface AppError {
|
||||
code: AppErrorCode;
|
||||
message: string;
|
||||
originalError?: unknown;
|
||||
context?: string;
|
||||
silent?: boolean;
|
||||
}
|
||||
|
||||
const ERROR_MESSAGES: Record<AppErrorCode, string> = {
|
||||
[AppErrorCode.NETWORK_ERROR]: '网络连接失败,请检查网络设置',
|
||||
[AppErrorCode.AUTH_ERROR]: '登录已过期,请重新登录',
|
||||
[AppErrorCode.FORBIDDEN]: '没有权限执行此操作',
|
||||
[AppErrorCode.NOT_FOUND]: '请求的资源不存在',
|
||||
[AppErrorCode.VALIDATION_ERROR]: '输入数据有误,请检查后重试',
|
||||
[AppErrorCode.SERVER_ERROR]: '服务器错误,请稍后重试',
|
||||
[AppErrorCode.TIMEOUT]: '请求超时,请稍后重试',
|
||||
[AppErrorCode.UNKNOWN]: '操作失败,请稍后重试',
|
||||
};
|
||||
|
||||
function classifyError(error: unknown): AppErrorCode {
|
||||
if (!error) return AppErrorCode.UNKNOWN;
|
||||
|
||||
if (error instanceof Error) {
|
||||
const message = error.message.toLowerCase();
|
||||
|
||||
if (message.includes('network') || message.includes('fetch') || message.includes('econnrefused')) {
|
||||
return AppErrorCode.NETWORK_ERROR;
|
||||
}
|
||||
if (message.includes('timeout') || message.includes('timed out')) {
|
||||
return AppErrorCode.TIMEOUT;
|
||||
}
|
||||
if (message.includes('unauthorized') || message.includes('401') || message.includes('token')) {
|
||||
return AppErrorCode.AUTH_ERROR;
|
||||
}
|
||||
if (message.includes('forbidden') || message.includes('403')) {
|
||||
return AppErrorCode.FORBIDDEN;
|
||||
}
|
||||
if (message.includes('not found') || message.includes('404')) {
|
||||
return AppErrorCode.NOT_FOUND;
|
||||
}
|
||||
if (message.includes('validation') || message.includes('400') || message.includes('422')) {
|
||||
return AppErrorCode.VALIDATION_ERROR;
|
||||
}
|
||||
if (message.includes('500') || message.includes('502') || message.includes('503')) {
|
||||
return AppErrorCode.SERVER_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof error === 'object' && error !== null) {
|
||||
const err = error as Record<string, unknown>;
|
||||
const status = err.status || err.statusCode || err.code;
|
||||
|
||||
if (status === 401) return AppErrorCode.AUTH_ERROR;
|
||||
if (status === 403) return AppErrorCode.FORBIDDEN;
|
||||
if (status === 404) return AppErrorCode.NOT_FOUND;
|
||||
if (status === 400 || status === 422) return AppErrorCode.VALIDATION_ERROR;
|
||||
if (typeof status === 'number' && status >= 500) return AppErrorCode.SERVER_ERROR;
|
||||
}
|
||||
|
||||
return AppErrorCode.UNKNOWN;
|
||||
}
|
||||
|
||||
export function createAppError(
|
||||
error: unknown,
|
||||
context?: string,
|
||||
options?: { silent?: boolean; userMessage?: string }
|
||||
): AppError {
|
||||
const code = classifyError(error);
|
||||
return {
|
||||
code,
|
||||
message: options?.userMessage || ERROR_MESSAGES[code],
|
||||
originalError: error,
|
||||
context,
|
||||
silent: options?.silent,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ErrorHandlerOptions {
|
||||
context?: string;
|
||||
userMessage?: string;
|
||||
silent?: boolean;
|
||||
showErrorPrompt?: boolean;
|
||||
logToConsole?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一错误处理函数
|
||||
*
|
||||
* 替代散落的 console.error + Alert 模式
|
||||
*
|
||||
* @example
|
||||
* // 之前
|
||||
* } catch (error) {
|
||||
* console.error('加载帖子详情失败:', error);
|
||||
* }
|
||||
*
|
||||
* // 之后
|
||||
* } catch (error) {
|
||||
* handleError(error, { context: '加载帖子详情' });
|
||||
* }
|
||||
*/
|
||||
export function handleError(error: unknown, options: ErrorHandlerOptions = {}): AppError {
|
||||
const {
|
||||
context,
|
||||
userMessage,
|
||||
silent = false,
|
||||
showErrorPrompt = false,
|
||||
logToConsole = true,
|
||||
} = options;
|
||||
|
||||
const appError = createAppError(error, context, { silent, userMessage });
|
||||
|
||||
if (logToConsole) {
|
||||
const prefix = context ? `[${context}]` : '';
|
||||
console.error(`${prefix} ${appError.message}`, error);
|
||||
}
|
||||
|
||||
if (showErrorPrompt && !silent) {
|
||||
showPrompt({
|
||||
type: 'error',
|
||||
title: context || '错误',
|
||||
message: appError.message,
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
|
||||
return appError;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带上下文的错误处理器
|
||||
*
|
||||
* @example
|
||||
* const postErrorHandler = createErrorHandler('帖子');
|
||||
*
|
||||
* } catch (error) {
|
||||
* postErrorHandler(error, { showErrorPrompt: true });
|
||||
* }
|
||||
*/
|
||||
export function createErrorHandler(defaultContext: string) {
|
||||
return (error: unknown, options: Omit<ErrorHandlerOptions, 'context'> = {}) => {
|
||||
return handleError(error, { ...options, context: defaultContext });
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全执行异步操作
|
||||
*
|
||||
* @example
|
||||
* const result = await safeAsync(
|
||||
* () => postService.getPost(id),
|
||||
* { context: '加载帖子' }
|
||||
* );
|
||||
* if (result.error) {
|
||||
* // 处理错误
|
||||
* } else {
|
||||
* // 使用 result.data
|
||||
* }
|
||||
*/
|
||||
export async function safeAsync<T>(
|
||||
fn: () => Promise<T>,
|
||||
options: ErrorHandlerOptions = {}
|
||||
): Promise<{ data: T | null; error: AppError | null }> {
|
||||
try {
|
||||
const data = await fn();
|
||||
return { data, error: null };
|
||||
} catch (error) {
|
||||
const appError = handleError(error, options);
|
||||
return { data: null, error: appError };
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,11 @@ export {
|
||||
export { showPrompt } from './promptService';
|
||||
export { showConfirm } from './dialogService';
|
||||
|
||||
// 统一错误处理服务
|
||||
export { handleError, createErrorHandler, safeAsync, createAppError } from './errorHandler';
|
||||
export type { AppError, ErrorHandlerOptions } from './errorHandler';
|
||||
export { AppErrorCode } from './errorHandler';
|
||||
|
||||
// APK 更新检查服务
|
||||
export { apkUpdateService, checkForAPKUpdate, manualCheckForUpdate } from './apkUpdateService';
|
||||
export type { APKVersionInfo, VersionCheckResult } from './apkUpdateService';
|
||||
|
||||
@@ -255,17 +255,12 @@ export const useUserStore = create<UserState>((set, get) => {
|
||||
const originalUsers = get().users;
|
||||
const originalUserCache = get().userCache;
|
||||
|
||||
// 乐观更新 users
|
||||
set(state => ({
|
||||
users: state.users.map(u =>
|
||||
u.id === userId
|
||||
? { ...u, isFollowing: true, followersCount: u.followers_count + 1 }
|
||||
: u
|
||||
),
|
||||
}));
|
||||
|
||||
// 乐观更新 userCache
|
||||
set(state => ({
|
||||
userCache: Object.fromEntries(
|
||||
Object.entries(state.userCache).map(([id, user]) => [
|
||||
id,
|
||||
@@ -280,30 +275,20 @@ export const useUserStore = create<UserState>((set, get) => {
|
||||
await authService.followUser(userId);
|
||||
} catch (error) {
|
||||
console.error('关注用户失败:', error);
|
||||
// 回滚
|
||||
set({
|
||||
users: originalUsers,
|
||||
userCache: originalUserCache
|
||||
});
|
||||
set({ users: originalUsers, userCache: originalUserCache });
|
||||
}
|
||||
},
|
||||
|
||||
// 取消关注 - authService 不管理状态,所以由 store 管理
|
||||
unfollowUser: async (userId: string) => {
|
||||
const originalUsers = get().users;
|
||||
const originalUserCache = get().userCache;
|
||||
|
||||
// 乐观更新 users
|
||||
set(state => ({
|
||||
users: state.users.map(u =>
|
||||
u.id === userId
|
||||
? { ...u, isFollowing: false, followersCount: Math.max(0, u.followers_count - 1) }
|
||||
: u
|
||||
),
|
||||
}));
|
||||
|
||||
// 乐观更新 userCache
|
||||
set(state => ({
|
||||
userCache: Object.fromEntries(
|
||||
Object.entries(state.userCache).map(([id, user]) => [
|
||||
id,
|
||||
@@ -318,11 +303,7 @@ export const useUserStore = create<UserState>((set, get) => {
|
||||
await authService.unfollowUser(userId);
|
||||
} catch (error) {
|
||||
console.error('取消关注用户失败:', error);
|
||||
// 回滚
|
||||
set({
|
||||
users: originalUsers,
|
||||
userCache: originalUserCache
|
||||
});
|
||||
set({ users: originalUsers, userCache: originalUserCache });
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -231,16 +231,18 @@ export type MessageSegment =
|
||||
|
||||
// 消息响应
|
||||
export interface MessageResponse {
|
||||
id: string; // 雪花算法ID (使用string避免JavaScript精度丢失)
|
||||
id: string;
|
||||
conversation_id: string;
|
||||
sender_id: string; // UUID字符串
|
||||
seq: number; // 消息序号
|
||||
segments: MessageSegment[]; // 消息链
|
||||
reply_to_id?: string; // 被回复消息的ID(用于关联查找)
|
||||
sender_id: string;
|
||||
seq: number;
|
||||
segments: MessageSegment[];
|
||||
reply_to_id?: string;
|
||||
status: MessageStatus;
|
||||
category?: string; // 消息类别:chat, notification, announcement
|
||||
category?: string;
|
||||
created_at: string;
|
||||
sender?: UserDTO;
|
||||
is_system_notice?: boolean;
|
||||
notice_content?: string;
|
||||
}
|
||||
|
||||
// 会话响应
|
||||
|
||||
Reference in New Issue
Block a user