Compare commits
2 Commits
9bbed8cf5e
...
4b5ce1ba21
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b5ce1ba21 | ||
|
|
6f84e17772 |
18
app.json
18
app.json
@@ -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
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
467
plugins/withForegroundService.js
Normal file
467
plugins/withForegroundService.js
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
const { withAndroidManifest, withDangerousMod } = require('@expo/config-plugins');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const FOREGROUND_SERVICE_PERMISSION = 'android.permission.FOREGROUND_SERVICE';
|
||||||
|
const FOREGROUND_SERVICE_DATA_SYNC = 'android.permission.FOREGROUND_SERVICE_DATA_SYNC';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expo Config Plugin:为 Android 添加前台服务保活
|
||||||
|
*
|
||||||
|
* 功能:
|
||||||
|
* 1. 添加 FOREGROUND_SERVICE 相关权限
|
||||||
|
* 2. 在 AndroidManifest 中声明 Service
|
||||||
|
* 3. 生成 Kotlin 原生服务代码
|
||||||
|
* 4. 生成 React Native Bridge 模块
|
||||||
|
*
|
||||||
|
* 参考:Element Android 的 GuardAndroidService
|
||||||
|
*/
|
||||||
|
const withForegroundService = (config, options = {}) => {
|
||||||
|
const {
|
||||||
|
notificationTitle = '萝卜社区',
|
||||||
|
notificationBody = '正在后台同步消息',
|
||||||
|
notificationIcon = 'ic_notification',
|
||||||
|
channelId = 'carrot_sync_foreground',
|
||||||
|
channelName = '消息同步',
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// 1. 添加权限和 Service 声明到 AndroidManifest
|
||||||
|
config = withAndroidManifest(config, (config) => {
|
||||||
|
const manifest = config.modResults;
|
||||||
|
|
||||||
|
// 添加权限声明
|
||||||
|
const permissions = [
|
||||||
|
FOREGROUND_SERVICE_PERMISSION,
|
||||||
|
FOREGROUND_SERVICE_DATA_SYNC,
|
||||||
|
];
|
||||||
|
|
||||||
|
permissions.forEach((permission) => {
|
||||||
|
const existingPermission = manifest.manifest['uses-permission']?.find(
|
||||||
|
(p) => p.$['android:name'] === permission
|
||||||
|
);
|
||||||
|
if (!existingPermission) {
|
||||||
|
if (!manifest.manifest['uses-permission']) {
|
||||||
|
manifest.manifest['uses-permission'] = [];
|
||||||
|
}
|
||||||
|
manifest.manifest['uses-permission'].push({
|
||||||
|
$: { 'android:name': permission },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加 Service 声明
|
||||||
|
if (!manifest.manifest.application[0].service) {
|
||||||
|
manifest.manifest.application[0].service = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingService = manifest.manifest.application[0].service.find(
|
||||||
|
(s) => s.$['android:name'] === '.SyncForegroundService'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existingService) {
|
||||||
|
manifest.manifest.application[0].service.push({
|
||||||
|
$: {
|
||||||
|
'android:name': '.SyncForegroundService',
|
||||||
|
'android:enabled': 'true',
|
||||||
|
'android:exported': 'false',
|
||||||
|
'android:foregroundServiceType': 'dataSync',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 生成 Kotlin 原生代码
|
||||||
|
config = withDangerousMod(config, [
|
||||||
|
'android',
|
||||||
|
async (config) => {
|
||||||
|
const projectRoot = config.modRequest.projectRoot;
|
||||||
|
const packageName = config.android.package;
|
||||||
|
const kotlinPath = path.join(
|
||||||
|
projectRoot,
|
||||||
|
'android/app/src/main/java',
|
||||||
|
packageName.replace(/\./g, '/')
|
||||||
|
);
|
||||||
|
|
||||||
|
// 确保目录存在
|
||||||
|
if (!fs.existsSync(kotlinPath)) {
|
||||||
|
fs.mkdirSync(kotlinPath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成 SyncForegroundService.kt
|
||||||
|
const serviceCode = generateServiceCode(packageName, {
|
||||||
|
notificationTitle,
|
||||||
|
notificationBody,
|
||||||
|
notificationIcon,
|
||||||
|
channelId,
|
||||||
|
channelName,
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(kotlinPath, 'SyncForegroundService.kt'),
|
||||||
|
serviceCode
|
||||||
|
);
|
||||||
|
|
||||||
|
// 生成 ForegroundServiceModule.kt (React Native Bridge)
|
||||||
|
const moduleCode = generateModuleCode(packageName);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(kotlinPath, 'ForegroundServiceModule.kt'),
|
||||||
|
moduleCode
|
||||||
|
);
|
||||||
|
|
||||||
|
// 生成 ForegroundServicePackage.kt
|
||||||
|
const packageCode = generatePackageCode(packageName);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(kotlinPath, 'ForegroundServicePackage.kt'),
|
||||||
|
packageCode
|
||||||
|
);
|
||||||
|
|
||||||
|
// 修改 MainApplication.kt 注册模块
|
||||||
|
const mainAppPath = path.join(kotlinPath, 'MainApplication.kt');
|
||||||
|
if (fs.existsSync(mainAppPath)) {
|
||||||
|
let content = fs.readFileSync(mainAppPath, 'utf-8');
|
||||||
|
|
||||||
|
// 添加 import
|
||||||
|
if (!content.includes('ForegroundServicePackage')) {
|
||||||
|
content = content.replace(
|
||||||
|
/package .*\n/,
|
||||||
|
`$&\nimport ${packageName}.ForegroundServicePackage\n`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在 packages 列表中添加
|
||||||
|
if (!content.includes('ForegroundServicePackage()')) {
|
||||||
|
// 查找 PackageList(this).packages.apply 块
|
||||||
|
content = content.replace(
|
||||||
|
/(PackageList\(this\)\.packages\.apply\s*\{)/,
|
||||||
|
`$1\n // 前台服务模块\n add(ForegroundServicePackage())`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(mainAppPath, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建通知图标目录和文件
|
||||||
|
const drawablePath = path.join(projectRoot, 'android/app/src/main/res/drawable');
|
||||||
|
if (!fs.existsSync(drawablePath)) {
|
||||||
|
fs.mkdirSync(drawablePath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconPath = path.join(drawablePath, 'ic_notification.xml');
|
||||||
|
if (!fs.existsSync(iconPath)) {
|
||||||
|
fs.writeFileSync(iconPath, generateNotificationIcon());
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
return config;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成前台服务 Kotlin 代码
|
||||||
|
*/
|
||||||
|
function generateServiceCode(packageName, options) {
|
||||||
|
const { notificationTitle, notificationBody, notificationIcon, channelId, channelName } = options;
|
||||||
|
|
||||||
|
return `package ${packageName}
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 前台同步服务
|
||||||
|
*
|
||||||
|
* 在通知栏显示常驻通知,防止应用进程被系统杀死
|
||||||
|
* 类似 Element Android 的 GuardAndroidService 和 V2Ray 的保活机制
|
||||||
|
*/
|
||||||
|
class SyncForegroundService : Service() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val NOTIFICATION_ID = 1001
|
||||||
|
const val CHANNEL_ID = "${channelId}"
|
||||||
|
const val CHANNEL_NAME = "${channelName}"
|
||||||
|
|
||||||
|
const val ACTION_START = "${packageName}.foreground.START"
|
||||||
|
const val ACTION_STOP = "${packageName}.foreground.STOP"
|
||||||
|
const val ACTION_UPDATE = "${packageName}.foreground.UPDATE"
|
||||||
|
|
||||||
|
const val EXTRA_TITLE = "title"
|
||||||
|
const val EXTRA_BODY = "body"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动前台服务
|
||||||
|
*/
|
||||||
|
fun start(context: Context, title: String = "${notificationTitle}", body: String = "${notificationBody}") {
|
||||||
|
val intent = Intent(context, SyncForegroundService::class.java).apply {
|
||||||
|
action = ACTION_START
|
||||||
|
putExtra(EXTRA_TITLE, title)
|
||||||
|
putExtra(EXTRA_BODY, body)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ContextCompat.startForegroundService(context, intent)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("SyncForegroundService", "启动前台服务失败", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止前台服务
|
||||||
|
*/
|
||||||
|
fun stop(context: Context) {
|
||||||
|
val intent = Intent(context, SyncForegroundService::class.java).apply {
|
||||||
|
action = ACTION_STOP
|
||||||
|
}
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新通知内容
|
||||||
|
*/
|
||||||
|
fun update(context: Context, title: String, body: String) {
|
||||||
|
val intent = Intent(context, SyncForegroundService::class.java).apply {
|
||||||
|
action = ACTION_UPDATE
|
||||||
|
putExtra(EXTRA_TITLE, title)
|
||||||
|
putExtra(EXTRA_BODY, body)
|
||||||
|
}
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var notificationTitle: String = "${notificationTitle}"
|
||||||
|
private var notificationBody: String = "${notificationBody}"
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
createNotificationChannel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
when (intent?.action) {
|
||||||
|
ACTION_START -> {
|
||||||
|
notificationTitle = intent.getStringExtra(EXTRA_TITLE) ?: "${notificationTitle}"
|
||||||
|
notificationBody = intent.getStringExtra(EXTRA_BODY) ?: "${notificationBody}"
|
||||||
|
startForegroundCompat()
|
||||||
|
}
|
||||||
|
ACTION_STOP -> {
|
||||||
|
stopForegroundCompat()
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
ACTION_UPDATE -> {
|
||||||
|
notificationTitle = intent.getStringExtra(EXTRA_TITLE) ?: notificationTitle
|
||||||
|
notificationBody = intent.getStringExtra(EXTRA_BODY) ?: notificationBody
|
||||||
|
updateNotification()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// START_STICKY: 服务被杀后自动重启
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动前台服务(兼容 Android Q+)
|
||||||
|
*/
|
||||||
|
private fun startForegroundCompat() {
|
||||||
|
val notification = buildNotification()
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
startForeground(
|
||||||
|
NOTIFICATION_ID,
|
||||||
|
notification,
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止前台服务(兼容 Android N+)
|
||||||
|
*/
|
||||||
|
private fun stopForegroundCompat() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
stopForeground(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建通知渠道
|
||||||
|
*/
|
||||||
|
private fun createNotificationChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
CHANNEL_NAME,
|
||||||
|
NotificationManager.IMPORTANCE_MIN // 最低重要性,不打扰用户
|
||||||
|
).apply {
|
||||||
|
description = "后台消息同步服务"
|
||||||
|
setSound(null, null)
|
||||||
|
setShowBadge(false)
|
||||||
|
enableLights(false)
|
||||||
|
enableVibration(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
|
manager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建通知
|
||||||
|
*/
|
||||||
|
private fun buildNotification(): Notification {
|
||||||
|
// 点击通知打开应用
|
||||||
|
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
launchIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setContentTitle(notificationTitle)
|
||||||
|
.setContentText(notificationBody)
|
||||||
|
.setSmallIcon(R.drawable.${notificationIcon})
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||||
|
.setOngoing(true) // 不可滑动清除
|
||||||
|
.setShowWhen(false)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新通知内容
|
||||||
|
*/
|
||||||
|
private fun updateNotification() {
|
||||||
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
|
manager.notify(NOTIFICATION_ID, buildNotification())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 React Native Bridge 模块代码
|
||||||
|
*/
|
||||||
|
function generateModuleCode(packageName) {
|
||||||
|
return `package ${packageName}
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.facebook.react.bridge.ReactApplicationContext
|
||||||
|
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||||
|
import com.facebook.react.bridge.ReactMethod
|
||||||
|
import com.facebook.react.bridge.Promise
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React Native Bridge: 前台服务控制模块
|
||||||
|
*/
|
||||||
|
class ForegroundServiceModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
||||||
|
|
||||||
|
override fun getName(): String = "ForegroundService"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动前台服务
|
||||||
|
*/
|
||||||
|
@ReactMethod
|
||||||
|
fun start(title: String, body: String, promise: Promise) {
|
||||||
|
try {
|
||||||
|
SyncForegroundService.start(reactApplicationContext, title, body)
|
||||||
|
promise.resolve(true)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
promise.reject("FOREGROUND_SERVICE_ERROR", e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止前台服务
|
||||||
|
*/
|
||||||
|
@ReactMethod
|
||||||
|
fun stop(promise: Promise) {
|
||||||
|
try {
|
||||||
|
SyncForegroundService.stop(reactApplicationContext)
|
||||||
|
promise.resolve(true)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
promise.reject("FOREGROUND_SERVICE_ERROR", e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新通知内容
|
||||||
|
*/
|
||||||
|
@ReactMethod
|
||||||
|
fun update(title: String, body: String, promise: Promise) {
|
||||||
|
try {
|
||||||
|
SyncForegroundService.update(reactApplicationContext, title, body)
|
||||||
|
promise.resolve(true)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
promise.reject("FOREGROUND_SERVICE_ERROR", e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 React Native Package 代码
|
||||||
|
*/
|
||||||
|
function generatePackageCode(packageName) {
|
||||||
|
return `package ${packageName}
|
||||||
|
|
||||||
|
import com.facebook.react.ReactPackage
|
||||||
|
import com.facebook.react.bridge.NativeModule
|
||||||
|
import com.facebook.react.bridge.ReactApplicationContext
|
||||||
|
import com.facebook.react.uimanager.ViewManager
|
||||||
|
|
||||||
|
class ForegroundServicePackage : ReactPackage {
|
||||||
|
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
||||||
|
return listOf(ForegroundServiceModule(reactContext))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成通知图标 XML
|
||||||
|
*/
|
||||||
|
function generateNotificationIcon() {
|
||||||
|
return `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<!-- 背景圆 -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#4CAF50"
|
||||||
|
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2z"/>
|
||||||
|
<!-- 对勾符号 -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M10,17l-5,-5 1.41,-1.41L10,14.17l7.59,-7.59L19,8l-9,9z"/>
|
||||||
|
</vector>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = withForegroundService;
|
||||||
@@ -13,6 +13,7 @@ import { useRouter } from 'expo-router';
|
|||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { 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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
179
src/hooks/usePlatformKeyboard.ts
Normal file
179
src/hooks/usePlatformKeyboard.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* usePlatformKeyboard - 统一的跨平台键盘行为 Hook
|
||||||
|
*
|
||||||
|
* 消除散落在多个文件中的 Platform.OS 键盘事件判断
|
||||||
|
*
|
||||||
|
* 使用前(重复代码):
|
||||||
|
* ```tsx
|
||||||
|
* const showSub = Keyboard.addListener(
|
||||||
|
* Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow',
|
||||||
|
* handler
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* 使用后:
|
||||||
|
* ```tsx
|
||||||
|
* const { addListener } = usePlatformKeyboard();
|
||||||
|
* const showSub = addListener('show', handler);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { Keyboard, Platform, KeyboardEvent, EmitterSubscription } from 'react-native';
|
||||||
|
|
||||||
|
export type KeyboardEventType = 'show' | 'hide' | 'change';
|
||||||
|
|
||||||
|
export interface PlatformKeyboardOptions {
|
||||||
|
onShow?: (event: KeyboardEvent) => void;
|
||||||
|
onHide?: (event: KeyboardEvent) => void;
|
||||||
|
onChange?: (height: number) => void;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlatformKeyboardResult {
|
||||||
|
/** 键盘高度 */
|
||||||
|
keyboardHeight: number;
|
||||||
|
/** 键盘是否可见 */
|
||||||
|
isKeyboardVisible: boolean;
|
||||||
|
/** 平台最佳的 KeyboardAvoidingView behavior */
|
||||||
|
keyboardBehavior: 'padding' | 'height' | undefined;
|
||||||
|
/** 平台最佳的 keyboardVerticalOffset */
|
||||||
|
keyboardVerticalOffset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取平台特定的键盘事件名称
|
||||||
|
*/
|
||||||
|
function getKeyboardEventName(type: KeyboardEventType): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'show':
|
||||||
|
return Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
||||||
|
case 'hide':
|
||||||
|
return Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
||||||
|
case 'change':
|
||||||
|
return Platform.OS === 'ios' ? 'keyboardWillChangeFrame' : 'keyboardDidShow';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 平台键盘 Hook
|
||||||
|
*
|
||||||
|
* 自动处理跨平台键盘事件差异
|
||||||
|
*/
|
||||||
|
export function usePlatformKeyboard(
|
||||||
|
options: PlatformKeyboardOptions = {}
|
||||||
|
): PlatformKeyboardResult {
|
||||||
|
const { onShow, onHide, onChange, enabled = true } = options;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
const subscriptions: EmitterSubscription[] = [];
|
||||||
|
|
||||||
|
if (onShow) {
|
||||||
|
subscriptions.push(
|
||||||
|
Keyboard.addListener(
|
||||||
|
getKeyboardEventName('show') as any,
|
||||||
|
onShow
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onHide) {
|
||||||
|
subscriptions.push(
|
||||||
|
Keyboard.addListener(
|
||||||
|
getKeyboardEventName('hide') as any,
|
||||||
|
onHide
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onChange) {
|
||||||
|
const handleChange = (e: KeyboardEvent) => {
|
||||||
|
onChange(e.endCoordinates.height);
|
||||||
|
};
|
||||||
|
subscriptions.push(
|
||||||
|
Keyboard.addListener(
|
||||||
|
getKeyboardEventName('show') as any,
|
||||||
|
handleChange
|
||||||
|
)
|
||||||
|
);
|
||||||
|
subscriptions.push(
|
||||||
|
Keyboard.addListener(
|
||||||
|
getKeyboardEventName('hide') as any,
|
||||||
|
() => onChange(0)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscriptions.forEach(sub => sub.remove());
|
||||||
|
};
|
||||||
|
}, [enabled, onShow, onHide, onChange]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
keyboardHeight: 0,
|
||||||
|
isKeyboardVisible: false,
|
||||||
|
keyboardBehavior: Platform.OS === 'ios' ? 'padding' : undefined,
|
||||||
|
keyboardVerticalOffset: Platform.OS === 'ios' ? 0 : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取平台最佳的 KeyboardAvoidingView props
|
||||||
|
*
|
||||||
|
* 用于替代重复的 Platform.OS 三元判断:
|
||||||
|
* ```tsx
|
||||||
|
* // 之前
|
||||||
|
* behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
*
|
||||||
|
* // 之后
|
||||||
|
* const { keyboardProps } = useKeyboardAvoidingProps();
|
||||||
|
* <KeyboardAvoidingView {...keyboardProps}>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useKeyboardAvoidingProps(
|
||||||
|
options: { offset?: number; defaultBehavior?: 'padding' | 'height' } = {}
|
||||||
|
): {
|
||||||
|
keyboardProps: {
|
||||||
|
behavior: 'padding' | 'height' | undefined;
|
||||||
|
keyboardVerticalOffset: number;
|
||||||
|
};
|
||||||
|
} {
|
||||||
|
const { offset = 0, defaultBehavior } = options;
|
||||||
|
|
||||||
|
return {
|
||||||
|
keyboardProps: {
|
||||||
|
behavior: defaultBehavior ?? (Platform.OS === 'ios' ? 'padding' : undefined),
|
||||||
|
keyboardVerticalOffset: Platform.OS === 'ios' ? offset : 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 键盘事件监听 Hook(简化版)
|
||||||
|
*
|
||||||
|
* 返回一个平台无关的 addListener 函数
|
||||||
|
*/
|
||||||
|
export function useKeyboardListener() {
|
||||||
|
const subscriptionsRef = useRef<EmitterSubscription[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
subscriptionsRef.current.forEach(sub => sub.remove());
|
||||||
|
subscriptionsRef.current = [];
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addListener = useCallback(
|
||||||
|
(type: KeyboardEventType, handler: (event: KeyboardEvent) => void) => {
|
||||||
|
const eventName = getKeyboardEventName(type);
|
||||||
|
const sub = Keyboard.addListener(eventName as any, handler);
|
||||||
|
subscriptionsRef.current.push(sub);
|
||||||
|
return sub;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { addListener };
|
||||||
|
}
|
||||||
83
src/infrastructure/platform/domUtils.ts
Normal file
83
src/infrastructure/platform/domUtils.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Web Platform DOM Utilities
|
||||||
|
*
|
||||||
|
* 提供Web平台特定的DOM操作工具函数
|
||||||
|
* 解决跨平台代码中需要访问Web DOM的补丁问题
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Platform, Keyboard } from 'react-native';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blur当前激活的DOM元素(Web)并收起键盘(移动端)
|
||||||
|
*
|
||||||
|
* - Web: blur DOM active element
|
||||||
|
* - iOS/Android: Keyboard.dismiss()
|
||||||
|
*
|
||||||
|
* 替代散落在多个文件中的重复代码
|
||||||
|
*/
|
||||||
|
export function blurActiveElement(): void {
|
||||||
|
if (Platform.OS === 'web') {
|
||||||
|
try {
|
||||||
|
const activeElement = (globalThis as any)?.document?.activeElement as
|
||||||
|
{ blur?: () => void } | undefined;
|
||||||
|
|
||||||
|
if (activeElement?.blur) {
|
||||||
|
activeElement.blur();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 安全忽略:某些环境下document可能不可访问
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Keyboard.dismiss();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查当前是否有激活的DOM元素(仅Web平台)
|
||||||
|
*/
|
||||||
|
export function hasActiveElement(): boolean {
|
||||||
|
if (Platform.OS !== 'web') return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const doc = (globalThis as any)?.document;
|
||||||
|
return !!doc?.activeElement && doc.activeElement !== doc.body;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前激活元素的类型(仅Web平台)
|
||||||
|
* 用于判断是否需要特殊处理(如输入框)
|
||||||
|
*/
|
||||||
|
export function getActiveElementType(): string | null {
|
||||||
|
if (Platform.OS !== 'web') return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const activeElement = (globalThis as any)?.document?.activeElement as
|
||||||
|
{ tagName?: string; type?: string } | undefined;
|
||||||
|
|
||||||
|
if (!activeElement) return null;
|
||||||
|
|
||||||
|
const tagName = activeElement.tagName?.toLowerCase();
|
||||||
|
const type = activeElement.type?.toLowerCase();
|
||||||
|
|
||||||
|
return tagName === 'input' ? `input:${type || 'text'}` : tagName || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断当前激活元素是否为输入类型
|
||||||
|
*/
|
||||||
|
export function isInputFocused(): boolean {
|
||||||
|
const elementType = getActiveElementType();
|
||||||
|
if (!elementType) return false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
elementType.startsWith('input:') ||
|
||||||
|
elementType === 'textarea' ||
|
||||||
|
elementType === 'select'
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/infrastructure/platform/index.ts
Normal file
12
src/infrastructure/platform/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Platform Infrastructure
|
||||||
|
*
|
||||||
|
* 平台相关的基础设施代码
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
blurActiveElement,
|
||||||
|
hasActiveElement,
|
||||||
|
getActiveElementType,
|
||||||
|
isInputFocused,
|
||||||
|
} from './domUtils';
|
||||||
@@ -26,19 +26,13 @@ import {
|
|||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { 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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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)));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
284
src/services/BackgroundSyncManager.ts
Normal file
284
src/services/BackgroundSyncManager.ts
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
/**
|
||||||
|
* 后台同步管理器
|
||||||
|
*
|
||||||
|
* 整合前台服务、后台同步、消息状态管理
|
||||||
|
* 参考 Element Android 的 BackgroundSyncStarter 设计
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { AppState, AppStateStatus, Platform } from 'react-native';
|
||||||
|
import { ForegroundServiceModule } from './ForegroundServiceModule';
|
||||||
|
import { wsService } from './wsService';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台同步模式
|
||||||
|
*/
|
||||||
|
export enum BackgroundSyncMode {
|
||||||
|
/** 省电模式:使用 expo-background-task(最小15分钟间隔) */
|
||||||
|
BATTERY_SAVER = 'battery_saver',
|
||||||
|
|
||||||
|
/** 实时模式:前台服务保活 + 持续同步 */
|
||||||
|
REALTIME = 'realtime',
|
||||||
|
|
||||||
|
/** 禁用后台同步 */
|
||||||
|
DISABLED = 'disabled',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'background_sync_mode';
|
||||||
|
const SYNC_INTERVAL_MS = 30 * 1000; // 30 秒同步间隔
|
||||||
|
|
||||||
|
class BackgroundSyncManager {
|
||||||
|
private mode: BackgroundSyncMode = BackgroundSyncMode.BATTERY_SAVER;
|
||||||
|
private appStateSubscription: ReturnType<typeof AppState.addEventListener> | null = null;
|
||||||
|
private syncInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private isInitialized: boolean = false;
|
||||||
|
private lastSyncAt: number = 0;
|
||||||
|
private syncMessagesCallback: (() => Promise<void>) | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置消息同步回调函数
|
||||||
|
* 由外部注入,避免循环依赖
|
||||||
|
*/
|
||||||
|
setSyncMessagesCallback(callback: () => Promise<void>): void {
|
||||||
|
this.syncMessagesCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化后台同步
|
||||||
|
*/
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
if (this.isInitialized) return;
|
||||||
|
|
||||||
|
// 加载保存的模式
|
||||||
|
const savedMode = await this.loadMode();
|
||||||
|
this.mode = savedMode;
|
||||||
|
|
||||||
|
// 监听 AppState 变化
|
||||||
|
this.appStateSubscription = AppState.addEventListener(
|
||||||
|
'change',
|
||||||
|
this.handleAppStateChange
|
||||||
|
);
|
||||||
|
|
||||||
|
// 监听 WebSocket 断开
|
||||||
|
this.setupWSDisconnectListener();
|
||||||
|
|
||||||
|
this.isInitialized = true;
|
||||||
|
console.log('[BackgroundSyncManager] 初始化完成, 模式:', this.mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换后台同步模式
|
||||||
|
*/
|
||||||
|
async setMode(mode: BackgroundSyncMode): Promise<void> {
|
||||||
|
// 如果当前在后台,先停止旧模式的保活
|
||||||
|
if (AppState.currentState !== 'active') {
|
||||||
|
this.stopPeriodicSync();
|
||||||
|
if (this.mode === BackgroundSyncMode.REALTIME) {
|
||||||
|
await ForegroundServiceModule.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新模式
|
||||||
|
this.mode = mode;
|
||||||
|
await this.saveMode(mode);
|
||||||
|
|
||||||
|
// 根据新模式立即执行相应操作
|
||||||
|
if (mode === BackgroundSyncMode.REALTIME) {
|
||||||
|
// 切换到实时模式:立即启动前台服务(无论前台还是后台)
|
||||||
|
const started = await ForegroundServiceModule.start({
|
||||||
|
title: '萝卜社区',
|
||||||
|
body: '正在后台同步消息',
|
||||||
|
});
|
||||||
|
if (started) {
|
||||||
|
this.startPeriodicSync();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 切换到其他模式:立即停止前台服务和定时同步
|
||||||
|
this.stopPeriodicSync();
|
||||||
|
await ForegroundServiceModule.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[BackgroundSyncManager] 模式已切换为:', mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前模式
|
||||||
|
*/
|
||||||
|
getMode(): BackgroundSyncMode {
|
||||||
|
return this.mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AppState 变化处理
|
||||||
|
*/
|
||||||
|
private handleAppStateChange = async (nextState: AppStateStatus) => {
|
||||||
|
console.log('[BackgroundSyncManager] AppState 变化:', nextState);
|
||||||
|
|
||||||
|
if (nextState === 'background' || nextState === 'inactive') {
|
||||||
|
// 进入后台
|
||||||
|
await this.startForBackground();
|
||||||
|
} else if (nextState === 'active') {
|
||||||
|
// 回到前台
|
||||||
|
await this.stopForForeground();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 进入后台时的处理
|
||||||
|
*/
|
||||||
|
private async startForBackground(): Promise<void> {
|
||||||
|
if (this.mode === BackgroundSyncMode.DISABLED) return;
|
||||||
|
|
||||||
|
console.log('[BackgroundSyncManager] 进入后台');
|
||||||
|
|
||||||
|
if (this.mode === BackgroundSyncMode.REALTIME) {
|
||||||
|
// 确保前台服务正在运行
|
||||||
|
await ForegroundServiceModule.start({
|
||||||
|
title: '萝卜社区',
|
||||||
|
body: '正在后台同步消息',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 启动定时同步
|
||||||
|
this.startPeriodicSync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回到前台时的处理
|
||||||
|
*/
|
||||||
|
private async stopForForeground(): Promise<void> {
|
||||||
|
console.log('[BackgroundSyncManager] 回到前台');
|
||||||
|
|
||||||
|
// 停止定时同步(前台通过 WebSocket 实时同步,不需要轮询)
|
||||||
|
this.stopPeriodicSync();
|
||||||
|
|
||||||
|
// 实时模式下:前台服务保持运行,只更新通知文字
|
||||||
|
if (this.mode === BackgroundSyncMode.REALTIME) {
|
||||||
|
await ForegroundServiceModule.update('萝卜社区', '正在后台同步消息');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 立即同步一次(获取离线消息)
|
||||||
|
await this.syncMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动定时同步
|
||||||
|
*/
|
||||||
|
private startPeriodicSync(): void {
|
||||||
|
this.stopPeriodicSync();
|
||||||
|
|
||||||
|
// 立即同步一次
|
||||||
|
this.syncMessages();
|
||||||
|
|
||||||
|
// 启动定时器
|
||||||
|
this.syncInterval = setInterval(() => {
|
||||||
|
this.syncMessages();
|
||||||
|
}, SYNC_INTERVAL_MS);
|
||||||
|
|
||||||
|
console.log('[BackgroundSyncManager] 定时同步已启动');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止定时同步
|
||||||
|
*/
|
||||||
|
private stopPeriodicSync(): void {
|
||||||
|
if (this.syncInterval) {
|
||||||
|
clearInterval(this.syncInterval);
|
||||||
|
this.syncInterval = null;
|
||||||
|
console.log('[BackgroundSyncManager] 定时同步已停止');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置 WebSocket 断开监听
|
||||||
|
*/
|
||||||
|
private setupWSDisconnectListener(): void {
|
||||||
|
// 监听 WebSocket 断开事件
|
||||||
|
wsService.onDisconnect(() => {
|
||||||
|
if (AppState.currentState !== 'active' && this.mode === BackgroundSyncMode.REALTIME) {
|
||||||
|
console.log('[BackgroundSyncManager] WebSocket 断开,尝试重连');
|
||||||
|
wsService.connect().catch((e) => {
|
||||||
|
console.error('[BackgroundSyncManager] WebSocket 重连失败:', e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行消息同步
|
||||||
|
*/
|
||||||
|
async syncMessages(): Promise<void> {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// 防止短时间内重复同步(最小 10 秒间隔)
|
||||||
|
if (now - this.lastSyncAt < 10000) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastSyncAt = now;
|
||||||
|
|
||||||
|
// 调用外部注入的同步回调
|
||||||
|
if (this.syncMessagesCallback) {
|
||||||
|
try {
|
||||||
|
await this.syncMessagesCallback();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[BackgroundSyncManager] 同步失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新前台服务通知内容
|
||||||
|
*/
|
||||||
|
async updateNotification(totalUnread: number): Promise<void> {
|
||||||
|
if (this.mode !== BackgroundSyncMode.REALTIME) return;
|
||||||
|
|
||||||
|
if (totalUnread > 0) {
|
||||||
|
await ForegroundServiceModule.update(
|
||||||
|
'萝卜社区',
|
||||||
|
`您有 ${totalUnread} 条未读消息`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await ForegroundServiceModule.update(
|
||||||
|
'萝卜社区',
|
||||||
|
'正在后台同步消息'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存模式到存储
|
||||||
|
*/
|
||||||
|
private async saveMode(mode: BackgroundSyncMode): Promise<void> {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem(STORAGE_KEY, mode);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[BackgroundSyncManager] 保存模式失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从存储加载模式
|
||||||
|
*/
|
||||||
|
private async loadMode(): Promise<BackgroundSyncMode> {
|
||||||
|
try {
|
||||||
|
const saved = await AsyncStorage.getItem(STORAGE_KEY);
|
||||||
|
return (saved as BackgroundSyncMode) || BackgroundSyncMode.BATTERY_SAVER;
|
||||||
|
} catch (error) {
|
||||||
|
return BackgroundSyncMode.BATTERY_SAVER;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理资源
|
||||||
|
*/
|
||||||
|
async cleanup(): Promise<void> {
|
||||||
|
this.stopPeriodicSync();
|
||||||
|
this.appStateSubscription?.remove();
|
||||||
|
this.appStateSubscription = null;
|
||||||
|
await ForegroundServiceModule.stop();
|
||||||
|
this.isInitialized = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const backgroundSyncManager = new BackgroundSyncManager();
|
||||||
102
src/services/ForegroundServiceModule.ts
Normal file
102
src/services/ForegroundServiceModule.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* 前台服务模块
|
||||||
|
*
|
||||||
|
* 提供 React Native 层对 Android 前台服务的控制接口
|
||||||
|
* 仅 Android 平台有效
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NativeModules, Platform } from 'react-native';
|
||||||
|
|
||||||
|
const { ForegroundService } = NativeModules;
|
||||||
|
|
||||||
|
export interface ForegroundServiceOptions {
|
||||||
|
title?: string;
|
||||||
|
body?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 前台服务管理器
|
||||||
|
*
|
||||||
|
* 使用方式:
|
||||||
|
* - 前台服务启动后,会在通知栏显示一个常驻通知
|
||||||
|
* - 服务会防止应用进程被系统杀死
|
||||||
|
* - 类似 V2Ray 和 Element Android 的保活机制
|
||||||
|
*/
|
||||||
|
export const ForegroundServiceModule = {
|
||||||
|
/**
|
||||||
|
* 启动前台服务
|
||||||
|
* @param options 通知配置
|
||||||
|
*/
|
||||||
|
async start(options: ForegroundServiceOptions = {}): Promise<boolean> {
|
||||||
|
if (Platform.OS !== 'android') {
|
||||||
|
console.log('[ForegroundService] iOS 不支持前台服务保活');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ForegroundService) {
|
||||||
|
console.error('[ForegroundService] 原生模块未注册');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await ForegroundService.start(
|
||||||
|
options.title || '萝卜社区',
|
||||||
|
options.body || '正在后台同步消息'
|
||||||
|
);
|
||||||
|
console.log('[ForegroundService] 服务已启动');
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ForegroundService] 启动失败:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止前台服务
|
||||||
|
*/
|
||||||
|
async stop(): Promise<boolean> {
|
||||||
|
if (Platform.OS !== 'android') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ForegroundService) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await ForegroundService.stop();
|
||||||
|
console.log('[ForegroundService] 服务已停止');
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ForegroundService] 停止失败:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新通知内容
|
||||||
|
*/
|
||||||
|
async update(title: string, body: string): Promise<boolean> {
|
||||||
|
if (Platform.OS !== 'android') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ForegroundService) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await ForegroundService.update(title, body);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ForegroundService] 更新失败:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否为 Android 平台
|
||||||
|
*/
|
||||||
|
isSupported(): boolean {
|
||||||
|
return Platform.OS === 'android' && !!ForegroundService;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,107 +1,92 @@
|
|||||||
/**
|
/**
|
||||||
* 后台保活服务
|
* 后台保活服务
|
||||||
* 使用 expo-background-fetch 和 expo-task-manager 实现 App 后台保活
|
|
||||||
*
|
*
|
||||||
* 功能:
|
* 整合前台服务和后台同步
|
||||||
* - 定期后台任务保持 App 在内存中
|
* - 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;
|
||||||
|
|||||||
196
src/services/errorHandler.ts
Normal file
196
src/services/errorHandler.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* 统一错误处理服务
|
||||||
|
*
|
||||||
|
* 提供标准化的错误处理流程:
|
||||||
|
* 1. 错误分类(网络错误、认证错误、业务错误等)
|
||||||
|
* 2. 日志记录
|
||||||
|
* 3. 用户提示
|
||||||
|
* 4. 错误上报
|
||||||
|
*
|
||||||
|
* 替代散落在代码中的 console.error + 手动 Alert 模式
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { showPrompt } from './promptService';
|
||||||
|
|
||||||
|
export enum AppErrorCode {
|
||||||
|
NETWORK_ERROR = 'NETWORK_ERROR',
|
||||||
|
AUTH_ERROR = 'AUTH_ERROR',
|
||||||
|
FORBIDDEN = 'FORBIDDEN',
|
||||||
|
NOT_FOUND = 'NOT_FOUND',
|
||||||
|
VALIDATION_ERROR = 'VALIDATION_ERROR',
|
||||||
|
SERVER_ERROR = 'SERVER_ERROR',
|
||||||
|
TIMEOUT = 'TIMEOUT',
|
||||||
|
UNKNOWN = 'UNKNOWN',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppError {
|
||||||
|
code: AppErrorCode;
|
||||||
|
message: string;
|
||||||
|
originalError?: unknown;
|
||||||
|
context?: string;
|
||||||
|
silent?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ERROR_MESSAGES: Record<AppErrorCode, string> = {
|
||||||
|
[AppErrorCode.NETWORK_ERROR]: '网络连接失败,请检查网络设置',
|
||||||
|
[AppErrorCode.AUTH_ERROR]: '登录已过期,请重新登录',
|
||||||
|
[AppErrorCode.FORBIDDEN]: '没有权限执行此操作',
|
||||||
|
[AppErrorCode.NOT_FOUND]: '请求的资源不存在',
|
||||||
|
[AppErrorCode.VALIDATION_ERROR]: '输入数据有误,请检查后重试',
|
||||||
|
[AppErrorCode.SERVER_ERROR]: '服务器错误,请稍后重试',
|
||||||
|
[AppErrorCode.TIMEOUT]: '请求超时,请稍后重试',
|
||||||
|
[AppErrorCode.UNKNOWN]: '操作失败,请稍后重试',
|
||||||
|
};
|
||||||
|
|
||||||
|
function classifyError(error: unknown): AppErrorCode {
|
||||||
|
if (!error) return AppErrorCode.UNKNOWN;
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
const message = error.message.toLowerCase();
|
||||||
|
|
||||||
|
if (message.includes('network') || message.includes('fetch') || message.includes('econnrefused')) {
|
||||||
|
return AppErrorCode.NETWORK_ERROR;
|
||||||
|
}
|
||||||
|
if (message.includes('timeout') || message.includes('timed out')) {
|
||||||
|
return AppErrorCode.TIMEOUT;
|
||||||
|
}
|
||||||
|
if (message.includes('unauthorized') || message.includes('401') || message.includes('token')) {
|
||||||
|
return AppErrorCode.AUTH_ERROR;
|
||||||
|
}
|
||||||
|
if (message.includes('forbidden') || message.includes('403')) {
|
||||||
|
return AppErrorCode.FORBIDDEN;
|
||||||
|
}
|
||||||
|
if (message.includes('not found') || message.includes('404')) {
|
||||||
|
return AppErrorCode.NOT_FOUND;
|
||||||
|
}
|
||||||
|
if (message.includes('validation') || message.includes('400') || message.includes('422')) {
|
||||||
|
return AppErrorCode.VALIDATION_ERROR;
|
||||||
|
}
|
||||||
|
if (message.includes('500') || message.includes('502') || message.includes('503')) {
|
||||||
|
return AppErrorCode.SERVER_ERROR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof error === 'object' && error !== null) {
|
||||||
|
const err = error as Record<string, unknown>;
|
||||||
|
const status = err.status || err.statusCode || err.code;
|
||||||
|
|
||||||
|
if (status === 401) return AppErrorCode.AUTH_ERROR;
|
||||||
|
if (status === 403) return AppErrorCode.FORBIDDEN;
|
||||||
|
if (status === 404) return AppErrorCode.NOT_FOUND;
|
||||||
|
if (status === 400 || status === 422) return AppErrorCode.VALIDATION_ERROR;
|
||||||
|
if (typeof status === 'number' && status >= 500) return AppErrorCode.SERVER_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
return AppErrorCode.UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAppError(
|
||||||
|
error: unknown,
|
||||||
|
context?: string,
|
||||||
|
options?: { silent?: boolean; userMessage?: string }
|
||||||
|
): AppError {
|
||||||
|
const code = classifyError(error);
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
message: options?.userMessage || ERROR_MESSAGES[code],
|
||||||
|
originalError: error,
|
||||||
|
context,
|
||||||
|
silent: options?.silent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorHandlerOptions {
|
||||||
|
context?: string;
|
||||||
|
userMessage?: string;
|
||||||
|
silent?: boolean;
|
||||||
|
showErrorPrompt?: boolean;
|
||||||
|
logToConsole?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一错误处理函数
|
||||||
|
*
|
||||||
|
* 替代散落的 console.error + Alert 模式
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 之前
|
||||||
|
* } catch (error) {
|
||||||
|
* console.error('加载帖子详情失败:', error);
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // 之后
|
||||||
|
* } catch (error) {
|
||||||
|
* handleError(error, { context: '加载帖子详情' });
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export function handleError(error: unknown, options: ErrorHandlerOptions = {}): AppError {
|
||||||
|
const {
|
||||||
|
context,
|
||||||
|
userMessage,
|
||||||
|
silent = false,
|
||||||
|
showErrorPrompt = false,
|
||||||
|
logToConsole = true,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const appError = createAppError(error, context, { silent, userMessage });
|
||||||
|
|
||||||
|
if (logToConsole) {
|
||||||
|
const prefix = context ? `[${context}]` : '';
|
||||||
|
console.error(`${prefix} ${appError.message}`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showErrorPrompt && !silent) {
|
||||||
|
showPrompt({
|
||||||
|
type: 'error',
|
||||||
|
title: context || '错误',
|
||||||
|
message: appError.message,
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return appError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建带上下文的错误处理器
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const postErrorHandler = createErrorHandler('帖子');
|
||||||
|
*
|
||||||
|
* } catch (error) {
|
||||||
|
* postErrorHandler(error, { showErrorPrompt: true });
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export function createErrorHandler(defaultContext: string) {
|
||||||
|
return (error: unknown, options: Omit<ErrorHandlerOptions, 'context'> = {}) => {
|
||||||
|
return handleError(error, { ...options, context: defaultContext });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全执行异步操作
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const result = await safeAsync(
|
||||||
|
* () => postService.getPost(id),
|
||||||
|
* { context: '加载帖子' }
|
||||||
|
* );
|
||||||
|
* if (result.error) {
|
||||||
|
* // 处理错误
|
||||||
|
* } else {
|
||||||
|
* // 使用 result.data
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export async function safeAsync<T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
options: ErrorHandlerOptions = {}
|
||||||
|
): Promise<{ data: T | null; error: AppError | null }> {
|
||||||
|
try {
|
||||||
|
const data = await fn();
|
||||||
|
return { data, error: null };
|
||||||
|
} catch (error) {
|
||||||
|
const appError = handleError(error, options);
|
||||||
|
return { data: null, error: appError };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -111,6 +111,11 @@ export {
|
|||||||
export { showPrompt } from './promptService';
|
export { 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';
|
||||||
|
|||||||
@@ -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
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 会话响应
|
// 会话响应
|
||||||
|
|||||||
Reference in New Issue
Block a user