Compare commits

...

2 Commits

Author SHA1 Message Date
lafay
4b5ce1ba21 refactor(platform): extract blurActiveElement to infrastructure module
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 4m47s
Frontend CI / ota-android (push) Successful in 10m48s
Frontend CI / build-android-apk (push) Successful in 1h36m37s
- Centralize duplicated Platform.OS === 'web' blur logic across 16 components
- Add blurActiveElement utility to src/infrastructure/platform module
- Optimize MessageBubble and ImageSegment with React.memo custom comparators
- Add useMemo for computed values in MessageSegmentsRenderer
- Update DTO types with is_system_notice and notice_content fields
- Fix keyboard dismissal order in useChatScreen handleDismiss
- Simplify userStore follow/unfollow state updates
- Remove unnecessary TypeScript type assertions throughout codebase
2026-04-11 22:35:11 +08:00
lafay
6f84e17772 feat(service): add foreground service for background message sync
Implement Android foreground service to keep app alive in background for real-time message sync:
- Add withForegroundService expo config plugin for notification channel setup
- Add BackgroundSyncManager with REALTIME, BATTERY_SAVER, and DISABLED modes
- Add ForegroundServiceModule for native Android foreground service binding
- Refactor backgroundService to integrate foreground service and expo-background-task

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

BREAKING CHANGE: Switched from expo-background-fetch to expo-background-task, minimum background sync interval changed from 900s to 15min in config
2026-04-11 04:27:22 +08:00
51 changed files with 1950 additions and 616 deletions

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import * as hrefs from '../../navigation/hrefs'; import * as hrefs from '../../navigation/hrefs';
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme'; import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
import { blurActiveElement } from '../../infrastructure/platform';
const { width, height } = Dimensions.get('window'); const { width, height } = Dimensions.get('window');
@@ -156,9 +157,8 @@ const QRCodeScannerWeb: React.FC<QRCodeScannerProps> = ({ visible, onClose }) =>
const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]); const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]);
useEffect(() => { useEffect(() => {
if (visible && Platform.OS === 'web') { if (visible) {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined; blurActiveElement();
active?.blur?.();
} }
}, [visible]); }, [visible]);

View File

@@ -21,6 +21,7 @@ import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useAppColors } from '../../../theme'; import { useAppColors } from '../../../theme';
import { spacing, borderRadius, fontSizes, shadows } from '../../../theme'; import { spacing, borderRadius, fontSizes, shadows } from '../../../theme';
import reportService, { ReportReason, ReportTargetType, REPORT_REASONS } from '../../../services/reportService'; import reportService, { ReportReason, ReportTargetType, REPORT_REASONS } from '../../../services/reportService';
import { blurActiveElement } from '../../../infrastructure/platform';
import Text from '../../common/Text'; import Text from '../../common/Text';
export interface ReportDialogProps { export interface ReportDialogProps {
@@ -63,9 +64,8 @@ const ReportDialog: React.FC<ReportDialogProps> = ({
const targetConfig = TARGET_CONFIG[targetType]; const targetConfig = TARGET_CONFIG[targetType];
useEffect(() => { useEffect(() => {
if (visible && Platform.OS === 'web') { if (visible) {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined; blurActiveElement();
active?.blur?.();
} }
}, [visible]); }, [visible]);

View File

@@ -125,12 +125,12 @@ function createSystemMessageStyles(colors: AppColors) {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'flex-start', alignItems: 'flex-start',
padding: 16, padding: 16,
backgroundColor: '#fff', backgroundColor: colors.background.paper,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: colors.divider || '#E5E5EA', borderBottomColor: colors.divider || '#E5E5EA',
}, },
unreadContainer: { unreadContainer: {
backgroundColor: '#FFF8F6', backgroundColor: colors.primary.main + '0A',
}, },
unreadIndicator: { unreadIndicator: {
position: 'absolute', position: 'absolute',
@@ -140,7 +140,7 @@ function createSystemMessageStyles(colors: AppColors) {
width: 8, width: 8,
height: 8, height: 8,
borderRadius: 4, borderRadius: 4,
backgroundColor: '#FF6B35', backgroundColor: colors.primary.main,
}, },
iconContainer: { iconContainer: {
marginRight: 14, marginRight: 14,
@@ -165,7 +165,7 @@ function createSystemMessageStyles(colors: AppColors) {
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
borderWidth: 2, borderWidth: 2,
borderColor: '#fff', borderColor: colors.background.paper,
}, },
content: { content: {
flex: 1, flex: 1,
@@ -220,7 +220,7 @@ function createSystemMessageStyles(colors: AppColors) {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
marginTop: 8, marginTop: 8,
backgroundColor: '#F5F5F7', backgroundColor: colors.background.default,
paddingHorizontal: 10, paddingHorizontal: 10,
paddingVertical: 6, paddingVertical: 6,
borderRadius: 8, 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> = ({ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
message, message,
onPress, onPress,

View File

@@ -13,6 +13,7 @@ import {
} from 'react-native'; } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { callStore } from '../../stores/callStore'; import { callStore } from '../../stores/callStore';
import { blurActiveElement } from '../../infrastructure/platform';
const { height: SCREEN_HEIGHT } = Dimensions.get('window'); const { height: SCREEN_HEIGHT } = Dimensions.get('window');
@@ -26,10 +27,7 @@ const IncomingCallModal: React.FC = () => {
useEffect(() => { useEffect(() => {
if (incomingCall) { if (incomingCall) {
if (Platform.OS === 'web') { blurActiveElement();
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
active?.blur?.();
}
const pulse = Animated.loop( const pulse = Animated.loop(
Animated.sequence([ Animated.sequence([
Animated.timing(pulseAnim, { Animated.timing(pulseAnim, {

View File

@@ -22,6 +22,7 @@ import {
useBreakpointGTE, useBreakpointGTE,
FineBreakpointKey, FineBreakpointKey,
} from '../../hooks/useResponsive'; } from '../../hooks/useResponsive';
import { blurActiveElement } from '../../infrastructure/platform';
export interface AdaptiveLayoutProps { export interface AdaptiveLayoutProps {
/** 主内容区 */ /** 主内容区 */
@@ -160,9 +161,8 @@ export function AdaptiveLayout({
// 抽屉动画 // 抽屉动画
useEffect(() => { useEffect(() => {
if (isDrawerOpen) { if (isDrawerOpen) {
if (Platform.OS === 'web') { if (isDrawerOpen) {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined; blurActiveElement();
active?.blur?.();
} }
Animated.parallel([ Animated.parallel([
Animated.timing(slideAnim, { Animated.timing(slideAnim, {

View File

@@ -6,6 +6,7 @@ import { LinearGradient } from 'expo-linear-gradient';
import { bindDialogListener, DialogPayload } from '../../services/dialogService'; import { bindDialogListener, DialogPayload } from '../../services/dialogService';
import { borderRadius, shadows, spacing, useAppColors, type AppColors } from '../../theme'; import { borderRadius, shadows, spacing, useAppColors, type AppColors } from '../../theme';
import { blurActiveElement } from '../../infrastructure/platform';
function createDialogHostStyles(colors: AppColors) { function createDialogHostStyles(colors: AppColors) {
return StyleSheet.create({ 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 AppDialogHost: React.FC = () => {
const [dialog, setDialog] = useState<DialogPayload | null>(null); const [dialog, setDialog] = useState<DialogPayload | null>(null);
const colors = useAppColors(); const colors = useAppColors();
@@ -113,7 +108,7 @@ const AppDialogHost: React.FC = () => {
useEffect(() => { useEffect(() => {
if (dialog) { if (dialog) {
blurActiveElementOnWeb(); blurActiveElement();
} }
}, [dialog]); }, [dialog]);

View File

@@ -33,6 +33,7 @@ import { MaterialCommunityIcons } from '@expo/vector-icons';
import * as MediaLibrary from 'expo-media-library'; import * as MediaLibrary from 'expo-media-library';
import { File, Paths } from 'expo-file-system'; import { File, Paths } from 'expo-file-system';
import { spacing, borderRadius, fontSizes } from '../../theme'; import { spacing, borderRadius, fontSizes } from '../../theme';
import { blurActiveElement } from '../../infrastructure/platform';
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window'); const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
@@ -127,10 +128,7 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
// 打开/关闭时重置状态 // 打开/关闭时重置状态
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
if (Platform.OS === 'web') { blurActiveElement();
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
active?.blur?.();
}
setCurrentIndex(initialIndex); setCurrentIndex(initialIndex);
setShowControls(true); setShowControls(true);
setError(false); setError(false);

View File

@@ -18,6 +18,7 @@ import { VideoView, useVideoPlayer } from 'expo-video';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { spacing, fontSizes, borderRadius } from '../../theme'; import { spacing, fontSizes, borderRadius } from '../../theme';
import { blurActiveElement } from '../../infrastructure/platform';
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window'); const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
@@ -81,9 +82,8 @@ export const VideoPlayerModal: React.FC<VideoPlayerModalProps> = ({
onClose, onClose,
}) => { }) => {
useEffect(() => { useEffect(() => {
if (visible && Platform.OS === 'web') { if (visible) {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined; blurActiveElement();
active?.blur?.();
} }
}, [visible]); }, [visible]);

View File

@@ -111,3 +111,16 @@ export type {
UseDifferentialPostsResult, UseDifferentialPostsResult,
DiffUpdatesInfo, DiffUpdatesInfo,
} from './useDifferentialPosts'; } from './useDifferentialPosts';
// ==================== 平台键盘 Hooks ====================
export {
usePlatformKeyboard,
useKeyboardAvoidingProps,
useKeyboardListener,
} from './usePlatformKeyboard';
export type {
KeyboardEventType,
PlatformKeyboardOptions,
PlatformKeyboardResult,
} from './usePlatformKeyboard';

View 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 };
}

View 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'
);
}

View File

@@ -0,0 +1,12 @@
/**
* Platform Infrastructure
*
* 平台相关的基础设施代码
*/
export {
blurActiveElement,
hasActiveElement,
getActiveElementType,
isInputFocused,
} from './domUtils';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -38,6 +38,7 @@ import { processPostUseCase } from '../../core/usecases/ProcessPostUseCase';
import { SearchScreen } from './SearchScreen'; import { SearchScreen } from './SearchScreen';
import { CreatePostScreen } from '../create/CreatePostScreen'; import { CreatePostScreen } from '../create/CreatePostScreen';
import * as hrefs from '../../navigation/hrefs'; import * as hrefs from '../../navigation/hrefs';
import { blurActiveElement } from '../../infrastructure/platform';
const TABS = ['关注', '最新', '热门']; const TABS = ['关注', '最新', '热门'];
const TAB_ICONS = ['account-heart-outline', 'clock-outline', 'fire']; const TAB_ICONS = ['account-heart-outline', 'clock-outline', 'fire'];
@@ -203,9 +204,8 @@ export const HomeScreen: React.FC = () => {
const [showCreatePost, setShowCreatePost] = useState(false); const [showCreatePost, setShowCreatePost] = useState(false);
useEffect(() => { useEffect(() => {
if (showCreatePost && Platform.OS === 'web') { if (showCreatePost) {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined; blurActiveElement();
active?.blur?.();
} }
}, [showCreatePost]); }, [showCreatePost]);

View File

@@ -42,8 +42,35 @@ import { useCursorPagination } from '../../hooks/useCursorPagination';
import { CommentItem, VoteCard, ReportDialog } from '../../components/business'; import { CommentItem, VoteCard, ReportDialog } from '../../components/business';
import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout, AppBackButton } from '../../components/common'; import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout, AppBackButton } from '../../components/common';
import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks/useResponsive'; import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks/useResponsive';
import { handleError } from '../../services/errorHandler';
import * as hrefs from '../../navigation/hrefs'; 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 = () => { export const PostDetailScreen: React.FC = () => {
const colors = useAppColors(); const colors = useAppColors();
const styles = useMemo(() => createPostDetailStyles(colors), [colors]); const styles = useMemo(() => createPostDetailStyles(colors), [colors]);
@@ -217,20 +244,17 @@ export const PostDetailScreen: React.FC = () => {
setPost(postData as unknown as Post); setPost(postData as unknown as Post);
// 初始化关注状态 // 初始化关注状态
if (postData.author) { if (postData.author) {
setIsFollowing((postData.author as any).is_following || false); setIsFollowing(postData.author.is_following || false);
setIsFollowingMe((postData.author as any).is_following_me || false); setIsFollowingMe(postData.author.is_following_me || false);
} }
// 只在首次加载时记录浏览量 if (!hasRecordedView.current) {
if (recordView && !hasRecordedView.current) {
hasRecordedView.current = true; hasRecordedView.current = true;
// 异步记录浏览量,不阻塞加载
postService.recordView(postId).catch(err => { postService.recordView(postId).catch(err => {
console.error('记录浏览量失败:', err); console.error('记录浏览量失败:', err);
}); });
} }
// 如果是投票帖子,立即加载投票数据 if (postData.is_vote) {
if ((postData as any).is_vote) {
setIsVoteLoading(true); setIsVoteLoading(true);
try { try {
const voteData = await voteService.getVoteResult(postId); const voteData = await voteService.getVoteResult(postId);
@@ -251,8 +275,8 @@ export const PostDetailScreen: React.FC = () => {
setPost(updatedPost); setPost(updatedPost);
// 初始化关注状态 // 初始化关注状态
if (updatedPost.author) { if (updatedPost.author) {
setIsFollowing((updatedPost.author as any).is_following || false); setIsFollowing(updatedPost.author.is_following || false);
setIsFollowingMe((updatedPost.author as any).is_following_me || false); setIsFollowingMe(updatedPost.author.is_following_me || false);
} }
} }
} }
@@ -260,7 +284,7 @@ export const PostDetailScreen: React.FC = () => {
// 加载评论(使用游标分页刷新) // 加载评论(使用游标分页刷新)
await refreshComments(); await refreshComments();
} catch (error) { } catch (error) {
console.error('加载帖子详情失败:', error); handleError(error, { context: '加载帖子详情' });
} finally { } finally {
setIsPostInitialLoading(false); setIsPostInitialLoading(false);
} }
@@ -458,65 +482,36 @@ export const PostDetailScreen: React.FC = () => {
const handleLike = useCallback(async () => { const handleLike = useCallback(async () => {
if (!post) return; if (!post) return;
// 先保存旧状态用于回滚 const originalPost = post;
const oldIsLiked = post.is_liked; setPost(prev => prev ? togglePostField(prev, 'is_liked', 'likes_count') : null);
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);
try { try {
if (oldIsLiked) { if (originalPost.is_liked) {
await processPostUseCase.unlikePost(post.id); await processPostUseCase.unlikePost(post.id);
} else { } else {
await processPostUseCase.likePost(post.id); await processPostUseCase.likePost(post.id);
} }
// UseCase 会更新 store本地状态已经是乐观更新的无需再次更新
} catch (error) { } catch (error) {
console.error('点赞操作失败:', error); handleError(error, { context: '点赞' });
// 失败时回滚状态 setPost(originalPost);
setPost(prev => prev ? {
...prev,
is_liked: oldIsLiked,
likes_count: oldLikesCount
} : null);
} }
}, [post]); }, [post]);
// 收藏帖子
const handleFavorite = useCallback(async () => { const handleFavorite = useCallback(async () => {
if (!post) return; if (!post) return;
// 先保存旧状态用于回滚 const originalPost = post;
const oldIsFavorited = post.is_favorited; setPost(prev => prev ? togglePostField(prev, 'is_favorited', 'favorites_count') : null);
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);
try { try {
if (oldIsFavorited) { if (originalPost.is_favorited) {
await processPostUseCase.unfavoritePost(post.id); await processPostUseCase.unfavoritePost(post.id);
} else { } else {
await processPostUseCase.favoritePost(post.id); await processPostUseCase.favoritePost(post.id);
} }
// UseCase 会更新 store本地状态已经是乐观更新的无需再次更新
} catch (error) { } catch (error) {
console.error('收藏操作失败:', error); handleError(error, { context: '收藏' });
// 失败时回滚状态 setPost(originalPost);
setPost(prev => prev ? {
...prev,
is_favorited: oldIsFavorited,
favorites_count: oldFavoritesCount
} : null);
} }
}, [post]); }, [post]);
@@ -573,24 +568,19 @@ export const PostDetailScreen: React.FC = () => {
setVoteResult(oldVoteResult); setVoteResult(oldVoteResult);
} }
} catch (error) { } catch (error) {
console.error('投票失败:', error); handleError(error, { context: '投票' });
// 失败时回滚
setVoteResult(oldVoteResult); setVoteResult(oldVoteResult);
} finally { } finally {
setIsVoteLoading(false); setIsVoteLoading(false);
} }
}, [post, voteResult, isVoteLoading]); }, [post, voteResult, isVoteLoading]);
// 取消投票处理函数
const handleUnvote = useCallback(async () => { const handleUnvote = useCallback(async () => {
if (!post || !voteResult || isVoteLoading || !voteResult.voted_option_id) return; if (!post || !voteResult || isVoteLoading || !voteResult.voted_option_id) return;
const votedOptionId = voteResult.voted_option_id; const votedOptionId = voteResult.voted_option_id;
// 保存旧状态用于回滚
const oldVoteResult = { ...voteResult }; const oldVoteResult = { ...voteResult };
// 乐观更新
setVoteResult((prev: VoteResultDTO | null) => { setVoteResult((prev: VoteResultDTO | null) => {
if (!prev) return null; if (!prev) return null;
return { return {
@@ -611,12 +601,10 @@ export const PostDetailScreen: React.FC = () => {
try { try {
const success = await voteService.unvote(post.id); const success = await voteService.unvote(post.id);
if (!success) { if (!success) {
// 失败时回滚
setVoteResult(oldVoteResult); setVoteResult(oldVoteResult);
} }
} catch (error) { } catch (error) {
console.error('取消投票失败:', error); handleError(error, { context: '取消投票' });
// 失败时回滚
setVoteResult(oldVoteResult); setVoteResult(oldVoteResult);
} finally { } finally {
setIsVoteLoading(false); setIsVoteLoading(false);
@@ -899,26 +887,15 @@ export const PostDetailScreen: React.FC = () => {
} }
}; };
// 点赞评论(包括回复)- 优化版
const handleLikeComment = async (comment: Comment) => { const handleLikeComment = async (comment: Comment) => {
const { id, is_liked, likes_count } = comment; const { id, is_liked, likes_count } = comment;
// 乐观更新:切换点赞状态 const oldIsLiked = is_liked ?? false;
const newIsLiked = !is_liked; const newIsLiked = !oldIsLiked;
const newLikesCount = newIsLiked ? likes_count + 1 : Math.max(0, likes_count - 1); const newLikesCount = newIsLiked ? likes_count + 1 : Math.max(0, likes_count - 1);
const originalComments = comments;
// 递归更新评论树中的点赞状态 setComments(prev => toggleCommentLikeInTree(prev, id, newIsLiked, newLikesCount));
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)));
try { try {
const success = newIsLiked const success = newIsLiked
@@ -929,18 +906,8 @@ export const PostDetailScreen: React.FC = () => {
throw new Error('点赞操作失败'); throw new Error('点赞操作失败');
} }
} catch (error) { } catch (error) {
console.error('评论点赞操作失败:', error); handleError(error, { context: '评论点赞' });
// 回滚状态 setComments(toggleCommentLikeInTree(originalComments, id, oldIsLiked, likes_count));
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)));
} }
}; };

View File

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

View File

@@ -19,6 +19,7 @@ import {
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { blurActiveElement } from '../../infrastructure/platform';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import { spacing, fontSizes, borderRadius, shadows, useAppColors, type AppColors } from '../../theme'; import { spacing, fontSizes, borderRadius, shadows, useAppColors, type AppColors } from '../../theme';
import { groupService } from '../../services/groupService'; import { groupService } from '../../services/groupService';
@@ -47,9 +48,8 @@ const CreateGroupScreen: React.FC = () => {
const [inviteModalVisible, setInviteModalVisible] = useState(false); const [inviteModalVisible, setInviteModalVisible] = useState(false);
useEffect(() => { useEffect(() => {
if (inviteModalVisible && Platform.OS === 'web') { if (inviteModalVisible) {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined; blurActiveElement();
active?.blur?.();
} }
}, [inviteModalVisible]); }, [inviteModalVisible]);

View File

@@ -46,6 +46,7 @@ import {
JoinType, JoinType,
} from '../../types/dto'; } from '../../types/dto';
import { User } from '../../types'; import { User } from '../../types';
import { blurActiveElement } from '../../infrastructure/platform';
import { firstRouteParam } from '../../navigation/paramUtils'; import { firstRouteParam } from '../../navigation/paramUtils';
import * as hrefs from '../../navigation/hrefs'; import * as hrefs from '../../navigation/hrefs';
import { messageManager } from '../../stores/messageManager'; import { messageManager } from '../../stores/messageManager';
@@ -119,9 +120,8 @@ const GroupInfoScreen: React.FC = () => {
const [inviting, setInviting] = useState(false); const [inviting, setInviting] = useState(false);
useEffect(() => { useEffect(() => {
if ((editModalVisible || announcementModalVisible || transferModalVisible || joinTypeModalVisible || inviteModalVisible) && Platform.OS === 'web') { if (editModalVisible || announcementModalVisible || transferModalVisible || joinTypeModalVisible || inviteModalVisible) {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined; blurActiveElement();
active?.blur?.();
} }
}, [editModalVisible, announcementModalVisible, transferModalVisible, joinTypeModalVisible, inviteModalVisible]); }, [editModalVisible, announcementModalVisible, transferModalVisible, joinTypeModalVisible, inviteModalVisible]);

View File

@@ -22,6 +22,7 @@ import {
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useLocalSearchParams, useRouter } from 'expo-router'; import { useLocalSearchParams, useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { blurActiveElement } from '../../infrastructure/platform';
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme'; import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
import { useAuthStore } from '../../stores'; import { useAuthStore } from '../../stores';
import { groupService } from '../../services/groupService'; import { groupService } from '../../services/groupService';
@@ -133,9 +134,8 @@ const GroupMembersScreen: React.FC = () => {
const [newNickname, setNewNickname] = useState(''); const [newNickname, setNewNickname] = useState('');
useEffect(() => { useEffect(() => {
if ((actionModalVisible || nicknameModalVisible) && Platform.OS === 'web') { if (actionModalVisible || nicknameModalVisible) {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined; blurActiveElement();
active?.blur?.();
} }
}, [actionModalVisible, nicknameModalVisible]); }, [actionModalVisible, nicknameModalVisible]);

View File

@@ -40,6 +40,7 @@ import {
} from '../../theme'; } from '../../theme';
import { ConversationResponse, UserDTO, MessageResponse, extractTextFromSegments } from '../../types/dto'; import { ConversationResponse, UserDTO, MessageResponse, extractTextFromSegments } from '../../types/dto';
import { authService } from '../../services'; import { authService } from '../../services';
import { blurActiveElement } from '../../infrastructure/platform';
import { useUserStore, useAuthStore } from '../../stores'; import { useUserStore, useAuthStore } from '../../stores';
// 【新架构】使用MessageManager hooks会话列表数据源自 MessageManager 游标同步) // 【新架构】使用MessageManager hooks会话列表数据源自 MessageManager 游标同步)
import { import {
@@ -138,9 +139,8 @@ export const MessageListScreen: React.FC = () => {
const [actionMenuVisible, setActionMenuVisible] = useState(false); const [actionMenuVisible, setActionMenuVisible] = useState(false);
useEffect(() => { useEffect(() => {
if (actionMenuVisible && Platform.OS === 'web') { if (actionMenuVisible) {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined; blurActiveElement();
active?.blur?.();
} }
}, [actionMenuVisible]); }, [actionMenuVisible]);
const [scannerVisible, setScannerVisible] = useState(false); const [scannerVisible, setScannerVisible] = useState(false);

View File

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

View File

@@ -15,15 +15,10 @@ import { useResponsive, useBreakpointGTE } from '../../../../hooks/useResponsive
import { EMOJIS } from './constants'; import { EMOJIS } from './constants';
import { CustomSticker, getCustomStickers, batchDeleteStickers, addStickerFromUrl } from '../../../../services/stickerService'; import { CustomSticker, getCustomStickers, batchDeleteStickers, addStickerFromUrl } from '../../../../services/stickerService';
import { useAppColors, spacing } from '../../../../theme'; import { useAppColors, spacing } from '../../../../theme';
import { blurActiveElement } from '../../../../infrastructure/platform';
const { height: SCREEN_HEIGHT } = Dimensions.get('window'); 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 布局 // 表情尺寸配置 - 固定宽度 40px使用 flex 布局
const EMOJI_SIZES = { const EMOJI_SIZES = {
mobile: { size: 24, itemWidth: 40, itemHeight: 40 }, mobile: { size: 24, itemWidth: 40, itemHeight: 40 },
@@ -182,7 +177,7 @@ export const EmojiPanel: React.FC<EmojiPanelProps> = ({
// 打开管理界面 // 打开管理界面
const handleOpenManage = () => { const handleOpenManage = () => {
blurActiveElementOnWeb(); blurActiveElement();
setShowManageModal(true); setShowManageModal(true);
setManageMode(false); setManageMode(false);
setSelectedStickers(new Set()); setSelectedStickers(new Set());

View File

@@ -20,6 +20,7 @@ import { LongPressMenuProps } from './types';
import { RECALL_TIME_LIMIT } from './constants'; import { RECALL_TIME_LIMIT } from './constants';
import { extractTextFromSegments, ImageSegmentData } from '../../../../types/dto'; import { extractTextFromSegments, ImageSegmentData } from '../../../../types/dto';
import { isStickerExists, addStickerFromUrl } from '../../../../services/stickerService'; import { isStickerExists, addStickerFromUrl } from '../../../../services/stickerService';
import { blurActiveElement } from '../../../../infrastructure/platform';
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window'); const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
@@ -37,16 +38,11 @@ export const LongPressMenu: React.FC<LongPressMenuProps> = ({
const styles = useChatScreenStyles(); const styles = useChatScreenStyles();
const scaleAnimation = useRef(new Animated.Value(0)).current; const scaleAnimation = useRef(new Animated.Value(0)).current;
const opacityAnimation = 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(() => { useEffect(() => {
if (visible) { if (visible) {
blurActiveElementOnWeb(); blurActiveElement();
Animated.parallel([ Animated.parallel([
Animated.spring(scaleAnimation, { Animated.spring(scaleAnimation, {
toValue: 1, toValue: 1,
@@ -61,7 +57,7 @@ export const LongPressMenu: React.FC<LongPressMenuProps> = ({
}), }),
]).start(); ]).start();
} else { } else {
blurActiveElementOnWeb(); blurActiveElement();
Animated.parallel([ Animated.parallel([
Animated.timing(scaleAnimation, { Animated.timing(scaleAnimation, {
toValue: 0, toValue: 0,
@@ -251,12 +247,12 @@ export const LongPressMenu: React.FC<LongPressMenuProps> = ({
visible={visible} visible={visible}
transparent transparent
animationType="none" animationType="none"
onShow={blurActiveElementOnWeb} onShow={blurActiveElement}
onRequestClose={onClose} onRequestClose={onClose}
> >
<Pressable <Pressable
onPress={() => { onPress={() => {
blurActiveElementOnWeb(); blurActiveElement();
onClose(); onClose();
}} }}
style={styles.qqMenuOverlay} style={styles.qqMenuOverlay}

View File

@@ -31,7 +31,7 @@ const MAX_WIDTH_RATIO = {
desktop: 0.55, desktop: 0.55,
}; };
export const MessageBubble: React.FC<MessageBubbleProps> = ({ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
message, message,
index, index,
currentUserId, currentUserId,
@@ -481,11 +481,37 @@ const segmentStyles = StyleSheet.create({
minWidth: 120, minWidth: 120,
}, },
pureImageBubble: { pureImageBubble: {
// 纯图片消息:去除内边距,让图片紧贴气泡边缘
paddingHorizontal: 0, paddingHorizontal: 0,
paddingVertical: 0, paddingVertical: 0,
overflow: 'hidden', 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; export default MessageBubble;

View File

@@ -165,7 +165,7 @@ const renderTextSegment = (data: TextSegmentData, isMe: boolean): React.ReactNod
* 渲染图片 Segment * 渲染图片 Segment
*/ */
// 图片Segment组件 - 使用Hook获取实际尺寸 // 图片Segment组件 - 使用Hook获取实际尺寸
const ImageSegment: React.FC<{ const ImageSegmentInner: React.FC<{
data: ImageSegmentData; data: ImageSegmentData;
isMe: boolean; isMe: boolean;
onImagePress?: (url: string) => void; onImagePress?: (url: string) => void;
@@ -284,7 +284,6 @@ const ImageSegment: React.FC<{
style={{ style={{
width: IMAGE_FALLBACK_SIZE.width, width: IMAGE_FALLBACK_SIZE.width,
height: IMAGE_FALLBACK_SIZE.height, height: IMAGE_FALLBACK_SIZE.height,
borderRadius: 8,
backgroundColor: 'rgba(0,0,0,0.1)', backgroundColor: 'rgba(0,0,0,0.1)',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
@@ -312,7 +311,6 @@ const ImageSegment: React.FC<{
style={{ style={{
width: IMAGE_FALLBACK_SIZE.width, width: IMAGE_FALLBACK_SIZE.width,
height: IMAGE_FALLBACK_SIZE.height, height: IMAGE_FALLBACK_SIZE.height,
borderRadius: 8,
backgroundColor: 'rgba(0,0,0,0.1)', backgroundColor: 'rgba(0,0,0,0.1)',
}} }}
/> />
@@ -334,7 +332,6 @@ const ImageSegment: React.FC<{
style={{ style={{
width: dimensions.width, width: dimensions.width,
height: dimensions.height, height: dimensions.height,
borderRadius: 8,
}} }}
recyclingKey={stableImageKey} recyclingKey={stableImageKey}
contentFit="cover" contentFit="cover"
@@ -345,7 +342,6 @@ const ImageSegment: React.FC<{
setLoadError(false); setLoadError(false);
}} }}
onError={() => { onError={() => {
// 缩略图失败时自动回退原图,降低跳转定位后的白块/丢图概率
if (data.thumbnail_url && data.url && imageUrl === data.thumbnail_url) { if (data.thumbnail_url && data.url && imageUrl === data.thumbnail_url) {
setCurrentImageUri(data.url); setCurrentImageUri(data.url);
return; 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 = ( const renderImageSegment = (
data: ImageSegmentData, data: ImageSegmentData,
props: Omit<SegmentRendererProps, 'segment'> props: Omit<SegmentRendererProps, 'segment'>
@@ -705,7 +712,7 @@ export const ReplyPreviewSegment: React.FC<ReplyPreviewSegmentProps> = ({
/** /**
* 渲染完整的消息链(多个 Segment 组合) * 渲染完整的消息链(多个 Segment 组合)
*/ */
export const MessageSegmentsRenderer: React.FC<{ const MessageSegmentsRendererInner: React.FC<{
segments: MessageSegment[]; segments: MessageSegment[];
isMe: boolean; isMe: boolean;
currentUserId?: string; currentUserId?: string;
@@ -734,11 +741,11 @@ export const MessageSegmentsRenderer: React.FC<{
const fontSize = useChatSettingsStore((s) => s.fontSize); const fontSize = useChatSettingsStore((s) => s.fontSize);
const segStyles = useMemo(() => createSegmentStyles(themeColors, fontSize), [themeColors, fontSize]); const segStyles = useMemo(() => createSegmentStyles(themeColors, fontSize), [themeColors, fontSize]);
const replySegment = segments.find(s => s.type === 'reply'); const replySegment = useMemo(() => segments.find(s => s.type === 'reply'), [segments]);
const otherSegments = segments.filter(s => s.type !== 'reply'); const otherSegments = useMemo(() => segments.filter(s => s.type !== 'reply'), [segments]);
const chunks = partitionMessageSegments(otherSegments); const chunks = useMemo(() => partitionMessageSegments(otherSegments), [otherSegments]);
const renderProps = { const renderProps = useMemo(() => ({
isMe, isMe,
currentUserId, currentUserId,
memberMap, memberMap,
@@ -747,7 +754,7 @@ export const MessageSegmentsRenderer: React.FC<{
onImagePress, onImagePress,
onImageLongPress, onImageLongPress,
onLinkPress, onLinkPress,
}; }), [isMe, currentUserId, memberMap, onAtPress, onReplyPress, onImagePress, onImageLongPress, onLinkPress]);
return ( return (
<SegmentStylesContext.Provider value={segStyles}> <SegmentStylesContext.Provider value={segStyles}>
@@ -837,13 +844,16 @@ function createSegmentStyles(colors: AppColors, fontSize: number = 16) {
}, },
imagesChunk: { imagesChunk: {
width: '100%', width: '100%',
overflow: 'hidden',
}, },
imageStackItem: { imageStackItem: {
width: '100%', width: '100%',
marginBottom: 6, marginBottom: 4,
overflow: 'hidden',
}, },
imageStackItemLast: { imageStackItemLast: {
width: '100%', width: '100%',
overflow: 'hidden',
}, },
blockChunk: { blockChunk: {
width: '100%', width: '100%',
@@ -864,14 +874,13 @@ function createSegmentStyles(colors: AppColors, fontSize: number = 16) {
color: colors.chat.textPrimary, color: colors.chat.textPrimary,
}, },
// @提及 - 微信/QQ 风格:更精致的高亮,支持动态字号 // @提及 - 与普通文本字号一致,仅颜色区分
atText: { atText: {
fontSize, fontSize,
lineHeight: Math.round(fontSize * 1.4), lineHeight: Math.round(fontSize * 1.43),
fontWeight: '500',
}, },
atTextMe: { atTextMe: {
color: '#1A5F9E', // 深蓝色,在绿色背景上更清晰 color: '#1A5F9E',
}, },
atTextOther: { atTextOther: {
color: colors.chat.link, color: colors.chat.link,
@@ -879,25 +888,15 @@ function createSegmentStyles(colors: AppColors, fontSize: number = 16) {
atHighlight: { atHighlight: {
backgroundColor: 'rgba(74, 136, 199, 0.15)', backgroundColor: 'rgba(74, 136, 199, 0.15)',
borderRadius: 4, borderRadius: 4,
paddingHorizontal: 3,
}, },
// 图片 - QQ风格保持原始宽高比 // 图片 - QQ风格保持原始宽高比
imageContainer: { imageContainer: {
borderRadius: 12,
overflow: 'hidden', overflow: 'hidden',
marginTop: 4,
shadowColor: colors.chat.shadow,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.15,
shadowRadius: 4,
elevation: 4,
}, },
imageMe: { imageMe: {
borderBottomRightRadius: 4,
}, },
imageOther: { imageOther: {
borderBottomLeftRadius: 4,
}, },
// 移除固定的 image 样式,改为在组件中动态计算 // 移除固定的 image 样式,改为在组件中动态计算
@@ -1143,4 +1142,19 @@ function useSegmentStyles(): SegmentStyles {
return ctx; 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; export default MessageSegmentsRenderer;

View File

@@ -174,9 +174,9 @@ export const useChatScreen = () => {
status: (m.status || 'normal') as MessageStatus, status: (m.status || 'normal') as MessageStatus,
category: m.category, category: m.category,
created_at: m.created_at, created_at: m.created_at,
sender: (m as any).sender, sender: m.sender,
is_system_notice: (m as any).is_system_notice, is_system_notice: m.is_system_notice,
notice_content: (m as any).notice_content, notice_content: m.notice_content,
})); }));
}, [messageManagerMessages]); }, [messageManagerMessages]);
@@ -258,7 +258,7 @@ export const useChatScreen = () => {
setOtherUser(prev => ({ ...(prev || {}), ...other })); setOtherUser(prev => ({ ...(prev || {}), ...other }));
} }
// 获取对方最后阅读位置 // 获取对方最后阅读位置
setOtherUserLastReadSeq((conversation as any).other_last_read_seq || 0); setOtherUserLastReadSeq(conversation.other_last_read_seq || 0);
} }
}, [conversation, isGroupChat, currentUserId]); }, [conversation, isGroupChat, currentUserId]);
@@ -273,8 +273,8 @@ export const useChatScreen = () => {
const detail = await userManager.getUserById(String(otherUserId), true); const detail = await userManager.getUserById(String(otherUserId), true);
if (!detail || cancelled) return; if (!detail || cancelled) return;
setOtherUser(prev => ({ ...(prev || {}), ...detail })); setOtherUser(prev => ({ ...(prev || {}), ...detail }));
if (typeof (detail as any).is_following_me === 'boolean') { if (typeof detail.is_following_me === 'boolean') {
setIsFollowedByOther((detail as any).is_following_me); setIsFollowedByOther(detail.is_following_me);
} else { } else {
setIsFollowedByOther(null); setIsFollowedByOther(null);
} }
@@ -360,7 +360,7 @@ export const useChatScreen = () => {
}, [loading, loadingMore, messages.length, scrollToLatest]); }, [loading, loadingMore, messages.length, scrollToLatest]);
// 新消息跟随策略Telegram/QQ 风格): // 新消息跟随策略Telegram/QQ 风格):
// 仅当最新端消息 seq 增长且用户在底部附近时才跟随; // 仅当"最新端消息 seq 增长"且用户在底部附近时才跟随;
// 历史加载只会增加旧消息,不会提升 latest seq因此不会触发回底。 // 历史加载只会增加旧消息,不会提升 latest seq因此不会触发回底。
useEffect(() => { useEffect(() => {
const currentCount = messages.length; const currentCount = messages.length;
@@ -371,14 +371,14 @@ export const useChatScreen = () => {
if (loading || loadingMore) return; if (loading || loadingMore) return;
if (latestSeq <= prevLatestSeq) return; if (latestSeq <= prevLatestSeq) return;
if (suppressAutoFollowRef.current) return; if (suppressAutoFollowRef.current || isBrowsingHistoryRef.current || isHistoryLoadingLocked()) return;
if (!isNearBottom()) return; if (!isNearBottom()) return;
const timer = setTimeout(() => { const timer = setTimeout(() => {
scrollToLatest(false, false, 'new-message-follow'); scrollToLatest(false, false, 'new-message-follow');
}, 0); }, 0);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [messages, loading, loadingMore, isNearBottom, scrollToLatest]); }, [messages, loading, loadingMore, isNearBottom, scrollToLatest, isHistoryLoadingLocked]);
// 获取当前用户信息 // 获取当前用户信息
useEffect(() => { useEffect(() => {
@@ -559,10 +559,10 @@ export const useChatScreen = () => {
if (!conversationId) return; if (!conversationId) return;
if (!conversation) return; if (!conversation) return;
const unreadCount = Number((conversation as any).unread_count || 0); const unreadCount = Number(conversation.unread_count || 0);
if (unreadCount <= 0) return; 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 latestFromMessages = messages.length > 0 ? Math.max(...messages.map(m => m.seq || 0)) : 0;
const targetSeq = Math.max(latestFromConversation, latestFromMessages); const targetSeq = Math.max(latestFromConversation, latestFromMessages);
if (targetSeq <= 0) return; if (targetSeq <= 0) return;
@@ -1305,8 +1305,8 @@ export const useChatScreen = () => {
// 关闭所有面板 // 关闭所有面板
const handleDismiss = useCallback(() => { const handleDismiss = useCallback(() => {
if (activePanel !== 'none') {
Keyboard.dismiss(); Keyboard.dismiss();
if (activePanel !== 'none') {
setActivePanel('none'); setActivePanel('none');
} }
}, [activePanel]); }, [activePanel]);

View File

@@ -16,6 +16,7 @@ import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '
import { authService } from '../../../services/authService'; import { authService } from '../../../services/authService';
import { useAuthStore } from '../../../stores'; import { useAuthStore } from '../../../stores';
import { Avatar, EmptyState, Loading, Text } from '../../../components/common'; import { Avatar, EmptyState, Loading, Text } from '../../../components/common';
import { blurActiveElement } from '../../../infrastructure/platform';
import { User } from '../../../types'; import { User } from '../../../types';
type MutualFollowSelectorModalProps = { type MutualFollowSelectorModalProps = {
@@ -57,10 +58,7 @@ const MutualFollowSelectorModal: React.FC<MutualFollowSelectorModalProps> = ({
useEffect(() => { useEffect(() => {
if (!visible) return; if (!visible) return;
if (Platform.OS === 'web') { blurActiveElement();
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
active?.blur?.();
}
if (!currentUserId) return; if (!currentUserId) return;
const nextSelectedIds = new Set(stableInitialSelectedIds); const nextSelectedIds = new Set(stableInitialSelectedIds);

View File

@@ -482,7 +482,6 @@ export const AboutScreen: React.FC = () => {
const appInfoItems = [ const appInfoItems = [
{ key: 'name', icon: 'information-outline', title: '应用名称', value: APP_NAME }, { key: 'name', icon: 'information-outline', title: '应用名称', value: APP_NAME },
{ key: 'version', icon: 'tag-outline', title: '版本号', value: `v${APP_VERSION} (${BUILD_NUMBER})` }, { 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) => ( const renderSettingItem = (item: { key: string; icon: string; title: string; value?: string; action?: () => void }, index: number, total: number, showArrow: boolean = false) => (

View File

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

View File

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

View File

@@ -10,6 +10,8 @@ import {
StyleSheet, StyleSheet,
Switch, Switch,
ScrollView, ScrollView,
Alert,
TouchableOpacity,
} from 'react-native'; } from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
@@ -22,6 +24,7 @@ import {
setVibrationPreference, setVibrationPreference,
} from '../../services/notificationPreferences'; } from '../../services/notificationPreferences';
import { useResponsive, useResponsiveSpacing } from '../../hooks'; import { useResponsive, useResponsiveSpacing } from '../../hooks';
import { backgroundSyncManager, BackgroundSyncMode } from '../../services/BackgroundSyncManager';
// 内容最大宽度 // 内容最大宽度
const CONTENT_MAX_WIDTH = 720; const CONTENT_MAX_WIDTH = 720;
@@ -41,6 +44,7 @@ export const NotificationSettingsScreen: React.FC = () => {
const [vibrationEnabled, setVibrationEnabledState] = useState(true); const [vibrationEnabled, setVibrationEnabledState] = useState(true);
const [pushEnabled, setPushEnabled] = useState(true); const [pushEnabled, setPushEnabled] = useState(true);
const [soundEnabled, setSoundEnabled] = useState(true); const [soundEnabled, setSoundEnabled] = useState(true);
const [syncMode, setSyncMode] = useState<BackgroundSyncMode>(BackgroundSyncMode.BATTERY_SAVER);
const { isWideScreen, isMobile } = useResponsive(); const { isWideScreen, isMobile } = useResponsive();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@@ -55,6 +59,7 @@ export const NotificationSettingsScreen: React.FC = () => {
setVibrationEnabledState(prefs.vibrationEnabled); setVibrationEnabledState(prefs.vibrationEnabled);
setPushEnabled(prefs.pushEnabled); setPushEnabled(prefs.pushEnabled);
setSoundEnabled(prefs.soundEnabled); setSoundEnabled(prefs.soundEnabled);
setSyncMode(backgroundSyncManager.getMode());
} catch (error) { } catch (error) {
console.error('加载通知设置失败:', 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[] = [ const settings: NotificationSettingItem[] = [
{ {
key: 'push', key: 'push',
@@ -164,6 +214,62 @@ export const NotificationSettingsScreen: React.FC = () => {
</Text> </Text>
</View> </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, borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.divider, borderBottomColor: colors.divider,
}, },
settingItemActive: {
backgroundColor: colors.primary.main + '0D',
},
iconContainer: { iconContainer: {
width: 24, width: 24,
height: 24, height: 24,

View File

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

View File

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

View File

@@ -22,6 +22,7 @@ import { PanGestureHandler, State } from 'react-native-gesture-handler';
import type { PanGestureHandlerStateChangeEvent } from 'react-native-gesture-handler'; import type { PanGestureHandlerStateChangeEvent } from 'react-native-gesture-handler';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { blurActiveElement } from '../../infrastructure/platform';
import { useFocusEffect } from '@react-navigation/native'; import { useFocusEffect } from '@react-navigation/native';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { import {
@@ -214,9 +215,8 @@ export const ScheduleScreen: React.FC = () => {
const [isSyncing, setIsSyncing] = useState(false); const [isSyncing, setIsSyncing] = useState(false);
useEffect(() => { useEffect(() => {
if ((isAddModalVisible || isSettingsModalVisible || isSyncModalVisible) && Platform.OS === 'web') { if (isAddModalVisible || isSettingsModalVisible || isSyncModalVisible) {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined; blurActiveElement();
active?.blur?.();
} }
}, [isAddModalVisible, isSettingsModalVisible, isSyncModalVisible]); }, [isAddModalVisible, isSettingsModalVisible, isSyncModalVisible]);

View File

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

View File

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

View File

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

View 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 };
}
}

View File

@@ -111,6 +111,11 @@ export {
export { showPrompt } from './promptService'; export { showPrompt } from './promptService';
export { showConfirm } from './dialogService'; export { showConfirm } from './dialogService';
// 统一错误处理服务
export { handleError, createErrorHandler, safeAsync, createAppError } from './errorHandler';
export type { AppError, ErrorHandlerOptions } from './errorHandler';
export { AppErrorCode } from './errorHandler';
// APK 更新检查服务 // APK 更新检查服务
export { apkUpdateService, checkForAPKUpdate, manualCheckForUpdate } from './apkUpdateService'; export { apkUpdateService, checkForAPKUpdate, manualCheckForUpdate } from './apkUpdateService';
export type { APKVersionInfo, VersionCheckResult } from './apkUpdateService'; export type { APKVersionInfo, VersionCheckResult } from './apkUpdateService';

View File

@@ -255,17 +255,12 @@ export const useUserStore = create<UserState>((set, get) => {
const originalUsers = get().users; const originalUsers = get().users;
const originalUserCache = get().userCache; const originalUserCache = get().userCache;
// 乐观更新 users
set(state => ({ set(state => ({
users: state.users.map(u => users: state.users.map(u =>
u.id === userId u.id === userId
? { ...u, isFollowing: true, followersCount: u.followers_count + 1 } ? { ...u, isFollowing: true, followersCount: u.followers_count + 1 }
: u : u
), ),
}));
// 乐观更新 userCache
set(state => ({
userCache: Object.fromEntries( userCache: Object.fromEntries(
Object.entries(state.userCache).map(([id, user]) => [ Object.entries(state.userCache).map(([id, user]) => [
id, id,
@@ -280,30 +275,20 @@ export const useUserStore = create<UserState>((set, get) => {
await authService.followUser(userId); await authService.followUser(userId);
} catch (error) { } catch (error) {
console.error('关注用户失败:', error); console.error('关注用户失败:', error);
// 回滚 set({ users: originalUsers, userCache: originalUserCache });
set({
users: originalUsers,
userCache: originalUserCache
});
} }
}, },
// 取消关注 - authService 不管理状态,所以由 store 管理
unfollowUser: async (userId: string) => { unfollowUser: async (userId: string) => {
const originalUsers = get().users; const originalUsers = get().users;
const originalUserCache = get().userCache; const originalUserCache = get().userCache;
// 乐观更新 users
set(state => ({ set(state => ({
users: state.users.map(u => users: state.users.map(u =>
u.id === userId u.id === userId
? { ...u, isFollowing: false, followersCount: Math.max(0, u.followers_count - 1) } ? { ...u, isFollowing: false, followersCount: Math.max(0, u.followers_count - 1) }
: u : u
), ),
}));
// 乐观更新 userCache
set(state => ({
userCache: Object.fromEntries( userCache: Object.fromEntries(
Object.entries(state.userCache).map(([id, user]) => [ Object.entries(state.userCache).map(([id, user]) => [
id, id,
@@ -318,11 +303,7 @@ export const useUserStore = create<UserState>((set, get) => {
await authService.unfollowUser(userId); await authService.unfollowUser(userId);
} catch (error) { } catch (error) {
console.error('取消关注用户失败:', error); console.error('取消关注用户失败:', error);
// 回滚 set({ users: originalUsers, userCache: originalUserCache });
set({
users: originalUsers,
userCache: originalUserCache
});
} }
}, },

View File

@@ -231,16 +231,18 @@ export type MessageSegment =
// 消息响应 // 消息响应
export interface MessageResponse { export interface MessageResponse {
id: string; // 雪花算法ID (使用string避免JavaScript精度丢失) id: string;
conversation_id: string; conversation_id: string;
sender_id: string; // UUID字符串 sender_id: string;
seq: number; // 消息序号 seq: number;
segments: MessageSegment[]; // 消息链 segments: MessageSegment[];
reply_to_id?: string; // 被回复消息的ID用于关联查找 reply_to_id?: string;
status: MessageStatus; status: MessageStatus;
category?: string; // 消息类别chat, notification, announcement category?: string;
created_at: string; created_at: string;
sender?: UserDTO; sender?: UserDTO;
is_system_notice?: boolean;
notice_content?: string;
} }
// 会话响应 // 会话响应