refactor(database): migrate to new modular database layer and unify data access
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 2m44s
Frontend CI / ota-android (push) Successful in 12m51s
Frontend CI / build-android-apk (push) Successful in 1h1m26s

- Remove legacy database.ts, LocalDataSource.ts, and MessageRepository.ts
- Create new src/database/ module with messageRepository, userCacheRepository, conversationRepository, and groupCacheRepository
- Update all consumers to import from @/database instead of services/database
- Add web platform blur handling for modal components to fix focus issues
- Flatten SystemMessageItem and NotificationsScreen styles for consistent design
- Add draggable slider in ChatSettingsScreen and dynamic font size support
- Introduce 9 new chat color themes
- Add profile screens for about, terms, and privacy policy with navigation routes
- Add policy links to login and registration screens
- Fix post share URL format from /posts/ to /post/
This commit is contained in:
lafay
2026-04-04 08:01:45 +08:00
parent 189b977fac
commit 82c2970a85
76 changed files with 3382 additions and 2000 deletions

View File

@@ -155,6 +155,13 @@ const QRCodeScannerWeb: React.FC<QRCodeScannerProps> = ({ visible, onClose }) =>
const themeColors = useAppColors();
const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]);
useEffect(() => {
if (visible && Platform.OS === 'web') {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
active?.blur?.();
}
}, [visible]);
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
<View style={styles.container}>

View File

@@ -4,7 +4,7 @@
* 提供友好的用户界面和流畅的交互体验
*/
import React, { useState, useCallback, useMemo } from 'react';
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import {
Modal,
View,
@@ -62,6 +62,13 @@ const ReportDialog: React.FC<ReportDialogProps> = ({
const targetConfig = TARGET_CONFIG[targetType];
useEffect(() => {
if (visible && Platform.OS === 'web') {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
active?.blur?.();
}
}, [visible]);
// 判断补充说明是否必填(选择"其他原因"时必填)
const isDescriptionRequired = selectedReason === 'other';
const descriptionPlaceholder = isDescriptionRequired

View File

@@ -1,7 +1,7 @@
/**
* SystemMessageItem 系统消息项组件 - 美化版
* SystemMessageItem 系统消息项组件 - 扁平化风格
* 根据系统消息类型显示不同图标和内容
* 采用卡片式设计符合胡萝卜BBS整体风格
* 与登录、注册、设置页面风格保持一致
*/
import React, { useMemo } from 'react';
@@ -9,7 +9,7 @@ import { View, TouchableOpacity, StyleSheet } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { formatDistanceToNow } from 'date-fns';
import { zhCN } from 'date-fns/locale';
import { spacing, borderRadius, shadows, fontSizes, useAppColors, type AppColors } from '../../theme';
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../theme';
import { SystemMessageResponse, SystemMessageType } from '../../types/dto';
import Text from '../common/Text';
import Avatar from '../common/Avatar';
@@ -124,17 +124,13 @@ function createSystemMessageStyles(colors: AppColors) {
container: {
flexDirection: 'row',
alignItems: 'flex-start',
padding: spacing.md,
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
marginHorizontal: spacing.md,
marginBottom: spacing.sm,
...shadows.sm,
padding: 16,
backgroundColor: '#fff',
borderBottomWidth: 1,
borderBottomColor: colors.divider || '#E5E5EA',
},
unreadContainer: {
backgroundColor: colors.primary.light + '08',
borderLeftWidth: 3,
borderLeftColor: colors.primary.main,
backgroundColor: '#FFF8F6',
},
unreadIndicator: {
position: 'absolute',
@@ -143,19 +139,19 @@ function createSystemMessageStyles(colors: AppColors) {
marginTop: -4,
width: 8,
height: 8,
borderRadius: borderRadius.full,
backgroundColor: colors.primary.main,
borderRadius: 4,
backgroundColor: '#FF6B35',
},
iconContainer: {
marginRight: spacing.md,
marginRight: 14,
},
avatarWrapper: {
position: 'relative',
},
iconWrapper: {
width: 48,
height: 48,
borderRadius: borderRadius.lg,
width: 44,
height: 44,
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
},
@@ -163,13 +159,13 @@ function createSystemMessageStyles(colors: AppColors) {
position: 'absolute',
bottom: -2,
right: -2,
width: 20,
height: 20,
borderRadius: borderRadius.full,
width: 18,
height: 18,
borderRadius: 9,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: colors.background.paper,
borderColor: '#fff',
},
content: {
flex: 1,
@@ -180,17 +176,17 @@ function createSystemMessageStyles(colors: AppColors) {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.xs,
marginBottom: 4,
},
titleLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
marginRight: spacing.sm,
marginRight: 8,
},
title: {
fontWeight: '500',
fontSize: fontSizes.md,
fontSize: 15,
color: colors.text.primary,
},
unreadTitle: {
@@ -200,10 +196,10 @@ function createSystemMessageStyles(colors: AppColors) {
statusBadge: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.xs,
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: borderRadius.sm,
marginLeft: spacing.xs,
borderRadius: 4,
marginLeft: 6,
},
statusText: {
fontSize: 10,
@@ -211,55 +207,59 @@ function createSystemMessageStyles(colors: AppColors) {
marginLeft: 2,
},
time: {
fontSize: fontSizes.sm,
fontSize: 13,
flexShrink: 0,
color: colors.text.hint,
},
messageContent: {
lineHeight: 20,
fontSize: fontSizes.sm,
fontSize: 14,
color: colors.text.secondary,
},
extraInfo: {
flexDirection: 'row',
alignItems: 'center',
marginTop: spacing.xs,
backgroundColor: colors.primary.light + '12',
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: borderRadius.md,
marginTop: 8,
backgroundColor: '#F5F5F7',
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 8,
alignSelf: 'flex-start',
},
extraText: {
marginLeft: spacing.xs,
marginLeft: 6,
flex: 1,
fontWeight: '500',
fontSize: 13,
},
actionsRow: {
flexDirection: 'row',
marginTop: spacing.sm,
gap: spacing.sm,
marginTop: 12,
gap: 10,
},
actionBtn: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.xs,
paddingHorizontal: spacing.md,
borderRadius: borderRadius.md,
paddingVertical: 8,
paddingHorizontal: 16,
borderRadius: 10,
borderWidth: 1,
gap: spacing.xs,
gap: 4,
},
rejectBtn: {
borderColor: `${colors.error.main}55`,
backgroundColor: `${colors.error.main}18`,
borderColor: colors.error.main + '40',
backgroundColor: colors.error.light + '20',
},
approveBtn: {
borderColor: `${colors.success.main}55`,
backgroundColor: `${colors.success.main}18`,
borderColor: colors.success.main + '40',
backgroundColor: colors.success.light + '20',
},
actionBtnText: {
fontWeight: '500',
fontSize: 13,
},
arrowContainer: {
marginLeft: spacing.sm,
marginLeft: 8,
justifyContent: 'center',
alignItems: 'center',
height: 20,
@@ -318,6 +318,13 @@ const getSystemMessageTitle = (message: SystemMessageResponse): string => {
}
};
// 胡萝卜橙主题色
const THEME_COLORS = {
primary: '#FF6B35',
primaryLight: '#FF8C5A',
primaryDark: '#E55A2B',
};
const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
message,
onPress,

View File

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

View File

@@ -15,6 +15,7 @@ import {
Modal,
Pressable,
Dimensions,
Platform,
} from 'react-native';
import {
useResponsive,
@@ -159,6 +160,10 @@ export function AdaptiveLayout({
// 抽屉动画
useEffect(() => {
if (isDrawerOpen) {
if (Platform.OS === 'web') {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
active?.blur?.();
}
Animated.parallel([
Animated.timing(slideAnim, {
toValue: 1,

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Modal, Pressable, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Modal, Pressable, StyleSheet, Text, TouchableOpacity, View, Platform } from 'react-native';
import type { AlertButton } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
@@ -93,6 +93,12 @@ function createDialogHostStyles(colors: AppColors) {
});
}
const blurActiveElementOnWeb = () => {
if (Platform.OS !== 'web') return;
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
active?.blur?.();
};
const AppDialogHost: React.FC = () => {
const [dialog, setDialog] = useState<DialogPayload | null>(null);
const colors = useAppColors();
@@ -105,6 +111,12 @@ const AppDialogHost: React.FC = () => {
return unbind;
}, []);
useEffect(() => {
if (dialog) {
blurActiveElementOnWeb();
}
}, [dialog]);
const actions = useMemo<AlertButton[]>(() => {
if (!dialog?.actions?.length) return [{ text: '确定' }];
return dialog.actions;

View File

@@ -14,6 +14,7 @@ import {
Text,
StatusBar,
Alert,
Platform,
} from 'react-native';
import { Image as ExpoImage } from 'expo-image';
import {
@@ -126,6 +127,10 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
// 打开/关闭时重置状态
useEffect(() => {
if (visible) {
if (Platform.OS === 'web') {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
active?.blur?.();
}
setCurrentIndex(initialIndex);
setShowControls(true);
setError(false);

View File

@@ -3,7 +3,7 @@
* 全屏模态播放视频,基于 expo-video
*/
import React, { useCallback } from 'react';
import React, { useCallback, useEffect } from 'react';
import {
Modal,
View,
@@ -12,6 +12,7 @@ import {
TouchableOpacity,
Text,
StatusBar,
Platform,
} from 'react-native';
import { VideoView, useVideoPlayer } from 'expo-video';
import { MaterialCommunityIcons } from '@expo/vector-icons';
@@ -79,6 +80,13 @@ export const VideoPlayerModal: React.FC<VideoPlayerModalProps> = ({
url,
onClose,
}) => {
useEffect(() => {
if (visible && Platform.OS === 'web') {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
active?.blur?.();
}
}, [visible]);
return (
<Modal
visible={visible}

View File

@@ -4,7 +4,7 @@
* 纯业务逻辑无UI依赖
*/
import { messageRepository } from '../../data/repositories/MessageRepository';
import { messageRepository, userCacheRepository, CachedMessage } from '@/database';
import { wsClient, WSEventType } from '../../data/datasources/WSClient';
import {
Message,
@@ -18,6 +18,16 @@ import {
import { messageService } from '../../services/messageService';
import { api } from '../../services/api';
const cachedMessageToMessage = (cached: CachedMessage): Message => ({
id: cached.id,
conversationId: cached.conversationId,
senderId: cached.senderId,
seq: cached.seq,
segments: cached.segments || [],
createdAt: cached.createdAt,
status: cached.status as Message['status'],
});
// 事件类型定义
export type MessageUseCaseEventType =
| 'message_received'
@@ -182,7 +192,16 @@ class ProcessMessageUseCase {
// 异步保存到本地数据库
const isRead = _isAck || sender_id === this.currentUserId;
messageRepository.saveMessage(newMessage, isRead).catch((error) => {
messageRepository.saveMessage({
id: newMessage.id,
conversationId: newMessage.conversationId,
senderId: newMessage.senderId,
seq: newMessage.seq,
segments: newMessage.segments,
createdAt: newMessage.createdAt,
status: newMessage.status || 'normal',
isRead,
}).catch((error) => {
console.error('[ProcessMessageUseCase] 保存消息失败:', error);
});
@@ -227,7 +246,7 @@ class ProcessMessageUseCase {
*/
private async getSenderInfo(userId: string): Promise<any | null> {
// 先检查本地缓存
const cachedUser = await messageRepository.getUserCache(userId);
const cachedUser = await userCacheRepository.get(userId);
if (cachedUser) {
return cachedUser;
}
@@ -255,9 +274,9 @@ class ProcessMessageUseCase {
*/
private async fetchUserInfo(userId: string): Promise<any | null> {
try {
const response = await api.get(`/users/${userId}`);
const response = await api.get<any>(`/users/${userId}`);
if (response.code === 0 && response.data) {
await messageRepository.saveUserCache(response.data);
await userCacheRepository.save(response.data as any);
return response.data;
}
return null;
@@ -322,8 +341,8 @@ class ProcessMessageUseCase {
// 更新本地数据库状态
messageRepository
.updateMessageStatus(message_id, 'recalled', true)
.catch((error) => {
.updateStatus(message_id, 'recalled', true)
.catch((error: unknown) => {
console.error('[ProcessMessageUseCase] 更新撤回状态失败:', error);
});
@@ -349,8 +368,8 @@ class ProcessMessageUseCase {
// 更新本地数据库状态
messageRepository
.updateMessageStatus(message_id, 'recalled', true)
.catch((error) => {
.updateStatus(message_id, 'recalled', true)
.catch((error: unknown) => {
console.error('[ProcessMessageUseCase] 更新撤回状态失败:', error);
});
@@ -506,7 +525,16 @@ class ProcessMessageUseCase {
});
// 保存到本地数据库
await messageRepository.saveMessage(message, true);
await messageRepository.saveMessage({
id: message.id,
conversationId: message.conversationId,
senderId: message.senderId,
seq: message.seq,
segments: message.segments,
createdAt: message.createdAt,
status: message.status || 'normal',
isRead: true,
});
return message;
}
@@ -522,7 +550,8 @@ class ProcessMessageUseCase {
* 获取本地消息
*/
async getLocalMessages(conversationId: string, limit: number = 20): Promise<Message[]> {
return messageRepository.getMessagesByConversation(conversationId, limit);
const cached = await messageRepository.getByConversation(conversationId, limit);
return cached.map(cachedMessageToMessage);
}
/**
@@ -534,14 +563,14 @@ class ProcessMessageUseCase {
limit: number = 20
): Promise<Message[]> {
// 先从本地获取
const localMessages = await messageRepository.getMessagesBeforeSeq(
const localMessages = await messageRepository.getBeforeSeq(
conversationId,
beforeSeq,
limit
);
if (localMessages.length >= limit) {
return localMessages;
return localMessages.map(cachedMessageToMessage);
}
// 本地数据不足,从服务端获取
@@ -567,7 +596,16 @@ class ProcessMessageUseCase {
);
// 保存到本地
await messageRepository.saveMessages(serverMessages, false);
await messageRepository.saveMessagesBatch(serverMessages.map((m: any) => ({
id: m.id,
conversationId: m.conversation_id || conversationId,
senderId: m.sender_id,
seq: m.seq,
segments: m.segments || [],
createdAt: m.created_at,
status: m.status || 'normal',
isRead: false,
})));
return serverMessages;
}
@@ -575,7 +613,7 @@ class ProcessMessageUseCase {
console.error('[ProcessMessageUseCase] 获取历史消息失败:', error);
}
return localMessages;
return localMessages.map(cachedMessageToMessage);
}
/**
@@ -602,7 +640,16 @@ class ProcessMessageUseCase {
);
// 保存到本地
await messageRepository.saveMessages(messages, false);
await messageRepository.saveMessagesBatch(messages.map((m: any) => ({
id: m.id,
conversationId: m.conversation_id || conversationId,
senderId: m.sender_id,
seq: m.seq,
segments: m.segments || [],
createdAt: m.created_at,
status: m.status || 'normal',
isRead: false,
})));
return messages;
}

View File

@@ -5,5 +5,5 @@
export * from './interfaces';
export * from './ApiDataSource';
export * from './LocalDataSource';
export { LocalDataSource, localDataSource } from '@/database';
export * from './CacheDataSource';

View File

@@ -20,6 +20,8 @@ export interface ILocalDataSource {
run(sql: string, params?: any[]): Promise<{ lastInsertRowId: number; changes: number }>;
transaction(operations: () => Promise<void>): Promise<void>;
batch(operations: (db: ILocalDataSource) => Promise<void>): Promise<void>;
enqueueWrite<T>(operation: () => Promise<T>): Promise<T>;
initialize(userId?: string): Promise<void>;
}
// 缓存数据源接口

View File

@@ -1,220 +0,0 @@
/**
* MessageRepository - 消息仓库实现
* 封装所有SQLite数据库操作不依赖任何UI或状态管理
*/
import {
saveMessage as dbSaveMessage,
saveMessagesBatch as dbSaveMessagesBatch,
getMessagesByConversation as dbGetMessagesByConversation,
getMaxSeq as dbGetMaxSeq,
getMinSeq as dbGetMinSeq,
getMessagesBeforeSeq as dbGetMessagesBeforeSeq,
markConversationAsRead as dbMarkConversationAsRead,
updateConversationCacheUnreadCount as dbUpdateConversationCacheUnreadCount,
getUserCache as dbGetUserCache,
saveUserCache as dbSaveUserCache,
deleteConversation as dbDeleteConversation,
updateMessageStatus as dbUpdateMessageStatus,
CachedMessage,
} from '../../services/database';
import { Message, Conversation, createMessage, createConversation } from '../../core/entities/Message';
// 数据库消息到领域实体的转换
const cachedMessageToMessage = (cached: CachedMessage): Message => ({
id: cached.id,
conversationId: cached.conversationId,
senderId: cached.senderId,
seq: cached.seq,
segments: cached.segments || [],
createdAt: cached.createdAt,
status: cached.status as Message['status'],
});
// 领域实体到数据库消息的转换
const messageToCachedMessage = (message: Message, isRead: boolean): CachedMessage => ({
id: message.id,
conversationId: message.conversationId,
senderId: message.senderId,
content: message.segments
.filter((s) => s.type === 'text')
.map((s) => s.data?.text || '')
.join(''),
type: message.segments.find((s) => s.type === 'image') ? 'image' : 'text',
isRead,
createdAt: message.createdAt,
seq: message.seq,
status: message.status,
segments: message.segments,
});
class MessageRepository {
// ==================== 消息操作 ====================
/**
* 保存单条消息
*/
async saveMessage(message: Message, isRead: boolean): Promise<void> {
try {
const cachedMessage = messageToCachedMessage(message, isRead);
await dbSaveMessage(cachedMessage);
} catch (error) {
console.error('[MessageRepository] 保存消息失败:', error);
throw error;
}
}
/**
* 批量保存消息
*/
async saveMessages(messages: Message[], isRead: boolean): Promise<void> {
if (!messages || messages.length === 0) return;
try {
const cachedMessages = messages.map((m) => messageToCachedMessage(m, isRead));
await dbSaveMessagesBatch(cachedMessages);
} catch (error) {
console.error('[MessageRepository] 批量保存消息失败:', error);
throw error;
}
}
/**
* 获取会话的消息
*/
async getMessagesByConversation(
conversationId: string,
limit: number = 20
): Promise<Message[]> {
try {
const cachedMessages = await dbGetMessagesByConversation(conversationId, limit);
return cachedMessages.map(cachedMessageToMessage);
} catch (error) {
console.error('[MessageRepository] 获取消息失败:', error);
return [];
}
}
/**
* 获取会话的最大消息序号
*/
async getMaxSeq(conversationId: string): Promise<number> {
try {
return await dbGetMaxSeq(conversationId);
} catch (error) {
console.error('[MessageRepository] 获取最大序号失败:', error);
return 0;
}
}
/**
* 获取会话的最小消息序号
*/
async getMinSeq(conversationId: string): Promise<number> {
try {
return await dbGetMinSeq(conversationId);
} catch (error) {
console.error('[MessageRepository] 获取最小序号失败:', error);
return 0;
}
}
/**
* 获取指定seq之前的历史消息
*/
async getMessagesBeforeSeq(
conversationId: string,
beforeSeq: number,
limit: number = 20
): Promise<Message[]> {
try {
const cachedMessages = await dbGetMessagesBeforeSeq(conversationId, beforeSeq, limit);
return cachedMessages.map(cachedMessageToMessage);
} catch (error) {
console.error('[MessageRepository] 获取历史消息失败:', error);
return [];
}
}
/**
* 标记会话的所有消息为已读
*/
async markConversationAsRead(conversationId: string): Promise<void> {
try {
await dbMarkConversationAsRead(conversationId);
} catch (error) {
console.error('[MessageRepository] 标记会话已读失败:', error);
throw error;
}
}
/**
* 更新会话缓存的未读数
*/
async updateConversationUnreadCount(
conversationId: string,
count: number
): Promise<void> {
try {
await dbUpdateConversationCacheUnreadCount(conversationId, count);
} catch (error) {
console.error('[MessageRepository] 更新会话未读数失败:', error);
}
}
/**
* 更新消息状态(如撤回)
*/
async updateMessageStatus(
messageId: string,
status: string,
clearContent: boolean = false
): Promise<void> {
try {
await dbUpdateMessageStatus(messageId, status, clearContent);
} catch (error) {
console.error('[MessageRepository] 更新消息状态失败:', error);
throw error;
}
}
/**
* 删除会话及其所有消息
*/
async deleteConversation(conversationId: string): Promise<void> {
try {
await dbDeleteConversation(conversationId);
} catch (error) {
console.error('[MessageRepository] 删除会话失败:', error);
throw error;
}
}
// ==================== 用户缓存操作 ====================
/**
* 获取用户缓存
*/
async getUserCache(userId: string): Promise<any | null> {
try {
return await dbGetUserCache(userId);
} catch (error) {
console.error('[MessageRepository] 获取用户缓存失败:', error);
return null;
}
}
/**
* 保存用户缓存
*/
async saveUserCache(user: any): Promise<void> {
try {
await dbSaveUserCache(user);
} catch (error) {
console.error('[MessageRepository] 保存用户缓存失败:', error);
}
}
}
export const messageRepository = new MessageRepository();
export default messageRepository;

View File

@@ -4,7 +4,7 @@
*/
import { ApiDataSource, apiDataSource } from '../datasources/ApiDataSource';
import { LocalDataSource, localDataSource } from '../datasources/LocalDataSource';
import { localDataSource } from '@/database';
import { PostMapper } from '../mappers/PostMapper';
import type { Post, PostImage, PostStatus } from '../../core/entities/Post';
import { createPost } from '../../core/entities/Post';
@@ -79,13 +79,12 @@ interface CachedPost {
export class PostRepository implements IPostRepository {
private api: ApiDataSource;
private localDb: LocalDataSource;
private localDb = localDataSource;
private memoryCache: Map<string, { post: Post; timestamp: number }> = new Map();
private readonly CACHE_TTL = 5 * 60 * 1000; // 5分钟缓存过期时间
private readonly CACHE_TTL = 5 * 60 * 1000;
constructor(api: ApiDataSource, localDb: LocalDataSource) {
constructor(api: ApiDataSource) {
this.api = api;
this.localDb = localDb;
}
// ==================== 私有辅助方法 ====================
@@ -575,5 +574,5 @@ export class PostRepository implements IPostRepository {
// ==================== 单例导出 ====================
export const postRepository = new PostRepository(apiDataSource, localDataSource);
export const postRepository = new PostRepository(apiDataSource);
export default postRepository;

View File

@@ -1,111 +1,21 @@
/**
*
* database.ts OPFS
*/
import * as SQLite from 'expo-sqlite';
import { ILocalDataSource, DataSourceError } from './interfaces';
import {
initDatabase,
getCurrentDbUserId,
isDbInitialized,
getDbInstance,
} from '../../services/database';
import { ILocalDataSource, DataSourceError } from '@/data/datasources/interfaces';
import { databaseManager } from './core/DatabaseManager';
let writeQueue: Promise<void> = Promise.resolve();
export interface LocalDataSourceConfig {
userId?: string;
}
export class LocalDataSource implements ILocalDataSource {
private userId: string | null = null;
private initialized = false;
private initializingPromise: Promise<void> | null = null;
constructor(config: LocalDataSourceConfig = {}) {
this.userId = config.userId || null;
async initialize(userId?: string): Promise<void> {
await databaseManager.initialize(userId);
}
async initialize(): Promise<void> {
if (this.initialized && isDbInitialized()) {
return;
}
if (this.initializingPromise) {
await this.initializingPromise;
return;
}
this.initializingPromise = this.doInitialize();
try {
await this.initializingPromise;
} finally {
this.initializingPromise = null;
}
}
private async doInitialize(): Promise<void> {
try {
// 如果 database.ts 已经初始化,复用它的连接
if (isDbInitialized()) {
const currentUserId = getCurrentDbUserId();
if (this.userId && this.userId !== currentUserId) {
await initDatabase(this.userId);
}
} else if (this.userId) {
await initDatabase(this.userId);
} else {
const currentUserId = getCurrentDbUserId();
if (!currentUserId) {
console.warn('[LocalDataSource] 没有用户登录,跳过初始化');
return;
}
}
const db = getDbInstance();
if (db) {
await db.execAsync(`
CREATE TABLE IF NOT EXISTS posts_cache (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)
`);
}
this.initialized = true;
} catch (error) {
this.handleError(error, 'INITIALIZE');
}
}
private handleError(error: unknown, operation: string): never {
const message = error instanceof Error ? error.message : 'Unknown error';
throw new DataSourceError(
`Database ${operation} failed: ${message}`,
'DB_ERROR',
'LocalDataSource',
error instanceof Error ? error : undefined
);
}
private ensureDb(): SQLite.SQLiteDatabase {
const db = getDbInstance();
if (!db) {
throw new DataSourceError(
'Database not initialized',
'DB_NOT_INITIALIZED',
'LocalDataSource'
);
}
return db;
private async ensureDb(): Promise<SQLite.SQLiteDatabase> {
return databaseManager.getDb();
}
async query<T>(sql: string, params?: any[]): Promise<T[]> {
try {
await this.initialize();
const db = this.ensureDb();
const db = await this.ensureDb();
if (params && params.length > 0) {
return await db.getAllAsync<T>(sql, params as any);
}
@@ -117,8 +27,7 @@ export class LocalDataSource implements ILocalDataSource {
async getFirst<T>(sql: string, params?: any[]): Promise<T | null> {
try {
await this.initialize();
const db = this.ensureDb();
const db = await this.ensureDb();
if (params && params.length > 0) {
return await db.getFirstAsync<T>(sql, params as any);
}
@@ -130,8 +39,7 @@ export class LocalDataSource implements ILocalDataSource {
async execute(sql: string, params?: any[]): Promise<void> {
try {
await this.initialize();
const db = this.ensureDb();
const db = await this.ensureDb();
await db.execAsync(sql);
} catch (error) {
this.handleError(error, 'EXECUTE');
@@ -140,30 +48,19 @@ export class LocalDataSource implements ILocalDataSource {
async run(sql: string, params?: any[]): Promise<{ lastInsertRowId: number; changes: number }> {
try {
await this.initialize();
const db = this.ensureDb();
const db = await this.ensureDb();
if (params && params.length > 0) {
return await db.runAsync(sql, params as any);
}
return await db.runAsync(sql);
} catch (error) {
if (this.isRecoverableError(error)) {
this.initialized = false;
await this.initialize();
const db = this.ensureDb();
if (params && params.length > 0) {
return await db.runAsync(sql, params as any);
}
return await db.runAsync(sql);
}
this.handleError(error, 'RUN');
}
}
async transaction(operations: () => Promise<void>): Promise<void> {
try {
await this.initialize();
const db = this.ensureDb();
const db = await this.ensureDb();
await db.withTransactionAsync(async () => {
await operations();
});
@@ -183,15 +80,13 @@ export class LocalDataSource implements ILocalDataSource {
}
async enqueueWrite<T>(operation: () => Promise<T>): Promise<T> {
await this.initialize();
const wrappedOperation = async () => {
try {
return await operation();
} catch (error) {
if (this.isRecoverableError(error)) {
console.error('[LocalDataSource] 数据库写入异常,尝试重连后重试:', error);
this.initialized = false;
await this.initialize();
await databaseManager.initialize(databaseManager.getCurrentUserId());
return operation();
}
throw error;
@@ -213,6 +108,16 @@ export class LocalDataSource implements ILocalDataSource {
message.includes('cannot create file')
);
}
private handleError(error: unknown, operation: string): never {
const message = error instanceof Error ? error.message : 'Unknown error';
throw new DataSourceError(
`Database ${operation} failed: ${message}`,
'DB_ERROR',
'LocalDataSource',
error instanceof Error ? error : undefined
);
}
}
export const localDataSource = new LocalDataSource();
export const localDataSource = new LocalDataSource();

View File

@@ -0,0 +1,14 @@
export const DB_CONFIG = {
MAX_RETRIES: 4,
WEB_RETRY_DELAY_BASE: 500,
NATIVE_RETRY_DELAY: 100,
WEB_CLOSE_DELAY: 300,
WEB_FINAL_CLOSE_DELAY: 200,
LOCK_PREFIX: 'db-',
DEFAULT_DB_NAME: 'carrot_bbs.db',
DB_NAME_PREFIX: 'carrot_bbs_',
} as const;
export const getDbName = (userId: string | null): string => {
return userId ? `${DB_CONFIG.DB_NAME_PREFIX}${userId}.db` : DB_CONFIG.DEFAULT_DB_NAME;
};

View File

@@ -0,0 +1,196 @@
import * as SQLite from 'expo-sqlite';
import { LockManager } from './LockManager';
import { DB_CONFIG, getDbName } from './DatabaseConfig';
import { runMigrations } from '../schema/MigrationManager';
type SQLiteDatabase = SQLite.SQLiteDatabase;
class DatabaseManager {
private static instance: DatabaseManager;
private db: SQLiteDatabase | null = null;
private currentUserId: string | null = null;
private initPromise: Promise<void> | null = null;
private initTargetUserId: string | null = null;
private isInitialized = false;
static getInstance(): DatabaseManager {
if (!DatabaseManager.instance) {
DatabaseManager.instance = new DatabaseManager();
}
return DatabaseManager.instance;
}
async initialize(userId?: string | null): Promise<void> {
const targetUserId = userId || null;
const callId = Date.now();
console.log(`[DatabaseManager ${callId}] initialize 开始, userId=${targetUserId}, 当前db=${!!this.db}, 当前userId=${this.currentUserId}`);
if (this.db && this.currentUserId === targetUserId) {
console.log(`[DatabaseManager ${callId}] 已是目标数据库,直接返回`);
this.isInitialized = true;
return;
}
if (this.initPromise && this.initTargetUserId === targetUserId) {
console.log(`[DatabaseManager ${callId}] 等待进行中的初始化`);
await this.initPromise;
console.log(`[DatabaseManager ${callId}] 初始化完成`);
return;
}
if (this.initPromise) {
console.log(`[DatabaseManager ${callId}] 等待其他初始化完成`);
await this.initPromise.catch(() => {});
}
if (this.db && this.currentUserId === targetUserId) {
console.log(`[DatabaseManager ${callId}] 其他初始化已完成目标,直接返回`);
this.isInitialized = true;
return;
}
this.initTargetUserId = targetUserId;
console.log(`[DatabaseManager ${callId}] 开始实际初始化`);
this.initPromise = LockManager.withMutex(async () => {
const mutexId = callId;
console.log(`[DatabaseManager ${mutexId}] 获得互斥锁`);
if (this.db && this.currentUserId === targetUserId) {
console.log(`[DatabaseManager ${mutexId}] 互斥锁内检查:已是目标数据库`);
this.isInitialized = true;
return;
}
const isWeb = typeof window !== 'undefined';
if (this.db) {
console.log(`[DatabaseManager ${mutexId}] 关闭旧数据库连接...`);
try {
await this.db.closeAsync();
console.log(`[DatabaseManager ${mutexId}] 旧连接已关闭`);
} catch (e) {
console.warn(`[DatabaseManager ${mutexId}] 关闭旧连接失败:`, e);
}
this.db = null;
this.currentUserId = null;
this.isInitialized = false;
if (isWeb) {
await new Promise(r => setTimeout(r, DB_CONFIG.WEB_CLOSE_DELAY));
}
}
const dbName = getDbName(targetUserId);
console.log(`[DatabaseManager ${mutexId}] 打开数据库: ${dbName}`);
const database = await this.openDatabaseWithRetry(dbName);
console.log(`[DatabaseManager ${mutexId}] 数据库已打开,执行迁移`);
await runMigrations(database);
console.log(`[DatabaseManager ${mutexId}] 迁移完成`);
this.db = database;
this.currentUserId = targetUserId;
this.isInitialized = true;
console.log(`[DatabaseManager ${mutexId}] 初始化完成`);
}).finally(() => {
console.log(`[DatabaseManager ${callId}] 清理初始化状态`);
this.initPromise = null;
this.initTargetUserId = null;
});
await this.initPromise;
console.log(`[DatabaseManager ${callId}] initialize 返回`);
}
private async openDatabaseWithRetry(dbName: string): Promise<SQLiteDatabase> {
const isWeb = typeof window !== 'undefined';
return LockManager.withWebLock(`${DB_CONFIG.LOCK_PREFIX}${dbName}`, async () => {
for (let attempt = 0; attempt <= DB_CONFIG.MAX_RETRIES; attempt++) {
try {
const database = await SQLite.openDatabaseAsync(dbName);
console.log(`[DatabaseManager] 成功打开数据库 (尝试 ${attempt + 1}/${DB_CONFIG.MAX_RETRIES + 1})`);
return database;
} catch (error) {
const msg = String(error);
console.error(`[DatabaseManager] 打开失败 (尝试 ${attempt + 1}/${DB_CONFIG.MAX_RETRIES + 1}):`, msg);
if (attempt < DB_CONFIG.MAX_RETRIES) {
const delay = isWeb ? DB_CONFIG.WEB_RETRY_DELAY_BASE * (attempt + 1) : DB_CONFIG.NATIVE_RETRY_DELAY;
console.warn(`[DatabaseManager] ${delay}ms 后重试...`);
await new Promise(r => setTimeout(r, delay));
} else {
throw error;
}
}
}
throw new Error('打开数据库失败');
});
}
async close(): Promise<void> {
if (this.initPromise) {
await this.initPromise.catch(() => {});
}
const dbName = getDbName(this.currentUserId);
await LockManager.withWebLock(`${DB_CONFIG.LOCK_PREFIX}${dbName}`, async () => {
await LockManager.withMutex(async () => {
if (!this.db) return;
try {
await this.db.closeAsync();
console.log('[DatabaseManager] 数据库已关闭');
if (typeof window !== 'undefined') {
await new Promise(r => setTimeout(r, DB_CONFIG.WEB_FINAL_CLOSE_DELAY));
}
} catch (e) {
console.warn('[DatabaseManager] 关闭失败:', e);
}
this.db = null;
this.currentUserId = null;
this.isInitialized = false;
});
});
}
async getDb(timeout: number = 10000): Promise<SQLiteDatabase> {
if (this.isInitialized && this.db) return this.db;
if (this.initPromise) {
const timeoutPromise = new Promise<null>((_, reject) =>
setTimeout(() => reject(new Error('等待数据库初始化超时')), timeout)
);
await Promise.race([this.initPromise, timeoutPromise]);
if (this.db) return this.db;
}
throw new Error('数据库未初始化,请先调用 initialize(userId)');
}
getDbSync(): SQLiteDatabase | null {
return this.db;
}
getCurrentUserId(): string | null {
return this.currentUserId;
}
isReady(): boolean {
return this.isInitialized && this.db !== null;
}
async withMutex<T>(fn: () => Promise<T>): Promise<T> {
return LockManager.withMutex(fn);
}
}
export const databaseManager = DatabaseManager.getInstance();
export { DatabaseManager };

View File

@@ -0,0 +1,34 @@
export class LockManager {
private static mutex: Promise<void> = Promise.resolve();
static async withMutex<T>(fn: () => Promise<T>): Promise<T> {
const prev = LockManager.mutex;
let release: () => void = () => {};
LockManager.mutex = new Promise<void>((r) => { release = r; });
try {
await prev;
return await fn();
} finally {
release();
}
}
static async withWebLock<T>(lockName: string, fn: () => Promise<T>): Promise<T> {
if (typeof navigator !== 'undefined' && navigator.locks) {
return new Promise((resolve, reject) => {
navigator.locks.request(lockName, { mode: 'exclusive' }, async (lock) => {
if (!lock) {
return reject(new Error('无法获取跨标签页锁'));
}
try {
const result = await fn();
resolve(result);
} catch (e) {
reject(e);
}
});
});
}
return fn();
}
}

54
src/database/index.ts Normal file
View File

@@ -0,0 +1,54 @@
import { localDataSource } from './LocalDataSource';
import { databaseManager } from './core/DatabaseManager';
export { databaseManager } from './core/DatabaseManager';
export { DatabaseManager } from './core/DatabaseManager';
export { localDataSource, LocalDataSource } from './LocalDataSource';
export {
messageRepository, MessageRepository,
conversationRepository, ConversationRepository,
userCacheRepository, UserCacheRepository,
groupCacheRepository, GroupCacheRepository,
postCacheRepository, PostCacheRepository,
} from './repositories';
export { CachedMessage } from './types';
export async function clearAllData(): Promise<void> {
await localDataSource.enqueueWrite(async () => {
await localDataSource.execute(`DELETE FROM messages`);
await localDataSource.execute(`DELETE FROM conversations`);
await localDataSource.execute(`DELETE FROM conversation_cache`);
await localDataSource.execute(`DELETE FROM conversation_list_cache`);
await localDataSource.execute(`DELETE FROM users`);
await localDataSource.execute(`DELETE FROM current_user_cache`);
await localDataSource.execute(`DELETE FROM groups`);
await localDataSource.execute(`DELETE FROM group_members`);
await localDataSource.execute(`DELETE FROM posts_cache`);
});
}
export const initDatabase = async (userId?: string | null): Promise<void> => {
await databaseManager.initialize(userId);
};
export const closeDatabase = async (): Promise<void> => {
await databaseManager.close();
};
export const getCurrentDbUserId = (): string | null => {
return databaseManager.getCurrentUserId();
};
export const isDbInitialized = (): boolean => {
return databaseManager.isReady();
};
export const getDbInstance = () => {
return databaseManager.getDbSync();
};
export const getDb = async (timeout?: number) => {
return databaseManager.getDb(timeout);
};

View File

@@ -0,0 +1,197 @@
import { localDataSource } from '../LocalDataSource';
import type { ILocalDataSource } from '@/data/datasources/interfaces';
import type { ConversationResponse, ConversationDetailResponse } from '@/types/dto';
type ConversationCacheData = ConversationResponse | ConversationDetailResponse;
const safeParseJson = <T>(value: string): T | null => {
try { return JSON.parse(value) as T; } catch { return null; }
};
export interface IConversationRepository {
save(conv: { id: string; participantId: string; lastMessageId?: string; lastSeq?: number; unreadCount?: number; createdAt: string; updatedAt: string }): Promise<void>;
getAll(): Promise<any[]>;
updateUnreadCount(id: string, count: number): Promise<void>;
updateLastMessage(id: string, lastMessageId: string, lastSeq: number, updatedAt: string): Promise<void>;
delete(conversationId: string): Promise<void>;
getCache(id: string): Promise<ConversationCacheData | null>;
getListCache(): Promise<ConversationResponse[]>;
saveCache(conv: ConversationCacheData): Promise<void>;
saveListCache(convs: ConversationResponse[]): Promise<void>;
updateListEntry(id: string, updates: Partial<ConversationResponse>): Promise<void>;
updateCacheUnreadCount(id: string, count: number, myLastReadSeq?: number): Promise<void>;
saveWithRelated(convs: ConversationResponse[], users?: any[], groups?: any[]): Promise<void>;
}
export class ConversationRepository implements IConversationRepository {
constructor(private dataSource: ILocalDataSource = localDataSource) {}
async save(conv: { id: string; participantId: string; lastMessageId?: string; lastSeq?: number; unreadCount?: number; createdAt: string; updatedAt: string }): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(
`INSERT OR REPLACE INTO conversations (id, participantId, lastMessageId, lastSeq, unreadCount, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[conv.id, conv.participantId, conv.lastMessageId || null, conv.lastSeq || 0, conv.unreadCount || 0, conv.createdAt, conv.updatedAt]
);
});
}
async getAll(): Promise<any[]> {
return this.dataSource.query<any>(`SELECT * FROM conversations ORDER BY updatedAt DESC`);
}
async updateUnreadCount(id: string, count: number): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(`UPDATE conversations SET unreadCount = ? WHERE id = ?`, [count, id]);
});
}
async updateLastMessage(id: string, lastMessageId: string, lastSeq: number, updatedAt: string): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(
`UPDATE conversations SET lastMessageId = ?, lastSeq = ?, updatedAt = ? WHERE id = ?`,
[lastMessageId, lastSeq, updatedAt, id]
);
});
}
async delete(conversationId: string): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(`DELETE FROM messages WHERE conversationId = ?`, [conversationId]);
await this.dataSource.run(`DELETE FROM conversations WHERE id = ?`, [conversationId]);
});
}
async getCache(id: string): Promise<ConversationCacheData | null> {
const r = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM conversation_cache WHERE id = ?`, [String(id)]
);
return r?.data ? safeParseJson<ConversationCacheData>(r.data) : null;
}
async getListCache(): Promise<ConversationResponse[]> {
let rows = await this.dataSource.query<{ data: string }>(
`SELECT data FROM conversation_list_cache ORDER BY updatedAt DESC`
);
if (!rows.length) {
rows = await this.dataSource.query<{ data: string }>(
`SELECT data FROM conversation_cache ORDER BY updatedAt DESC`
);
}
const list = rows.map(r => safeParseJson<ConversationResponse>(r.data))
.filter((c): c is ConversationResponse => Boolean(c))
.filter(c => typeof c.id !== 'undefined' && typeof c.type !== 'undefined');
return list.sort((a, b) => {
const aPinned = a.is_pinned ? 1 : 0;
const bPinned = b.is_pinned ? 1 : 0;
if (aPinned !== bPinned) return bPinned - aPinned;
const aTime = new Date(a.last_message_at || a.updated_at || 0).getTime();
const bTime = new Date(b.last_message_at || b.updated_at || 0).getTime();
return bTime - aTime;
});
}
async saveCache(conv: ConversationCacheData): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
const updatedAt = ('updated_at' in conv && conv.updated_at) ? String(conv.updated_at) : new Date().toISOString();
await this.dataSource.run(
`INSERT OR REPLACE INTO conversation_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(conv.id), JSON.stringify(conv), updatedAt]
);
});
}
async saveListCache(convs: ConversationResponse[]): Promise<void> {
if (!convs?.length) return;
await this.dataSource.enqueueWrite(async () => {
for (const c of convs) {
await this.dataSource.run(
`INSERT OR REPLACE INTO conversation_list_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(c.id), JSON.stringify(c), c.updated_at || new Date().toISOString()]
);
}
});
}
async updateListEntry(id: string, updates: Partial<ConversationResponse>): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
const r = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM conversation_list_cache WHERE id = ?`, [String(id)]
);
if (!r?.data) return;
const conv = safeParseJson<ConversationResponse>(r.data);
if (!conv) return;
const updated = { ...conv, ...updates };
const updatedAt = updates.last_message_at || updated.last_message_at || new Date().toISOString();
await this.dataSource.run(
`UPDATE conversation_list_cache SET data = ?, updatedAt = ? WHERE id = ?`,
[JSON.stringify(updated), updatedAt, String(id)]
);
});
}
async updateCacheUnreadCount(id: string, count: number, myLastReadSeq?: number): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
const listRow = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM conversation_list_cache WHERE id = ?`, [String(id)]
);
if (listRow?.data) {
const conv = safeParseJson<ConversationResponse>(listRow.data);
if (conv) {
conv.unread_count = count;
if (typeof myLastReadSeq === 'number' && !Number.isNaN(myLastReadSeq)) conv.my_last_read_seq = myLastReadSeq;
await this.dataSource.run(
`UPDATE conversation_list_cache SET data = ? WHERE id = ?`,
[JSON.stringify(conv), String(id)]
);
}
}
const cacheRow = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM conversation_cache WHERE id = ?`, [String(id)]
);
if (cacheRow?.data) {
const conv = safeParseJson<Record<string, unknown>>(cacheRow.data);
if (conv) {
conv.unread_count = count;
if (typeof myLastReadSeq === 'number' && !Number.isNaN(myLastReadSeq)) conv.my_last_read_seq = myLastReadSeq;
await this.dataSource.run(
`UPDATE conversation_cache SET data = ? WHERE id = ?`,
[JSON.stringify(conv), String(id)]
);
}
}
});
}
async saveWithRelated(convs: ConversationResponse[], users?: any[], groups?: any[]): Promise<void> {
if (!convs?.length) return;
const now = new Date().toISOString();
await this.dataSource.enqueueWrite(async () => {
for (const c of convs) {
await this.dataSource.run(
`INSERT OR REPLACE INTO conversation_list_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(c.id), JSON.stringify(c), c.updated_at || now]
);
}
if (users?.length) {
for (const u of users) {
await this.dataSource.run(
`INSERT OR REPLACE INTO users (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(u.id), JSON.stringify(u), now]
);
}
}
if (groups?.length) {
for (const g of groups) {
await this.dataSource.run(
`INSERT OR REPLACE INTO groups (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(g.id), JSON.stringify(g), now]
);
}
}
});
}
}
export const conversationRepository = new ConversationRepository();

View File

@@ -0,0 +1,78 @@
import { localDataSource } from '../LocalDataSource';
import type { ILocalDataSource } from '@/data/datasources/interfaces';
import type { GroupResponse, GroupMemberResponse } from '@/types/dto';
const safeParseJson = <T>(value: string): T | null => {
try { return JSON.parse(value) as T; } catch { return null; }
};
export interface IGroupCacheRepository {
save(group: GroupResponse): Promise<void>;
saveBatch(groups: GroupResponse[]): Promise<void>;
get(groupId: string): Promise<GroupResponse | null>;
getAll(): Promise<GroupResponse[]>;
saveMembers(groupId: string, members: GroupMemberResponse[]): Promise<void>;
getMembers(groupId: string): Promise<GroupMemberResponse[]>;
}
export class GroupCacheRepository implements IGroupCacheRepository {
constructor(private dataSource: ILocalDataSource = localDataSource) {}
async save(group: GroupResponse): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(
`INSERT OR REPLACE INTO groups (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(group.id), JSON.stringify(group), new Date().toISOString()]
);
});
}
async saveBatch(groups: GroupResponse[]): Promise<void> {
if (!groups?.length) return;
await this.dataSource.enqueueWrite(async () => {
const now = new Date().toISOString();
for (const g of groups) {
await this.dataSource.run(
`INSERT OR REPLACE INTO groups (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(g.id), JSON.stringify(g), now]
);
}
});
}
async get(groupId: string): Promise<GroupResponse | null> {
const r = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM groups WHERE id = ?`, [String(groupId)]
);
return r?.data ? safeParseJson<GroupResponse>(r.data) : null;
}
async getAll(): Promise<GroupResponse[]> {
const rows = await this.dataSource.query<{ data: string }>(
`SELECT data FROM groups ORDER BY updatedAt DESC`
);
return rows.map(r => safeParseJson<GroupResponse>(r.data)).filter((g): g is GroupResponse => Boolean(g));
}
async saveMembers(groupId: string, members: GroupMemberResponse[]): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
const now = new Date().toISOString();
await this.dataSource.run(`DELETE FROM group_members WHERE groupId = ?`, [String(groupId)]);
for (const m of members) {
await this.dataSource.run(
`INSERT OR REPLACE INTO group_members (groupId, userId, data, updatedAt) VALUES (?, ?, ?, ?)`,
[String(groupId), String(m.user_id), JSON.stringify(m), now]
);
}
});
}
async getMembers(groupId: string): Promise<GroupMemberResponse[]> {
const rows = await this.dataSource.query<{ data: string }>(
`SELECT data FROM group_members WHERE groupId = ? ORDER BY updatedAt DESC`, [String(groupId)]
);
return rows.map(r => safeParseJson<GroupMemberResponse>(r.data)).filter((m): m is GroupMemberResponse => Boolean(m));
}
}
export const groupCacheRepository = new GroupCacheRepository();

View File

@@ -0,0 +1,152 @@
import { localDataSource } from '../LocalDataSource';
import { CachedMessage } from '../types';
import type { ILocalDataSource } from '@/data/datasources/interfaces';
export interface IMessageRepository {
saveMessage(message: CachedMessage): Promise<void>;
saveMessagesBatch(messages: CachedMessage[]): Promise<void>;
getByConversation(conversationId: string, limit?: number): Promise<CachedMessage[]>;
getMaxSeq(conversationId: string): Promise<number>;
getMinSeq(conversationId: string): Promise<number>;
getBeforeSeq(conversationId: string, beforeSeq: number, limit?: number): Promise<CachedMessage[]>;
getCount(conversationId: string): Promise<number>;
getCountBeforeSeq(conversationId: string, beforeSeq: number): Promise<number>;
markAsRead(messageId: string): Promise<void>;
markConversationAsRead(conversationId: string): Promise<void>;
delete(messageId: string): Promise<void>;
updateStatus(messageId: string, status: string, clearContent?: boolean): Promise<void>;
clearConversation(conversationId: string): Promise<void>;
search(keyword: string): Promise<CachedMessage[]>;
getStats(): Promise<{ totalMessages: number; totalConversations: number }>;
}
export class MessageRepository implements IMessageRepository {
constructor(private dataSource: ILocalDataSource = localDataSource) {}
async saveMessage(message: CachedMessage): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(
`INSERT OR REPLACE INTO messages (id, conversationId, senderId, content, type, isRead, createdAt, seq, status, segments)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[message.id, message.conversationId, message.senderId, message.content || '',
message.type || 'text', message.isRead ? 1 : 0, message.createdAt,
message.seq || 0, message.status || 'normal',
message.segments ? JSON.stringify(message.segments) : null]
);
});
}
async saveMessagesBatch(messages: CachedMessage[]): Promise<void> {
if (!messages?.length) return;
await this.dataSource.enqueueWrite(async () => {
for (const msg of messages) {
await this.dataSource.run(
`INSERT OR REPLACE INTO messages (id, conversationId, senderId, content, type, isRead, createdAt, seq, status, segments)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[msg.id, msg.conversationId, msg.senderId, msg.content || '',
msg.type || 'text', msg.isRead ? 1 : 0, msg.createdAt,
msg.seq || 0, msg.status || 'normal',
msg.segments ? JSON.stringify(msg.segments) : null]
);
}
});
}
async getByConversation(conversationId: string, limit: number = 20): Promise<CachedMessage[]> {
const rows = await this.dataSource.query<any>(
`SELECT * FROM messages WHERE conversationId = ? ORDER BY seq DESC LIMIT ?`,
[conversationId, limit]
);
return rows.reverse().map(r => ({
...r, isRead: r.isRead === 1, segments: r.segments ? JSON.parse(r.segments) : undefined
}));
}
async getMaxSeq(conversationId: string): Promise<number> {
const r = await this.dataSource.getFirst<{ maxSeq: number }>(
`SELECT MAX(seq) as maxSeq FROM messages WHERE conversationId = ?`, [conversationId]
);
return r?.maxSeq || 0;
}
async getMinSeq(conversationId: string): Promise<number> {
const r = await this.dataSource.getFirst<{ minSeq: number }>(
`SELECT MIN(seq) as minSeq FROM messages WHERE conversationId = ?`, [conversationId]
);
return r?.minSeq || 0;
}
async getBeforeSeq(conversationId: string, beforeSeq: number, limit: number = 20): Promise<CachedMessage[]> {
const rows = await this.dataSource.query<any>(
`SELECT * FROM messages WHERE conversationId = ? AND seq < ? ORDER BY seq DESC LIMIT ?`,
[conversationId, beforeSeq, limit]
);
return rows.map(r => ({ ...r, isRead: r.isRead === 1, segments: r.segments ? JSON.parse(r.segments) : undefined })).reverse();
}
async getCount(conversationId: string): Promise<number> {
const r = await this.dataSource.getFirst<{ count: number }>(
`SELECT COUNT(*) as count FROM messages WHERE conversationId = ?`, [conversationId]
);
return r?.count || 0;
}
async getCountBeforeSeq(conversationId: string, beforeSeq: number): Promise<number> {
const r = await this.dataSource.getFirst<{ count: number }>(
`SELECT COUNT(*) as count FROM messages WHERE conversationId = ? AND seq < ?`, [conversationId, beforeSeq]
);
return r?.count || 0;
}
async markAsRead(messageId: string): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(`UPDATE messages SET isRead = 1 WHERE id = ?`, [messageId]);
});
}
async markConversationAsRead(conversationId: string): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(`UPDATE messages SET isRead = 1 WHERE conversationId = ?`, [conversationId]);
});
}
async delete(messageId: string): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(`DELETE FROM messages WHERE id = ?`, [messageId]);
});
}
async updateStatus(messageId: string, status: string, clearContent: boolean = false): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
if (clearContent) {
await this.dataSource.run(
`UPDATE messages SET status = ?, content = '', segments = '[]' WHERE id = ?`,
[status, messageId]
);
} else {
await this.dataSource.run(`UPDATE messages SET status = ? WHERE id = ?`, [status, messageId]);
}
});
}
async clearConversation(conversationId: string): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(`DELETE FROM messages WHERE conversationId = ?`, [conversationId]);
});
}
async search(keyword: string): Promise<CachedMessage[]> {
const rows = await this.dataSource.query<any>(
`SELECT * FROM messages WHERE content LIKE ? ORDER BY createdAt DESC LIMIT 50`, [`%${keyword}%`]
);
return rows.map(r => ({ ...r, isRead: r.isRead === 1, segments: r.segments ? JSON.parse(r.segments) : undefined }));
}
async getStats(): Promise<{ totalMessages: number; totalConversations: number }> {
const msgCount = await this.dataSource.getFirst<{ count: number }>(`SELECT COUNT(*) as count FROM messages`);
const convCount = await this.dataSource.getFirst<{ count: number }>(`SELECT COUNT(*) as count FROM conversations`);
return { totalMessages: msgCount?.count || 0, totalConversations: convCount?.count || 0 };
}
}
export const messageRepository = new MessageRepository();

View File

@@ -0,0 +1,44 @@
import { localDataSource } from '../LocalDataSource';
import type { ILocalDataSource } from '@/data/datasources/interfaces';
export interface IPostCacheRepository {
save(id: string, data: any): Promise<void>;
get(id: string): Promise<any | null>;
getAll(): Promise<any[]>;
delete(id: string): Promise<void>;
}
export class PostCacheRepository implements IPostCacheRepository {
constructor(private dataSource: ILocalDataSource = localDataSource) {}
async save(id: string, data: any): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(
`INSERT OR REPLACE INTO posts_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(id), JSON.stringify(data), new Date().toISOString()]
);
});
}
async get(id: string): Promise<any | null> {
const r = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM posts_cache WHERE id = ?`, [String(id)]
);
return r?.data ? JSON.parse(r.data) : null;
}
async getAll(): Promise<any[]> {
const rows = await this.dataSource.query<{ data: string }>(
`SELECT data FROM posts_cache ORDER BY updatedAt DESC`
);
return rows.map(r => JSON.parse(r.data));
}
async delete(id: string): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(`DELETE FROM posts_cache WHERE id = ?`, [String(id)]);
});
}
}
export const postCacheRepository = new PostCacheRepository();

View File

@@ -0,0 +1,81 @@
import { localDataSource } from '../LocalDataSource';
import type { ILocalDataSource } from '@/data/datasources/interfaces';
import type { UserDTO } from '@/types/dto';
const safeParseJson = <T>(value: string): T | null => {
try { return JSON.parse(value) as T; } catch { return null; }
};
export interface IUserCacheRepository {
save(user: UserDTO): Promise<void>;
saveBatch(users: UserDTO[]): Promise<void>;
get(userId: string): Promise<UserDTO | null>;
getLatest(): Promise<UserDTO | null>;
saveCurrent(user: UserDTO): Promise<void>;
getCurrent(): Promise<UserDTO | null>;
clearCurrent(): Promise<void>;
}
export class UserCacheRepository implements IUserCacheRepository {
constructor(private dataSource: ILocalDataSource = localDataSource) {}
async save(user: UserDTO): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(
`INSERT OR REPLACE INTO users (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(user.id), JSON.stringify(user), new Date().toISOString()]
);
});
}
async saveBatch(users: UserDTO[]): Promise<void> {
if (!users?.length) return;
await this.dataSource.enqueueWrite(async () => {
const now = new Date().toISOString();
for (const u of users) {
await this.dataSource.run(
`INSERT OR REPLACE INTO users (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(u.id), JSON.stringify(u), now]
);
}
});
}
async get(userId: string): Promise<UserDTO | null> {
const r = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM users WHERE id = ?`, [String(userId)]
);
return r?.data ? safeParseJson<UserDTO>(r.data) : null;
}
async getLatest(): Promise<UserDTO | null> {
const r = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM users ORDER BY updatedAt DESC LIMIT 1`
);
return r?.data ? safeParseJson<UserDTO>(r.data) : null;
}
async saveCurrent(user: UserDTO): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(
`INSERT OR REPLACE INTO current_user_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
['me', JSON.stringify(user), new Date().toISOString()]
);
});
}
async getCurrent(): Promise<UserDTO | null> {
const r = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM current_user_cache WHERE id = 'me'`
);
return r?.data ? safeParseJson<UserDTO>(r.data) : null;
}
async clearCurrent(): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(`DELETE FROM current_user_cache WHERE id = 'me'`);
});
}
}
export const userCacheRepository = new UserCacheRepository();

View File

@@ -0,0 +1,5 @@
export { IMessageRepository, MessageRepository, messageRepository } from './MessageRepository';
export { IConversationRepository, ConversationRepository, conversationRepository } from './ConversationRepository';
export { IUserCacheRepository, UserCacheRepository, userCacheRepository } from './UserCacheRepository';
export { IGroupCacheRepository, GroupCacheRepository, groupCacheRepository } from './GroupCacheRepository';
export { IPostCacheRepository, PostCacheRepository, postCacheRepository } from './PostCacheRepository';

View File

@@ -0,0 +1,40 @@
import type { SQLiteDatabase } from 'expo-sqlite';
export const CONVERSATIONS_TABLE = 'conversations';
export const CONVERSATION_CACHE_TABLE = 'conversation_cache';
export const CONVERSATION_LIST_CACHE_TABLE = 'conversation_list_cache';
export const CREATE_CONVERSATIONS_TABLE = `CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY NOT NULL,
participantId TEXT NOT NULL,
lastMessageId TEXT,
lastSeq INTEGER DEFAULT 0,
unreadCount INTEGER DEFAULT 0,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`;
export const CREATE_CONVERSATION_CACHE_TABLE = `CREATE TABLE IF NOT EXISTS conversation_cache (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`;
export const CREATE_CONVERSATION_LIST_CACHE_TABLE = `CREATE TABLE IF NOT EXISTS conversation_list_cache (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`;
export const MIGRATE_CONVERSATIONS_TABLE = async (db: SQLiteDatabase): Promise<void> => {
try {
const tableInfo = await db.getAllAsync<{ name: string }>(`PRAGMA table_info(conversations)`);
const columns = tableInfo.map(col => col.name);
if (!columns.includes('lastSeq')) {
await db.execAsync(`ALTER TABLE conversations ADD COLUMN lastSeq INTEGER DEFAULT 0`);
}
} catch (error) {
console.error('[ConversationSchema] 迁移失败:', error);
}
};

View File

@@ -0,0 +1,20 @@
export const GROUPS_TABLE = 'groups';
export const GROUP_MEMBERS_TABLE = 'group_members';
export const CREATE_GROUPS_TABLE = `CREATE TABLE IF NOT EXISTS groups (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`;
export const CREATE_GROUP_MEMBERS_TABLE = `CREATE TABLE IF NOT EXISTS group_members (
groupId TEXT NOT NULL,
userId TEXT NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL,
PRIMARY KEY (groupId, userId)
)`;
export const CREATE_GROUP_INDEXES = [
`CREATE INDEX IF NOT EXISTS idx_group_members_groupId ON group_members(groupId)`,
];

View File

@@ -0,0 +1,41 @@
import type { SQLiteDatabase } from 'expo-sqlite';
export const MESSAGES_TABLE = 'messages';
export const CREATE_MESSAGES_TABLE = `CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY NOT NULL,
conversationId TEXT NOT NULL,
senderId TEXT NOT NULL,
content TEXT,
type TEXT DEFAULT 'text',
isRead INTEGER DEFAULT 0,
createdAt TEXT NOT NULL,
seq INTEGER DEFAULT 0,
status TEXT DEFAULT 'normal',
segments TEXT
)`;
export const CREATE_MESSAGES_INDEXES = [
`CREATE INDEX IF NOT EXISTS idx_messages_conversationId ON messages(conversationId)`,
`CREATE INDEX IF NOT EXISTS idx_messages_seq ON messages(seq)`,
`CREATE INDEX IF NOT EXISTS idx_messages_conversation_seq ON messages(conversationId, seq)`,
];
export const MIGRATE_MESSAGES_TABLE = async (db: SQLiteDatabase): Promise<void> => {
try {
const tableInfo = await db.getAllAsync<{ name: string }>(`PRAGMA table_info(messages)`);
const columns = tableInfo.map(col => col.name);
if (!columns.includes('seq')) {
await db.execAsync(`ALTER TABLE messages ADD COLUMN seq INTEGER DEFAULT 0`);
}
if (!columns.includes('status')) {
await db.execAsync(`ALTER TABLE messages ADD COLUMN status TEXT DEFAULT 'normal'`);
}
if (!columns.includes('segments')) {
await db.execAsync(`ALTER TABLE messages ADD COLUMN segments TEXT`);
}
} catch (error) {
console.error('[MessageSchema] 迁移失败:', error);
}
};

View File

@@ -0,0 +1,57 @@
import type { SQLiteDatabase } from 'expo-sqlite';
import {
CREATE_MESSAGES_TABLE,
CREATE_MESSAGES_INDEXES,
MIGRATE_MESSAGES_TABLE,
} from './MessageSchema';
import {
CREATE_CONVERSATIONS_TABLE,
CREATE_CONVERSATION_CACHE_TABLE,
CREATE_CONVERSATION_LIST_CACHE_TABLE,
MIGRATE_CONVERSATIONS_TABLE,
} from './ConversationSchema';
import {
CREATE_USERS_TABLE,
CREATE_CURRENT_USER_CACHE_TABLE,
} from './UserSchema';
import {
CREATE_GROUPS_TABLE,
CREATE_GROUP_MEMBERS_TABLE,
CREATE_GROUP_INDEXES,
} from './GroupSchema';
import {
CREATE_POSTS_CACHE_TABLE,
} from './PostSchema';
export async function runMigrations(db: SQLiteDatabase): Promise<void> {
if (typeof window !== 'undefined') {
try {
await db.execAsync(`PRAGMA locking_mode = EXCLUSIVE;`);
await db.execAsync(`PRAGMA journal_mode = MEMORY;`);
await db.execAsync(`PRAGMA temp_store = MEMORY;`);
console.log('[MigrationManager] Web: 已设置 EXCLUSIVE locking + MEMORY journal/temp 模式');
} catch (e) {
console.warn('[MigrationManager] 设置 PRAGMA 失败:', e);
}
}
await db.execAsync(CREATE_MESSAGES_TABLE);
await db.execAsync(CREATE_CONVERSATIONS_TABLE);
await db.execAsync(CREATE_CONVERSATION_CACHE_TABLE);
await db.execAsync(CREATE_CONVERSATION_LIST_CACHE_TABLE);
await db.execAsync(CREATE_USERS_TABLE);
await db.execAsync(CREATE_CURRENT_USER_CACHE_TABLE);
await db.execAsync(CREATE_GROUPS_TABLE);
await db.execAsync(CREATE_GROUP_MEMBERS_TABLE);
await db.execAsync(CREATE_POSTS_CACHE_TABLE);
for (const indexSql of CREATE_MESSAGES_INDEXES) {
await db.execAsync(indexSql);
}
for (const indexSql of CREATE_GROUP_INDEXES) {
await db.execAsync(indexSql);
}
await MIGRATE_MESSAGES_TABLE(db);
await MIGRATE_CONVERSATIONS_TABLE(db);
}

View File

@@ -0,0 +1,9 @@
export const POSTS_CACHE_TABLE = 'posts_cache';
export const CREATE_POSTS_CACHE_TABLE = `CREATE TABLE IF NOT EXISTS posts_cache (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`;
export const CREATE_POSTS_INDEXES: string[] = [];

View File

@@ -0,0 +1,16 @@
export const USERS_TABLE = 'users';
export const CURRENT_USER_CACHE_TABLE = 'current_user_cache';
export const CREATE_USERS_TABLE = `CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`;
export const CREATE_CURRENT_USER_CACHE_TABLE = `CREATE TABLE IF NOT EXISTS current_user_cache (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`;
export const CREATE_USER_INDEXES: string[] = [];

View File

@@ -0,0 +1,6 @@
export * from './MessageSchema';
export * from './ConversationSchema';
export * from './UserSchema';
export * from './GroupSchema';
export * from './PostSchema';
export { runMigrations } from './MigrationManager';

View File

@@ -0,0 +1,14 @@
export interface CachedMessage {
id: string;
conversationId: string;
senderId: string;
content?: string;
type?: string;
isRead: boolean;
createdAt: string;
seq: number;
status: string;
segments?: any;
}
export { CachedMessage as default };

View File

@@ -0,0 +1 @@
export { CachedMessage } from './CachedMessage';

View File

@@ -177,3 +177,17 @@ export function hrefMaterialSubject(subjectId: string): string {
export function hrefMaterialDetail(materialId: string): string {
return `/apps/materials/detail?materialId=${encodeURIComponent(materialId)}`;
}
// ==================== Profile Settings (设置相关) ====================
export function hrefProfileAbout(): string {
return '/profile/about';
}
export function hrefProfileTerms(): string {
return '/profile/terms';
}
export function hrefProfilePrivacy(): string {
return '/profile/privacy';
}

View File

@@ -237,6 +237,16 @@ export const LoginScreen: React.FC = () => {
<Text style={styles.registerLink}></Text>
</TouchableOpacity>
</View>
{/* 协议提示 */}
<View style={styles.policyFooter}>
<Text style={styles.policyText}>
<Text style={styles.policyLink} onPress={() => router.push(hrefs.hrefProfileTerms())}></Text>
<Text style={styles.policyLink} onPress={() => router.push(hrefs.hrefProfilePrivacy())}></Text>
</Text>
</View>
</Animated.View>
</ScrollView>
</KeyboardAvoidingView>
@@ -376,6 +386,18 @@ function createLoginStyles(colors: AppColors) {
fontWeight: '600',
marginLeft: 6,
},
policyFooter: {
alignItems: 'center',
marginTop: 24,
},
policyText: {
fontSize: 13,
color: colors.text.hint,
},
policyLink: {
color: THEME_COLORS.primary,
fontWeight: '500',
},
});
}

View File

@@ -12,9 +12,11 @@ import {
TouchableOpacity,
ActivityIndicator,
} from 'react-native';
import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useAppColors, type AppColors } from '../../theme';
import { RegisterStepProps } from './types';
import * as hrefs from '../../navigation/hrefs';
const THEME_COLORS = {
primary: '#FF6B35',
@@ -32,6 +34,7 @@ export const RegisterStep3Profile: React.FC<RegisterStepProps> = ({
}) => {
const colors = propColors || useAppColors();
const styles = createStyles(colors);
const router = useRouter();
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
@@ -185,28 +188,40 @@ export const RegisterStep3Profile: React.FC<RegisterStepProps> = ({
})}
{/* 服务条款 */}
<TouchableOpacity
style={styles.termsContainer}
onPress={() => {
setAgreedToTerms(!agreedToTerms);
if (errors.terms) {
setErrors((prev) => ({ ...prev, terms: '' }));
}
}}
activeOpacity={0.8}
>
<MaterialCommunityIcons
name={agreedToTerms ? 'checkbox-marked' : 'checkbox-blank-outline'}
size={22}
color={agreedToTerms ? THEME_COLORS.primary : colors.text?.hint || '#999'}
/>
<View style={styles.termsContainer}>
<TouchableOpacity
onPress={() => {
setAgreedToTerms(!agreedToTerms);
if (errors.terms) {
setErrors((prev) => ({ ...prev, terms: '' }));
}
}}
activeOpacity={0.8}
style={styles.checkboxWrapper}
>
<MaterialCommunityIcons
name={agreedToTerms ? 'checkbox-marked' : 'checkbox-blank-outline'}
size={22}
color={agreedToTerms ? THEME_COLORS.primary : colors.text?.hint || '#999'}
/>
</TouchableOpacity>
<Text style={styles.termsText}>
<Text style={styles.termsLink}></Text>
<Text
style={styles.termsLink}
onPress={() => router.push(hrefs.hrefProfileTerms())}
>
</Text>
<Text style={styles.termsLink}></Text>
<Text
style={styles.termsLink}
onPress={() => router.push(hrefs.hrefProfilePrivacy())}
>
</Text>
</Text>
</TouchableOpacity>
</View>
{errors.terms ? (
<Text style={[styles.errorText, { marginTop: -12, marginBottom: 12 }]}>
{errors.terms}
@@ -295,10 +310,13 @@ function createStyles(colors: AppColors) {
},
termsContainer: {
flexDirection: 'row',
alignItems: 'center',
alignItems: 'flex-start',
marginBottom: 20,
marginTop: 4,
},
checkboxWrapper: {
paddingTop: 2,
},
termsText: {
fontSize: 14,
color: colors.text?.secondary || '#666',

View File

@@ -203,6 +203,13 @@ export const HomeScreen: React.FC = () => {
// 发帖弹窗状态
const [showCreatePost, setShowCreatePost] = useState(false);
useEffect(() => {
if (showCreatePost && Platform.OS === 'web') {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
active?.blur?.();
}
}, [showCreatePost]);
// 用于跟踪当前页面显示的帖子 ID以便从 store 同步状态
const postIdsRef = useRef<Set<string>>(new Set());
@@ -599,7 +606,7 @@ export const HomeScreen: React.FC = () => {
} catch (shareError) {
console.error('上报分享次数失败:', shareError);
}
const postUrl = `https://browser.littlelan.cn/posts/${encodeURIComponent(post.id)}`;
const postUrl = `https://browser.littlelan.cn/post/${encodeURIComponent(post.id)}`;
Clipboard.setString(postUrl);
Alert.alert('已复制', '帖子链接已复制到剪贴板');
};

View File

@@ -536,7 +536,7 @@ export const PostDetailScreen: React.FC = () => {
} catch (error) {
console.error('上报分享次数失败:', error);
}
const postUrl = `https://browser.littlelan.cn/posts/${encodeURIComponent(post.id)}`;
const postUrl = `https://browser.littlelan.cn/post/${encodeURIComponent(post.id)}`;
Clipboard.setString(postUrl);
Alert.alert('已复制', '帖子链接已复制到剪贴板');
}, [post?.id]);

View File

@@ -4,7 +4,7 @@
* 支持响应式布局
*/
import React, { useState, useMemo } from 'react';
import React, { useState, useMemo, useEffect } from 'react';
import {
View,
StyleSheet,
@@ -14,6 +14,7 @@ import {
TextInput,
FlatList,
Image,
Platform,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
@@ -45,6 +46,13 @@ const CreateGroupScreen: React.FC = () => {
// 邀请成员模态框状态
const [inviteModalVisible, setInviteModalVisible] = useState(false);
useEffect(() => {
if (inviteModalVisible && Platform.OS === 'web') {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
active?.blur?.();
}
}, [inviteModalVisible]);
// 移除已选成员
const removeSelectedMember = (userId: string) => {
const newSelectedIds = new Set(selectedMemberIds);

View File

@@ -118,6 +118,13 @@ const GroupInfoScreen: React.FC = () => {
const [inviteModalVisible, setInviteModalVisible] = useState(false);
const [inviting, setInviting] = useState(false);
useEffect(() => {
if ((editModalVisible || announcementModalVisible || transferModalVisible || joinTypeModalVisible || inviteModalVisible) && Platform.OS === 'web') {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
active?.blur?.();
}
}, [editModalVisible, announcementModalVisible, transferModalVisible, joinTypeModalVisible, inviteModalVisible]);
// 计算当前用户的角色
const isOwner = currentMember?.role === 'owner';
const isAdmin = currentMember?.role === 'admin' || isOwner;

View File

@@ -17,6 +17,7 @@ import {
Modal,
TextInput,
Dimensions,
Platform,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLocalSearchParams, useRouter } from 'expo-router';
@@ -131,6 +132,13 @@ const GroupMembersScreen: React.FC = () => {
const [nicknameModalVisible, setNicknameModalVisible] = useState(false);
const [newNickname, setNewNickname] = useState('');
useEffect(() => {
if ((actionModalVisible || nicknameModalVisible) && Platform.OS === 'web') {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
active?.blur?.();
}
}, [actionModalVisible, nicknameModalVisible]);
// 计算当前用户的角色
const isOwner = currentMember?.role === 'owner';
const isAdmin = currentMember?.role === 'admin' || isOwner;

View File

@@ -136,6 +136,13 @@ export const MessageListScreen: React.FC = () => {
const [isSearching, setIsSearching] = useState(false);
const [activeSearchTab, setActiveSearchTab] = useState<'chat' | 'user'>('chat');
const [actionMenuVisible, setActionMenuVisible] = useState(false);
useEffect(() => {
if (actionMenuVisible && Platform.OS === 'web') {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
active?.blur?.();
}
}, [actionMenuVisible]);
const [scannerVisible, setScannerVisible] = useState(false);
// 系统通知显示状态 - 用于在移动端显示通知页面

View File

@@ -1,8 +1,14 @@
/**
* 通知页 NotificationsScreen
* 通知页 NotificationsScreen(扁平化风格)
* 胡萝卜BBS - 系统消息列表
* 【游标分页】使用 messageService.getSystemMessagesCursor
* 支持响应式布局
*
* 设计风格:
* - 纯白背景,扁平化设计
* - 简洁的标题区域
* - 分段式筛选标签
* - 与登录、注册、设置页面风格一致
*/
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
@@ -15,17 +21,19 @@ import {
ActivityIndicator,
Dimensions,
BackHandler,
Animated,
StatusBar,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useIsFocused } from '@react-navigation/native';
import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { spacing, borderRadius, shadows, useAppColors, type AppColors } from '../../theme';
import { spacing, borderRadius, useAppColors, type AppColors } from '../../theme';
import { SystemMessageResponse } from '../../types/dto';
import { messageService } from '../../services/messageService';
import { commentService } from '../../services/commentService';
import { SystemMessageItem } from '../../components/business';
import { Text, EmptyState, ResponsiveContainer, AppBackButton } from '../../components/common';
import { Text, ResponsiveContainer } from '../../components/common';
import { useCursorPagination } from '../../hooks/useCursorPagination';
import * as hrefs from '../../navigation/hrefs';
import { useMessageManagerSystemUnreadCount } from '../../stores';
@@ -41,6 +49,13 @@ const MESSAGE_TYPES = [
{ key: 'announcement', title: '公告' },
];
// 胡萝卜橙主题色
const THEME_COLORS = {
primary: '#FF6B35',
primaryLight: '#FF8C5A',
primaryDark: '#E55A2B',
};
const LIKE_SYSTEM_TYPES = new Set(['like_post', 'like_comment', 'like_reply', 'favorite_post']);
const GROUP_SYSTEM_TYPES = new Set(['group_invite', 'group_join_apply', 'group_join_approved', 'group_join_rejected']);
@@ -51,6 +66,10 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
const { setSystemUnreadCount, decrementSystemUnreadCount } = useMessageManagerSystemUnreadCount();
const router = useRouter();
// 动画值
const fadeAnim = useRef(new Animated.Value(0)).current;
const slideAnim = useRef(new Animated.Value(20)).current;
// 修复竞态条件:使用本地状态管理窗口尺寸,避免 hydration 问题
const [windowSize, setWindowSize] = useState({ width: 0, height: 0 });
const [isHydrated, setIsHydrated] = useState(false);
@@ -177,6 +196,22 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
}
}, [isFocused]); // 只依赖 isFocused函数使用 ref 稳定引用
// 启动入场动画
useEffect(() => {
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 400,
useNativeDriver: true,
}),
Animated.timing(slideAnim, {
toValue: 0,
duration: 400,
useNativeDriver: true,
}),
]).start();
}, []);
// 屏幕失去焦点时,如果有 onBack 回调则调用它(用于内嵌模式)
useEffect(() => {
if (!isFocused && onBack) {
@@ -343,7 +378,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
);
};
// 渲染头部(包含返回按钮)
// 渲染头部(包含返回按钮)- 扁平化风格
const renderHeader = () => {
// 大屏模式(>= lg 断点)隐藏返回按钮
const shouldShowBackButton = onBack && !isWideScreen;
@@ -351,7 +386,11 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
return (
<View style={[styles.header, isWideScreen ? styles.headerWide : null]}>
{shouldShowBackButton ? (
<AppBackButton style={styles.backButton} onPress={onBack} />
<TouchableOpacity style={styles.backButton} onPress={onBack} activeOpacity={0.8}>
<View style={styles.backIconButton}>
<MaterialCommunityIcons name="arrow-left" size={22} color={colors.text.primary} />
</View>
</TouchableOpacity>
) : (
<View style={styles.backButtonPlaceholder} />
)}
@@ -370,14 +409,14 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
);
};
// 渲染空状态
// 渲染空状态 - 扁平化风格
const renderEmpty = () => (
<View style={[styles.emptyContainer, isWideScreen && styles.emptyContainerWeb]}>
<EmptyState
title="暂无通知"
description="关注一些用户来获取通知吧"
icon="bell-outline"
/>
<View style={styles.emptyIconContainer}>
<MaterialCommunityIcons name="bell-outline" size={64} color={colors.text.hint} />
</View>
<Text style={styles.emptyTitle}></Text>
<Text style={styles.emptyDescription}></Text>
</View>
);
@@ -385,8 +424,9 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
if (!isHydrated) {
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<StatusBar barStyle="dark-content" backgroundColor="#fff" />
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary.main} />
<ActivityIndicator size="large" color={THEME_COLORS.primary} />
</View>
</SafeAreaView>
);
@@ -394,164 +434,175 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
{isWideScreen ? (
<ResponsiveContainer maxWidth={containerMaxWidth}>
{/* 头部 - 大屏模式下隐藏返回按钮 */}
{renderHeader()}
{/* 分类筛选 */}
<View style={[styles.filterContainer, isWideScreen && styles.filterContainerWideWeb]}>
{MESSAGE_TYPES.map(type => {
const count = type.key === 'all'
? displayMessages.length
: displayMessages.filter((m) => {
if (type.key === 'like_post') {
return LIKE_SYSTEM_TYPES.has(m.system_type);
}
if (type.key === 'group') {
return GROUP_SYSTEM_TYPES.has(m.system_type);
}
return m.system_type === type.key;
}).length;
const isActive = activeType === type.key;
return (
<TouchableOpacity
key={type.key}
style={[
styles.filterTag,
isActive && styles.filterTagActive,
isWideScreen && styles.filterTagWide,
]}
onPress={() => setActiveType(type.key)}
activeOpacity={0.8}
>
<Text
variant="body"
color={isActive ? colors.text.inverse : colors.text.secondary}
style={isActive ? styles.filterTagTextActive : undefined}
<StatusBar barStyle="dark-content" backgroundColor="#fff" />
<Animated.View
style={[
styles.content,
{
opacity: fadeAnim,
transform: [{ translateY: slideAnim }],
},
]}
>
{isWideScreen ? (
<ResponsiveContainer maxWidth={containerMaxWidth}>
{/* 头部 - 大屏模式下隐藏返回按钮 */}
{renderHeader()}
{/* 分类筛选 */}
<View style={[styles.filterContainer, isWideScreen && styles.filterContainerWideWeb]}>
{MESSAGE_TYPES.map(type => {
const count = type.key === 'all'
? displayMessages.length
: displayMessages.filter((m) => {
if (type.key === 'like_post') {
return LIKE_SYSTEM_TYPES.has(m.system_type);
}
if (type.key === 'group') {
return GROUP_SYSTEM_TYPES.has(m.system_type);
}
return m.system_type === type.key;
}).length;
const isActive = activeType === type.key;
return (
<TouchableOpacity
key={type.key}
style={[
styles.filterTag,
isActive && styles.filterTagActive,
isWideScreen && styles.filterTagWide,
]}
onPress={() => setActiveType(type.key)}
activeOpacity={0.8}
>
{type.title}
</Text>
{count > 0 && (
<View style={[styles.filterCount, isActive && styles.filterCountActive]}>
<Text
variant="caption"
color={isActive ? colors.text.inverse : colors.text.hint}
>
{count}
</Text>
</View>
)}
</TouchableOpacity>
);
})}
</View>
{/* 消息列表 */}
{isLoading && messages.length === 0 ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary.main} />
<Text
variant="body"
color={isActive ? colors.text.inverse : colors.text.secondary}
style={isActive ? styles.filterTagTextActive : undefined}
>
{type.title}
</Text>
{count > 0 && (
<View style={[styles.filterCount, isActive && styles.filterCountActive]}>
<Text
variant="caption"
color={isActive ? colors.text.inverse : colors.text.hint}
>
{count}
</Text>
</View>
)}
</TouchableOpacity>
);
})}
</View>
) : (
<FlatList
data={filteredMessages}
renderItem={renderMessage}
keyExtractor={item => item.id.toString()}
contentContainerStyle={[styles.listContent, isWideScreen && styles.listContentWide]}
showsVerticalScrollIndicator={false}
ListEmptyComponent={renderEmpty}
ListFooterComponent={renderFooter}
onEndReached={onEndReached}
onEndReachedThreshold={0.3}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
/>
)}
</ResponsiveContainer>
) : (
<>
{/* 头部 - 小屏模式下显示返回按钮 */}
{renderHeader()}
{/* 分类筛选 */}
<View style={styles.filterContainer}>
{MESSAGE_TYPES.map(type => {
const count = type.key === 'all'
? displayMessages.length
: displayMessages.filter((m) => {
if (type.key === 'like_post') {
return LIKE_SYSTEM_TYPES.has(m.system_type);
}
if (type.key === 'group') {
return GROUP_SYSTEM_TYPES.has(m.system_type);
}
return m.system_type === type.key;
}).length;
const isActive = activeType === type.key;
return (
<TouchableOpacity
key={type.key}
style={[
styles.filterTag,
isActive && styles.filterTagActive,
]}
onPress={() => setActiveType(type.key)}
activeOpacity={0.8}
>
<Text
variant="body"
color={isActive ? colors.text.inverse : colors.text.secondary}
style={isActive ? styles.filterTagTextActive : undefined}
{/* 消息列表 */}
{isLoading && messages.length === 0 ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={THEME_COLORS.primary} />
</View>
) : (
<FlatList
data={filteredMessages}
renderItem={renderMessage}
keyExtractor={item => item.id.toString()}
contentContainerStyle={[styles.listContent, isWideScreen && styles.listContentWide]}
showsVerticalScrollIndicator={false}
ListEmptyComponent={renderEmpty}
ListFooterComponent={renderFooter}
onEndReached={onEndReached}
onEndReachedThreshold={0.3}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={[THEME_COLORS.primary]}
tintColor={THEME_COLORS.primary}
/>
}
/>
)}
</ResponsiveContainer>
) : (
<>
{/* 头部 - 小屏模式下显示返回按钮 */}
{renderHeader()}
{/* 分类筛选 */}
<View style={styles.filterContainer}>
{MESSAGE_TYPES.map(type => {
const count = type.key === 'all'
? displayMessages.length
: displayMessages.filter((m) => {
if (type.key === 'like_post') {
return LIKE_SYSTEM_TYPES.has(m.system_type);
}
if (type.key === 'group') {
return GROUP_SYSTEM_TYPES.has(m.system_type);
}
return m.system_type === type.key;
}).length;
const isActive = activeType === type.key;
return (
<TouchableOpacity
key={type.key}
style={[
styles.filterTag,
isActive && styles.filterTagActive,
]}
onPress={() => setActiveType(type.key)}
activeOpacity={0.8}
>
{type.title}
</Text>
{count > 0 && (
<View style={[styles.filterCount, isActive && styles.filterCountActive]}>
<Text
variant="caption"
color={isActive ? colors.text.inverse : colors.text.hint}
>
{count}
</Text>
</View>
)}
</TouchableOpacity>
);
})}
</View>
{/* 消息列表 */}
{isLoading && messages.length === 0 ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary.main} />
<Text
variant="body"
color={isActive ? colors.text.inverse : colors.text.secondary}
style={isActive ? styles.filterTagTextActive : undefined}
>
{type.title}
</Text>
{count > 0 && (
<View style={[styles.filterCount, isActive && styles.filterCountActive]}>
<Text
variant="caption"
color={isActive ? colors.text.inverse : colors.text.hint}
>
{count}
</Text>
</View>
)}
</TouchableOpacity>
);
})}
</View>
) : (
<FlatList
data={filteredMessages}
renderItem={renderMessage}
keyExtractor={item => item.id.toString()}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
ListEmptyComponent={renderEmpty}
ListFooterComponent={renderFooter}
onEndReached={onEndReached}
onEndReachedThreshold={0.3}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
/>
)}
</>
)}
{/* 消息列表 */}
{isLoading && messages.length === 0 ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={THEME_COLORS.primary} />
</View>
) : (
<FlatList
data={filteredMessages}
renderItem={renderMessage}
keyExtractor={item => item.id.toString()}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
ListEmptyComponent={renderEmpty}
ListFooterComponent={renderFooter}
onEndReached={onEndReached}
onEndReachedThreshold={0.3}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={[THEME_COLORS.primary]}
tintColor={THEME_COLORS.primary}
/>
}
/>
)}
</>
)}
</Animated.View>
</SafeAreaView>
);
};
@@ -560,21 +611,23 @@ function createNotificationsStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
backgroundColor: '#fff',
},
// Header 样式 - 美化版
content: {
flex: 1,
},
// Header 样式 - 扁平化风格
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
backgroundColor: colors.background.paper,
...shadows.sm,
paddingHorizontal: 20,
paddingVertical: 16,
backgroundColor: '#fff',
},
headerWide: {
paddingHorizontal: spacing.xl,
paddingVertical: spacing.lg,
paddingHorizontal: 32,
paddingVertical: 20,
},
headerTitleContainer: {
flexDirection: 'row',
@@ -591,7 +644,7 @@ function createNotificationsStyles(colors: AppColors) {
fontSize: 20,
},
unreadBadge: {
backgroundColor: colors.primary.main,
backgroundColor: THEME_COLORS.primary,
borderRadius: 10,
paddingHorizontal: 6,
paddingVertical: 2,
@@ -601,7 +654,7 @@ function createNotificationsStyles(colors: AppColors) {
justifyContent: 'center',
},
unreadBadgeText: {
color: colors.text.inverse,
color: '#fff',
fontSize: 11,
fontWeight: '600',
},
@@ -609,50 +662,59 @@ function createNotificationsStyles(colors: AppColors) {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'flex-start',
alignItems: 'center',
},
backIconButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#F5F5F7',
justifyContent: 'center',
alignItems: 'center',
},
backButtonPlaceholder: {
width: 40,
},
// 分类筛选 - 美化版(使用分段式设计)
// 分类筛选 - 扁平化风格(分段式设计)
filterContainer: {
flexDirection: 'row',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
backgroundColor: colors.background.paper,
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: '#fff',
borderBottomWidth: 1,
borderBottomColor: colors.divider || '#E5E5EA',
},
filterContainerWideWeb: {
paddingHorizontal: spacing.xl,
paddingVertical: spacing.md,
paddingHorizontal: 32,
paddingVertical: 16,
justifyContent: 'center',
backgroundColor: colors.background.paper,
},
filterTag: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
marginRight: spacing.xs,
borderRadius: borderRadius.lg,
backgroundColor: colors.background.default,
paddingHorizontal: 14,
paddingVertical: 8,
marginRight: 8,
borderRadius: 14,
backgroundColor: '#F5F5F7',
borderWidth: 1,
borderColor: 'transparent',
},
filterTagWide: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
marginRight: spacing.sm,
paddingHorizontal: 18,
paddingVertical: 10,
marginRight: 10,
},
filterTagActive: {
backgroundColor: colors.primary.main,
borderColor: colors.primary.main,
backgroundColor: THEME_COLORS.primary,
borderColor: THEME_COLORS.primary,
},
filterTagTextActive: {
fontWeight: '600',
},
filterCount: {
marginLeft: spacing.xs,
backgroundColor: colors.primary.light + '40',
marginLeft: 6,
backgroundColor: THEME_COLORS.primaryLight + '40',
paddingHorizontal: 6,
paddingVertical: 1,
borderRadius: 8,
@@ -664,37 +726,59 @@ function createNotificationsStyles(colors: AppColors) {
},
listContent: {
flexGrow: 1,
paddingTop: spacing.md,
paddingBottom: spacing.lg,
paddingTop: 8,
paddingBottom: 24,
},
listContentWide: {
paddingHorizontal: spacing.xl,
paddingHorizontal: 32,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingVertical: spacing.xl * 2,
paddingVertical: 80,
},
loadingMore: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
paddingVertical: spacing.lg,
paddingVertical: 20,
},
loadingMoreText: {
marginLeft: spacing.sm,
marginLeft: 8,
},
// 空状态 - 扁平化风格
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingTop: spacing['3xl'],
paddingTop: 80,
paddingHorizontal: 40,
},
emptyContainerWeb: {
minHeight: 500,
justifyContent: 'center',
paddingTop: 100,
},
emptyIconContainer: {
width: 120,
height: 120,
borderRadius: 60,
backgroundColor: '#F5F5F7',
justifyContent: 'center',
alignItems: 'center',
marginBottom: 24,
},
emptyTitle: {
fontSize: 20,
fontWeight: '600',
color: colors.text.primary,
marginBottom: 8,
},
emptyDescription: {
fontSize: 15,
color: colors.text.secondary,
textAlign: 'center',
},
});
}

View File

@@ -21,10 +21,7 @@ import { useAuthStore } from '../../stores';
import { authService } from '../../services/authService';
import { messageService } from '../../services/messageService';
import { ApiError } from '../../services/api';
import {
clearConversationMessages,
getConversationCache,
} from '../../services/database';
import { messageRepository, conversationRepository } from '@/database';
import { messageManager } from '../../stores/messageManager';
import { userManager } from '../../stores/userManager';
import { Avatar, Text, Button, Loading, Divider } from '../../components/common';
@@ -81,7 +78,7 @@ const PrivateChatInfoScreen: React.FC = () => {
return;
}
const cached = await getConversationCache(conversationId);
const cached = await conversationRepository.getCache(conversationId);
if (cached) {
setIsPinned(Boolean((cached as any).is_pinned));
return;
@@ -137,7 +134,7 @@ const PrivateChatInfoScreen: React.FC = () => {
style: 'destructive',
onPress: async () => {
try {
await clearConversationMessages(conversationId);
await messageRepository.clearConversation(conversationId);
Alert.alert('成功', '聊天记录已清空');
} catch (error) {
Alert.alert('错误', '清空聊天记录失败');

View File

@@ -5,7 +5,7 @@
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { View, TouchableOpacity, ActivityIndicator, Modal, Alert, Dimensions, StyleSheet, FlatList, ListRenderItem, ScrollView } from 'react-native';
import { View, TouchableOpacity, ActivityIndicator, Modal, Alert, Dimensions, StyleSheet, FlatList, ListRenderItem, ScrollView, Platform } from 'react-native';
import { Image as ExpoImage } from 'expo-image';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker';
@@ -18,6 +18,12 @@ import { useAppColors, spacing } from '../../../../theme';
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
const blurActiveElementOnWeb = () => {
if (Platform.OS !== 'web') return;
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
active?.blur?.();
};
// 表情尺寸配置 - 固定宽度 40px使用 flex 布局
const EMOJI_SIZES = {
mobile: { size: 24, itemWidth: 40, itemHeight: 40 },
@@ -176,6 +182,7 @@ export const EmojiPanel: React.FC<EmojiPanelProps> = ({
// 打开管理界面
const handleOpenManage = () => {
blurActiveElementOnWeb();
setShowManageModal(true);
setManageMode(false);
setSelectedStickers(new Set());

View File

@@ -33,6 +33,7 @@ import {
} from '../../../../types/dto';
import { spacing, useAppColors, type AppColors } from '../../../../theme';
import { SenderInfo } from './types';
import { useChatSettingsStore } from '../../../../stores/chatSettingsStore';
const IMAGE_MAX_WIDTH = 200;
const IMAGE_MAX_HEIGHT = 260;
@@ -617,7 +618,8 @@ export const ReplyPreviewSegment: React.FC<ReplyPreviewSegmentProps> = ({
getSenderInfo,
}) => {
const themeColors = useAppColors();
const styles = useMemo(() => createSegmentStyles(themeColors), [themeColors]);
const fontSize = useChatSettingsStore((s) => s.fontSize);
const styles = useMemo(() => createSegmentStyles(themeColors, fontSize), [themeColors, fontSize]);
if (!replyMessage) {
// 如果没有引用消息详情只显示引用ID
@@ -729,7 +731,8 @@ export const MessageSegmentsRenderer: React.FC<{
getSenderInfo,
}) => {
const themeColors = useAppColors();
const segStyles = useMemo(() => createSegmentStyles(themeColors), [themeColors]);
const fontSize = useChatSettingsStore((s) => s.fontSize);
const segStyles = useMemo(() => createSegmentStyles(themeColors, fontSize), [themeColors, fontSize]);
const replySegment = segments.find(s => s.type === 'reply');
const otherSegments = segments.filter(s => s.type !== 'reply');
@@ -815,7 +818,7 @@ const formatDuration = (seconds: number): string => {
return mins > 0 ? `${mins}:${secs.toString().padStart(2, '0')}` : `0:${secs.toString().padStart(2, '0')}`;
};
function createSegmentStyles(colors: AppColors) {
function createSegmentStyles(colors: AppColors, fontSize: number = 16) {
return StyleSheet.create({
// 容器
segmentsContainer: {
@@ -847,10 +850,10 @@ function createSegmentStyles(colors: AppColors) {
marginTop: 2,
},
// 文本 - 适配暗色模式
// 文本 - 适配暗色模式,支持动态字号
textContent: {
fontSize: 17.5,
lineHeight: 25,
fontSize,
lineHeight: Math.round(fontSize * 1.43),
letterSpacing: 0.1,
fontWeight: '400',
},
@@ -861,10 +864,10 @@ function createSegmentStyles(colors: AppColors) {
color: colors.chat.textPrimary,
},
// @提及 - 微信/QQ 风格:更精致的高亮
// @提及 - 微信/QQ 风格:更精致的高亮,支持动态字号
atText: {
fontSize: 16,
lineHeight: 22,
fontSize,
lineHeight: Math.round(fontSize * 1.4),
fontWeight: '500',
},
atTextMe: {
@@ -904,7 +907,7 @@ function createSegmentStyles(colors: AppColors) {
height: 28,
},
faceText: {
fontSize: 16,
fontSize,
color: '#666',
},
@@ -930,7 +933,7 @@ function createSegmentStyles(colors: AppColors) {
},
voiceDuration: {
marginLeft: spacing.sm,
fontSize: 15,
fontSize: fontSize - 1,
fontWeight: '500',
},
@@ -1113,14 +1116,14 @@ function createSegmentStyles(colors: AppColors) {
minWidth: 0,
},
replySender: {
fontSize: 13,
fontSize: fontSize - 3,
fontWeight: '600',
color: colors.chat.link,
marginRight: 6,
flexShrink: 0,
},
replyText: {
fontSize: 13,
fontSize: fontSize - 3,
color: colors.chat.textTertiary,
flex: 1,
flexShrink: 1,

View File

@@ -42,10 +42,7 @@ import {
PendingChatAttachment,
} from './types';
import { TIME_SEPARATOR_INTERVAL, MEMBERS_PAGE_SIZE, MAX_PENDING_CHAT_IMAGES } from './constants';
import {
deleteMessage as deleteMessageFromDb,
clearConversationMessages,
} from '../../../../services/database';
import { messageRepository } from '@/database';
export const useChatScreen = () => {
const getSendErrorMessage = useCallback((error: unknown, fallback: string) => {
@@ -1209,7 +1206,7 @@ export const useChatScreen = () => {
const updatedMessages = messages.filter(m => m.id !== messageId);
// 注意:这里我们依赖 MessageManager 的数据,所以需要通过其他方式刷新
// 暂时只删除本地数据库
await deleteMessageFromDb(messageId);
await messageRepository.delete(messageId);
} catch (error) {
console.error('删除消息失败:', error);
Alert.alert('删除失败', '无法删除消息');
@@ -1224,7 +1221,7 @@ export const useChatScreen = () => {
setLastSeq(0);
setFirstSeq(0);
setHasMoreHistory(true);
await clearConversationMessages(conversationId);
await messageRepository.clearConversation(conversationId);
// 刷新消息列表
await refreshMessages();
} catch (error) {

View File

@@ -14,7 +14,7 @@ import {
MessageSegment,
} from '../../../types/dto';
import { authService } from '../../../services';
import { getUserCache } from '../../../services/database';
import { userCacheRepository } from '@/database';
import { Avatar, Text } from '../../../components/common';
const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity);
@@ -62,7 +62,7 @@ const AsyncMessagePreview: React.FC<{
try {
const asyncText = await extractTextFromSegmentsAsync(
segments,
getUserCache,
userCacheRepository.get.bind(userCacheRepository),
authService.getUserById.bind(authService)
);
if (isMountedRef.current && asyncText !== initialText) {

View File

@@ -57,6 +57,10 @@ const MutualFollowSelectorModal: React.FC<MutualFollowSelectorModalProps> = ({
useEffect(() => {
if (!visible) return;
if (Platform.OS === 'web') {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
active?.blur?.();
}
if (!currentUserId) return;
const nextSelectedIds = new Set(stableInitialSelectedIds);

View File

@@ -0,0 +1,583 @@
/**
* 关于我们页面 AboutScreen
* 胡萝卜BBS - 应用信息、版本检查与更新
* 扁平化设计风格,参考登录、注册、设置页面
*/
import React, { useMemo, useState, useCallback } from 'react';
import {
View,
StyleSheet,
TouchableOpacity,
ScrollView,
Alert,
ActivityIndicator,
Linking,
Platform,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import Constants from 'expo-constants';
import { useRouter } from 'expo-router';
import {
useAppColors,
spacing,
fontSizes,
borderRadius,
type AppColors,
} from '../../theme';
import { Text } from '../../components/common';
import { useResponsive, useResponsiveSpacing } from '../../hooks';
import { checkForAPKUpdate, type VersionCheckResult } from '../../services/apkUpdateService';
import * as hrefs from '../../navigation/hrefs';
const CONTENT_MAX_WIDTH = 720;
const THEME_COLORS = {
primary: '#FF6B35',
primaryLight: '#FF8C5A',
primaryDark: '#E55A2B',
};
const APP_NAME = '萝卜社区';
const APP_SLOGAN = '连接校园,分享生活';
const APP_VERSION = Constants.expoConfig?.version || '1.0.0';
const BUILD_NUMBER = Constants.expoConfig?.android?.versionCode || Constants.expoConfig?.ios?.buildNumber || '1';
function createAboutStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.paper,
},
scrollContent: {
paddingVertical: spacing.lg,
},
content: {
maxWidth: CONTENT_MAX_WIDTH,
alignSelf: 'center',
width: '100%',
},
header: {
alignItems: 'center',
marginBottom: spacing['3xl'],
paddingTop: spacing.xl,
},
logoContainer: {
width: 80,
height: 80,
borderRadius: 20,
backgroundColor: THEME_COLORS.primary,
justifyContent: 'center',
alignItems: 'center',
marginBottom: spacing.lg,
},
logoText: {
fontSize: 32,
fontWeight: '700',
color: '#fff',
},
appName: {
fontSize: 24,
fontWeight: '700',
color: colors.text.primary,
marginBottom: spacing.xs,
},
appSlogan: {
fontSize: 14,
color: colors.text.secondary,
marginBottom: spacing.sm,
},
versionText: {
fontSize: 13,
color: colors.text.hint,
},
groupContainer: {
marginBottom: spacing['2xl'],
maxWidth: CONTENT_MAX_WIDTH,
alignSelf: 'center',
width: '100%',
},
groupHeader: {
paddingHorizontal: spacing['2xl'],
marginBottom: spacing.sm,
marginTop: spacing.sm,
},
groupTitle: {
fontWeight: '600',
fontSize: fontSizes.sm,
color: colors.text.secondary,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
settingItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 16,
paddingHorizontal: spacing['2xl'],
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.divider,
},
settingItemLast: {
borderBottomWidth: 0,
},
settingItemLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
iconContainer: {
width: 24,
height: 24,
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.lg,
},
settingContent: {
flex: 1,
},
subtitle: {
marginTop: 2,
},
updateSection: {
paddingHorizontal: spacing['2xl'],
marginBottom: spacing['2xl'],
},
updateCard: {
backgroundColor: colors.background.default,
borderRadius: borderRadius.xl,
padding: spacing.lg,
},
updateHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.lg,
},
updateIconContainer: {
width: 44,
height: 44,
borderRadius: borderRadius.lg,
backgroundColor: colors.background.paper,
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
},
updateTitle: {
fontSize: 16,
fontWeight: '600',
color: colors.text.primary,
},
updateSubtitle: {
fontSize: 13,
color: colors.text.secondary,
marginTop: 2,
},
checkUpdateButton: {
height: 48,
borderRadius: borderRadius.lg,
backgroundColor: THEME_COLORS.primary,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
},
checkUpdateText: {
fontSize: 15,
fontWeight: '600',
color: '#fff',
marginLeft: spacing.sm,
},
updateStatusContainer: {
alignItems: 'center',
paddingVertical: spacing.lg,
},
updateStatusTitle: {
fontSize: 15,
fontWeight: '600',
color: colors.text.primary,
marginTop: spacing.md,
marginBottom: spacing.xs,
},
updateStatusDesc: {
fontSize: 13,
color: colors.text.secondary,
textAlign: 'center',
},
newVersionContainer: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
padding: spacing.lg,
marginTop: spacing.md,
},
newVersionHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.xs,
},
newVersionText: {
fontSize: 15,
fontWeight: '600',
color: colors.text.primary,
},
newVersionBadge: {
backgroundColor: THEME_COLORS.primary,
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderRadius: borderRadius.sm,
marginLeft: spacing.sm,
},
newVersionBadgeText: {
color: '#fff',
fontSize: 10,
fontWeight: '600',
},
releaseDate: {
fontSize: 12,
color: colors.text.hint,
marginBottom: spacing.md,
},
releaseNotes: {
fontSize: 14,
color: colors.text.secondary,
lineHeight: 20,
marginBottom: spacing.lg,
},
updateActions: {
flexDirection: 'row',
gap: spacing.md,
},
updateButton: {
flex: 1,
height: 40,
borderRadius: borderRadius.md,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
},
primaryButton: {
backgroundColor: THEME_COLORS.primary,
},
secondaryButton: {
backgroundColor: colors.background.default,
},
primaryButtonText: {
fontSize: 14,
fontWeight: '600',
color: '#fff',
marginLeft: spacing.xs,
},
secondaryButtonText: {
fontSize: 14,
fontWeight: '600',
color: colors.text.primary,
marginLeft: spacing.xs,
},
infoValue: {
fontSize: 15,
color: colors.text.secondary,
},
footer: {
alignItems: 'center',
marginTop: spacing.xl,
paddingBottom: spacing.xl,
},
copyright: {
fontSize: 12,
color: colors.text.hint,
textAlign: 'center',
},
companyName: {
fontSize: 12,
color: colors.text.hint,
marginTop: spacing.xs,
},
});
}
type UpdateStatus = 'idle' | 'checking' | 'noUpdate' | 'hasUpdate' | 'error' | 'notSupported';
export const AboutScreen: React.FC = () => {
const colors = useAppColors();
const styles = useMemo(() => createAboutStyles(colors), [colors]);
const insets = useSafeAreaInsets();
const router = useRouter();
const { isMobile } = useResponsive();
const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 });
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>('idle');
const [versionInfo, setVersionInfo] = useState<VersionCheckResult | null>(null);
const [errorMessage, setErrorMessage] = useState('');
const scrollBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.md;
const checkForUpdate = useCallback(async () => {
setUpdateStatus('checking');
setErrorMessage('');
try {
if (Platform.OS !== 'android') {
setUpdateStatus('notSupported');
return;
}
const result = await checkForAPKUpdate(true);
if (!result) {
setErrorMessage('无法获取版本信息,请检查网络连接');
setUpdateStatus('error');
return;
}
setVersionInfo(result);
if (result.hasUpdate) {
setUpdateStatus('hasUpdate');
} else {
setUpdateStatus('noUpdate');
}
} catch (error) {
setErrorMessage('检查更新失败,请稍后重试');
setUpdateStatus('error');
}
}, []);
const handleUpdate = useCallback(() => {
if (!versionInfo?.downloadUrl) return;
Alert.alert(
'更新应用',
`即将下载版本 ${versionInfo.latestVersion},是否继续?`,
[
{ text: '取消', style: 'cancel' },
{
text: '立即更新',
onPress: async () => {
try {
const canOpen = await Linking.canOpenURL(versionInfo.downloadUrl);
if (canOpen) {
await Linking.openURL(versionInfo.downloadUrl);
} else {
Alert.alert('错误', '无法打开下载链接');
}
} catch (error) {
Alert.alert('错误', '打开下载链接失败');
}
},
},
]
);
}, [versionInfo]);
const renderUpdateStatus = () => {
switch (updateStatus) {
case 'checking':
return (
<View style={styles.updateStatusContainer}>
<ActivityIndicator size="large" color={THEME_COLORS.primary} />
<Text style={styles.updateStatusTitle}>...</Text>
</View>
);
case 'noUpdate':
return (
<View style={styles.updateStatusContainer}>
<MaterialCommunityIcons name="check-circle" size={36} color="#4CAF50" />
<Text style={styles.updateStatusTitle}></Text>
<Text style={styles.updateStatusDesc}> {APP_VERSION} </Text>
<TouchableOpacity
style={[styles.checkUpdateButton, { marginTop: spacing.lg, backgroundColor: colors.background.paper }]}
onPress={() => setUpdateStatus('idle')}
>
<MaterialCommunityIcons name="refresh" size={18} color={colors.text.primary} />
<Text style={[styles.checkUpdateText, { color: colors.text.primary }]}></Text>
</TouchableOpacity>
</View>
);
case 'hasUpdate':
return (
<View style={styles.newVersionContainer}>
<View style={styles.newVersionHeader}>
<Text style={styles.newVersionText}></Text>
<View style={styles.newVersionBadge}>
<Text style={styles.newVersionBadgeText}>New</Text>
</View>
</View>
<Text style={styles.releaseDate}> {versionInfo?.latestVersion} · {versionInfo?.currentVersion}</Text>
<Text style={styles.releaseNotes}></Text>
<View style={styles.updateActions}>
<TouchableOpacity
style={[styles.updateButton, styles.secondaryButton]}
onPress={() => setUpdateStatus('idle')}
>
<Text style={styles.secondaryButtonText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.updateButton, styles.primaryButton]}
onPress={handleUpdate}
>
<MaterialCommunityIcons name="download" size={16} color="#fff" />
<Text style={styles.primaryButtonText}></Text>
</TouchableOpacity>
</View>
</View>
);
case 'error':
return (
<View style={styles.updateStatusContainer}>
<MaterialCommunityIcons name="alert-circle" size={36} color={colors.error.main} />
<Text style={styles.updateStatusTitle}></Text>
<Text style={styles.updateStatusDesc}>{errorMessage || '请检查网络连接后重试'}</Text>
<TouchableOpacity
style={[styles.checkUpdateButton, { marginTop: spacing.lg }]}
onPress={checkForUpdate}
>
<MaterialCommunityIcons name="refresh" size={18} color="#fff" />
<Text style={styles.checkUpdateText}></Text>
</TouchableOpacity>
</View>
);
case 'notSupported':
return (
<View style={styles.updateStatusContainer}>
<MaterialCommunityIcons name="apple" size={36} color={colors.text.secondary} />
<Text style={styles.updateStatusTitle}>iOS </Text>
<Text style={styles.updateStatusDesc}>iOS App Store </Text>
<TouchableOpacity
style={[styles.checkUpdateButton, { marginTop: spacing.lg, backgroundColor: colors.background.paper }]}
onPress={() => setUpdateStatus('idle')}
>
<MaterialCommunityIcons name="refresh" size={18} color={colors.text.primary} />
<Text style={[styles.checkUpdateText, { color: colors.text.primary }]}></Text>
</TouchableOpacity>
</View>
);
default:
return (
<TouchableOpacity style={styles.checkUpdateButton} onPress={checkForUpdate}>
<MaterialCommunityIcons name="update" size={18} color="#fff" />
<Text style={styles.checkUpdateText}></Text>
</TouchableOpacity>
);
}
};
const legalItems = [
{ key: 'terms', icon: 'file-document-outline', title: '用户协议', action: () => router.push(hrefs.hrefProfileTerms()) },
{ key: 'privacy', icon: 'shield-outline', title: '隐私政策', action: () => router.push(hrefs.hrefProfilePrivacy()) },
];
const appInfoItems = [
{ key: 'name', icon: 'information-outline', title: '应用名称', value: APP_NAME },
{ key: 'version', icon: 'tag-outline', title: '版本号', value: `v${APP_VERSION} (${BUILD_NUMBER})` },
{ key: 'developer', icon: 'office-building-outline', title: '开发者', value: '青春之旅电子信息科技' },
];
const renderSettingItem = (item: { key: string; icon: string; title: string; value?: string; action?: () => void }, index: number, total: number, showArrow: boolean = false) => (
<TouchableOpacity
key={item.key}
style={[styles.settingItem, index === total - 1 && styles.settingItemLast]}
onPress={item.action}
activeOpacity={0.7}
disabled={!item.action}
>
<View style={styles.settingItemLeft}>
<View style={styles.iconContainer}>
<MaterialCommunityIcons name={item.icon as any} size={22} color={colors.text.secondary} />
</View>
<View style={styles.settingContent}>
<Text variant="body" color={colors.text.primary}>
{item.title}
</Text>
{item.value && (
<Text variant="caption" color={colors.text.secondary} style={styles.subtitle}>
{item.value}
</Text>
)}
</View>
</View>
{showArrow && (
<MaterialCommunityIcons name="chevron-right" size={20} color={colors.text.hint} />
)}
{!showArrow && item.value && (
<Text style={styles.infoValue}>{item.value}</Text>
)}
</TouchableOpacity>
);
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<ScrollView
contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding }]}
showsVerticalScrollIndicator={false}
>
<View style={styles.content}>
<View style={styles.header}>
<View style={styles.logoContainer}>
<Text style={styles.logoText}></Text>
</View>
<Text style={styles.appName}>{APP_NAME}</Text>
<Text style={styles.appSlogan}>{APP_SLOGAN}</Text>
<Text style={styles.versionText}> {APP_VERSION}</Text>
</View>
<View style={styles.updateSection}>
<View style={styles.updateCard}>
<View style={styles.updateHeader}>
<View style={styles.updateIconContainer}>
<MaterialCommunityIcons name="cellphone-arrow-down" size={22} color={THEME_COLORS.primary} />
</View>
<View>
<Text style={styles.updateTitle}></Text>
<Text style={styles.updateSubtitle}></Text>
</View>
</View>
{renderUpdateStatus()}
</View>
</View>
<View style={styles.groupContainer}>
<View style={styles.groupHeader}>
<Text variant="caption" style={styles.groupTitle}>
</Text>
</View>
<View>
{legalItems.map((item, index) => renderSettingItem(item, index, legalItems.length, true))}
</View>
</View>
<View style={styles.groupContainer}>
<View style={styles.groupHeader}>
<Text variant="caption" style={styles.groupTitle}>
</Text>
</View>
<View>
{appInfoItems.map((item, index) => renderSettingItem(item, index, appInfoItems.length, false))}
</View>
</View>
<View style={styles.footer}>
<Text style={styles.copyright}>© 2026 </Text>
<Text style={styles.companyName}>ICP备XXXXXXXX号-1</Text>
</View>
</View>
</ScrollView>
</SafeAreaView>
);
};
export default AboutScreen;

View File

@@ -15,6 +15,14 @@ import { useResponsive, useResponsiveSpacing } from '../../hooks';
// 内容最大宽度
const CONTENT_MAX_WIDTH = 720;
// 胡萝卜橙主题色
const THEME_COLORS = {
primary: '#FF6B35',
primaryLight: '#FF8C5A',
primaryDark: '#E55A2B',
};
import { authService, resolveAuthApiError } from '../../services/authService';
import { showPrompt } from '../../services/promptService';
import { useAuthStore } from '../../stores';
@@ -186,167 +194,173 @@ export const AccountSecurityScreen: React.FC = () => {
}
};
const content = (
<>
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text variant="caption" style={styles.sectionTitle}>
</Text>
</View>
<View style={styles.card}>
<View style={styles.statusRow}>
<Text variant="body" color={colors.text.primary}></Text>
<View style={[styles.statusBadge, isEmailVerified ? styles.statusVerified : styles.statusUnverified]}>
<Text variant="caption" color={isEmailVerified ? '#1B5E20' : '#B26A00'}>
{emailStatusText}
</Text>
</View>
</View>
<View style={styles.inputWrapper}>
<MaterialCommunityIcons name="email-outline" size={20} color={colors.text.secondary} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="请输入邮箱"
placeholderTextColor={colors.text.hint}
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
/>
</View>
<View style={styles.codeRow}>
<View style={[styles.inputWrapper, styles.codeInput]}>
<MaterialCommunityIcons name="shield-key-outline" size={20} color={colors.text.secondary} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="验证码"
placeholderTextColor={colors.text.hint}
value={verificationCode}
onChangeText={setVerificationCode}
keyboardType="number-pad"
maxLength={6}
/>
</View>
<TouchableOpacity
style={[styles.sendCodeButton, (sendingCode || countdown > 0) && styles.buttonDisabled]}
onPress={handleSendCode}
disabled={sendingCode || countdown > 0}
>
{sendingCode ? (
<ActivityIndicator size="small" color={colors.text.inverse} />
) : (
<Text style={styles.sendCodeButtonText}>{countdown > 0 ? `${countdown}s` : '发送验证码'}</Text>
)}
</TouchableOpacity>
</View>
<TouchableOpacity
style={[styles.primaryButton, verifyingEmail && styles.buttonDisabled]}
onPress={handleVerifyEmail}
disabled={verifyingEmail}
>
{verifyingEmail ? (
<ActivityIndicator size="small" color={colors.text.inverse} />
) : (
<Text style={styles.primaryButtonText}></Text>
)}
</TouchableOpacity>
</View>
</View>
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text variant="caption" style={styles.sectionTitle}>
</Text>
</View>
<View style={styles.card}>
<View style={styles.inputWrapper}>
<MaterialCommunityIcons name="lock-outline" size={20} color={colors.text.secondary} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="当前密码"
placeholderTextColor={colors.text.hint}
value={oldPassword}
onChangeText={setOldPassword}
secureTextEntry
/>
</View>
<View style={styles.inputWrapper}>
<MaterialCommunityIcons name="lock-plus-outline" size={20} color={colors.text.secondary} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="新密码至少6位"
placeholderTextColor={colors.text.hint}
value={newPassword}
onChangeText={setNewPassword}
secureTextEntry
/>
</View>
<View style={styles.codeRow}>
<View style={[styles.inputWrapper, styles.codeInput]}>
<MaterialCommunityIcons name="shield-key-outline" size={20} color={colors.text.secondary} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="邮箱验证码"
placeholderTextColor={colors.text.hint}
value={changePasswordCode}
onChangeText={setChangePasswordCode}
keyboardType="number-pad"
maxLength={6}
/>
</View>
<TouchableOpacity
style={[styles.sendCodeButton, (sendingChangePwdCode || changePwdCountdown > 0) && styles.buttonDisabled]}
onPress={handleSendChangePasswordCode}
disabled={sendingChangePwdCode || changePwdCountdown > 0}
>
{sendingChangePwdCode ? (
<ActivityIndicator size="small" color={colors.text.inverse} />
) : (
<Text style={styles.sendCodeButtonText}>
{changePwdCountdown > 0 ? `${changePwdCountdown}s` : '发送验证码'}
</Text>
)}
</TouchableOpacity>
</View>
<View style={styles.inputWrapper}>
<MaterialCommunityIcons name="lock-check-outline" size={20} color={colors.text.secondary} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="确认新密码"
placeholderTextColor={colors.text.hint}
value={confirmPassword}
onChangeText={setConfirmPassword}
secureTextEntry
/>
</View>
<TouchableOpacity
style={[styles.primaryButton, updatingPassword && styles.buttonDisabled]}
onPress={handleChangePassword}
disabled={updatingPassword}
>
{updatingPassword ? (
<ActivityIndicator size="small" color={colors.text.inverse} />
) : (
<Text style={styles.primaryButtonText}></Text>
)}
</TouchableOpacity>
</View>
</View>
</>
);
const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 });
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding }]}>
{content}
<View style={styles.content}>
{/* 邮箱验证分组 */}
<View style={styles.groupHeader}>
<Text variant="caption" style={styles.groupTitle}>
</Text>
</View>
<View style={styles.card}>
{/* 状态显示 */}
<View style={styles.statusRow}>
<Text style={styles.statusLabel}></Text>
<View style={[styles.statusBadge, isEmailVerified ? styles.statusVerified : styles.statusUnverified]}>
<Text style={[styles.statusText, isEmailVerified ? styles.statusVerifiedText : styles.statusUnverifiedText]}>
{emailStatusText}
</Text>
</View>
</View>
{/* 邮箱输入框 */}
<View style={styles.inputWrapper}>
<MaterialCommunityIcons name="email-outline" size={20} color={colors.text.secondary} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="请输入邮箱"
placeholderTextColor={colors.text.hint}
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
/>
</View>
{/* 验证码行 */}
<View style={styles.codeRow}>
<View style={[styles.inputWrapper, styles.codeInput]}>
<MaterialCommunityIcons name="shield-key-outline" size={20} color={colors.text.secondary} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="验证码"
placeholderTextColor={colors.text.hint}
value={verificationCode}
onChangeText={setVerificationCode}
keyboardType="number-pad"
maxLength={6}
/>
</View>
<TouchableOpacity
style={[styles.sendCodeButton, (sendingCode || countdown > 0) && styles.buttonDisabled]}
onPress={handleSendCode}
disabled={sendingCode || countdown > 0}
>
{sendingCode ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.sendCodeButtonText}>{countdown > 0 ? `${countdown}s` : '发送验证码'}</Text>
)}
</TouchableOpacity>
</View>
{/* 验证按钮 */}
<TouchableOpacity
style={[styles.primaryButton, verifyingEmail && styles.buttonDisabled]}
onPress={handleVerifyEmail}
disabled={verifyingEmail}
>
{verifyingEmail ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.primaryButtonText}></Text>
)}
</TouchableOpacity>
</View>
{/* 修改密码分组 */}
<View style={styles.groupHeader}>
<Text variant="caption" style={styles.groupTitle}>
</Text>
</View>
<View style={styles.card}>
{/* 当前密码 */}
<View style={styles.inputWrapper}>
<MaterialCommunityIcons name="lock-outline" size={20} color={colors.text.secondary} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="当前密码"
placeholderTextColor={colors.text.hint}
value={oldPassword}
onChangeText={setOldPassword}
secureTextEntry
/>
</View>
{/* 新密码 */}
<View style={styles.inputWrapper}>
<MaterialCommunityIcons name="lock-plus-outline" size={20} color={colors.text.secondary} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="新密码至少6位"
placeholderTextColor={colors.text.hint}
value={newPassword}
onChangeText={setNewPassword}
secureTextEntry
/>
</View>
{/* 确认新密码 */}
<View style={styles.inputWrapper}>
<MaterialCommunityIcons name="lock-check-outline" size={20} color={colors.text.secondary} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="确认新密码"
placeholderTextColor={colors.text.hint}
value={confirmPassword}
onChangeText={setConfirmPassword}
secureTextEntry
/>
</View>
{/* 验证码行 */}
<View style={styles.codeRow}>
<View style={[styles.inputWrapper, styles.codeInput]}>
<MaterialCommunityIcons name="shield-key-outline" size={20} color={colors.text.secondary} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="邮箱验证码"
placeholderTextColor={colors.text.hint}
value={changePasswordCode}
onChangeText={setChangePasswordCode}
keyboardType="number-pad"
maxLength={6}
/>
</View>
<TouchableOpacity
style={[styles.sendCodeButton, (sendingChangePwdCode || changePwdCountdown > 0) && styles.buttonDisabled]}
onPress={handleSendChangePasswordCode}
disabled={sendingChangePwdCode || changePwdCountdown > 0}
>
{sendingChangePwdCode ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.sendCodeButtonText}>
{changePwdCountdown > 0 ? `${changePwdCountdown}s` : '发送验证码'}
</Text>
)}
</TouchableOpacity>
</View>
{/* 更新密码按钮 */}
<TouchableOpacity
style={[styles.primaryButton, updatingPassword && styles.buttonDisabled]}
onPress={handleChangePassword}
disabled={updatingPassword}
>
{updatingPassword ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.primaryButtonText}></Text>
)}
</TouchableOpacity>
</View>
</View>
</ScrollView>
</SafeAreaView>
);
@@ -356,37 +370,51 @@ function createAccountSecurityStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.paper,
backgroundColor: '#fff',
},
scrollContent: {
paddingVertical: spacing.lg,
},
section: {
marginBottom: spacing['2xl'],
content: {
maxWidth: CONTENT_MAX_WIDTH,
alignSelf: 'center',
width: '100%',
},
sectionHeader: {
// 分组标题
groupHeader: {
paddingHorizontal: spacing['2xl'],
marginBottom: spacing.sm,
marginTop: spacing.sm,
},
sectionTitle: {
groupTitle: {
fontWeight: '600',
fontSize: fontSizes.sm,
color: colors.text.secondary,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
// 卡片样式 - 统一
card: {
backgroundColor: '#F5F5F7',
borderRadius: 16,
padding: 6,
marginBottom: spacing['2xl'],
marginHorizontal: spacing['2xl'],
maxWidth: CONTENT_MAX_WIDTH,
alignSelf: 'center',
width: '100%',
},
// 状态显示
statusRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
marginBottom: spacing.md,
backgroundColor: '#fff',
borderRadius: 12,
},
statusLabel: {
fontSize: 15,
color: colors.text.secondary,
},
statusBadge: {
paddingHorizontal: spacing.sm,
@@ -394,24 +422,29 @@ function createAccountSecurityStyles(colors: AppColors) {
borderRadius: borderRadius.sm,
},
statusVerified: {
backgroundColor: colors.success.light + '40',
backgroundColor: '#E8F5E9',
},
statusUnverified: {
backgroundColor: colors.warning.light + '40',
backgroundColor: '#FFF3E0',
},
statusText: {
fontSize: 13,
fontWeight: '600',
},
statusVerifiedText: {
color: colors.success.dark,
color: '#2E7D32',
},
statusUnverifiedText: {
color: colors.warning.dark,
color: '#E65100',
},
// 输入框样式 - 统一
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.default,
backgroundColor: '#fff',
borderRadius: 14,
paddingHorizontal: spacing.md,
height: 50,
height: 56,
marginBottom: spacing.sm,
},
inputIcon: {
@@ -421,8 +454,9 @@ function createAccountSecurityStyles(colors: AppColors) {
flex: 1,
color: colors.text.primary,
fontSize: fontSizes.md,
height: 50,
height: 56,
},
// 验证码行
codeRow: {
flexDirection: 'row',
alignItems: 'center',
@@ -433,30 +467,32 @@ function createAccountSecurityStyles(colors: AppColors) {
flex: 1,
marginBottom: 0,
},
// 发送验证码按钮
sendCodeButton: {
height: 50,
height: 56,
minWidth: 110,
borderRadius: 14,
backgroundColor: colors.primary.main,
backgroundColor: THEME_COLORS.primary,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: spacing.sm,
},
sendCodeButtonText: {
color: colors.text.inverse,
color: '#fff',
fontSize: fontSizes.sm,
fontWeight: '600',
},
// 主按钮样式 - 统一
primaryButton: {
height: 50,
height: 56,
borderRadius: 14,
backgroundColor: colors.primary.main,
backgroundColor: THEME_COLORS.primary,
alignItems: 'center',
justifyContent: 'center',
marginTop: spacing.xs,
},
primaryButtonText: {
color: colors.text.inverse,
color: '#fff',
fontSize: fontSizes.md,
fontWeight: '600',
},

View File

@@ -3,17 +3,16 @@
* 胡萝卜BBS - 聊天个性化设置
*/
import React, { useMemo, useCallback } from 'react';
import React, { useMemo, useCallback, useRef } from 'react';
import {
View,
StyleSheet,
TouchableOpacity,
ScrollView,
Dimensions,
PanResponder,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import {
useAppColors,
spacing,
@@ -28,7 +27,6 @@ import {
useChatSettingsActions,
CHAT_THEMES,
} from '../../stores/chatSettingsStore';
import { useCurrentUser } from '../../stores';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
const CARD_MAX_WIDTH = 720;
@@ -43,7 +41,7 @@ interface SliderProps {
showValue?: boolean;
}
// 自定义滑块组件
// 自定义滑块组件(支持拖动)
const Slider: React.FC<SliderProps> = ({
value,
min,
@@ -55,34 +53,51 @@ const Slider: React.FC<SliderProps> = ({
}) => {
const colors = useAppColors();
const styles = useMemo(() => createSliderStyles(colors), [colors]);
const trackWidthRef = useRef(0);
const percentage = ((value - min) / (max - min)) * 100;
const handlePress = useCallback((event: any) => {
const { locationX } = event.nativeEvent;
const sliderWidth = SCREEN_WIDTH - 64; // 考虑边距
const newPercentage = Math.max(0, Math.min(100, (locationX / sliderWidth) * 100));
const percentage = isNaN(value) ? 0 : ((value - min) / (max - min)) * 100;
const updateValue = useCallback((locationX: number) => {
const width = trackWidthRef.current;
if (width <= 0) return;
const newPercentage = Math.max(0, Math.min(100, (locationX / width) * 100));
const newValue = Math.round(min + (newPercentage / 100) * (max - min));
onValueChange(newValue);
}, [min, max, onValueChange]);
const panResponder = useMemo(() => PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderGrant: (evt) => {
updateValue(evt.nativeEvent.locationX);
},
onPanResponderMove: (evt) => {
updateValue(evt.nativeEvent.locationX);
},
}), [updateValue]);
const handleLayout = useCallback((event: any) => {
trackWidthRef.current = event.nativeEvent.layout.width;
}, []);
return (
<View style={styles.container}>
<View style={styles.labelRow}>
{leftLabel && <Text style={styles.label}>{leftLabel}</Text>}
{showValue && <Text style={styles.value}>{value}</Text>}
{showValue && <Text style={styles.value}>{isNaN(value) ? 0 : value}</Text>}
{rightLabel && <Text style={styles.label}>{rightLabel}</Text>}
</View>
<TouchableOpacity
<View
style={styles.trackContainer}
onPress={handlePress}
activeOpacity={1}
onLayout={handleLayout}
{...panResponder.panHandlers}
>
<View style={styles.track}>
<View style={[styles.fill, { width: `${percentage}%`, backgroundColor: colors.primary.main }]} />
</View>
<View style={[styles.thumb, { left: `${percentage}%`, backgroundColor: colors.primary.main }]} />
</TouchableOpacity>
</View>
</View>
);
};
@@ -168,29 +183,32 @@ function createStyles(colors: AppColors) {
minHeight: 140,
},
previewMessage: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.md,
backgroundColor: '#FFFFFF',
padding: spacing.sm,
marginBottom: spacing.sm,
maxWidth: '80%',
alignSelf: 'flex-start',
minWidth: 44,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.06,
shadowRadius: 2,
elevation: 1,
},
previewReply: {
borderRadius: borderRadius.md,
padding: spacing.sm,
maxWidth: '80%',
alignSelf: 'flex-end',
minWidth: 44,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.06,
shadowRadius: 2,
elevation: 1,
},
previewText: {
fontSize: fontSizes.sm,
color: colors.text.primary,
},
previewTime: {
fontSize: fontSizes.xs,
color: colors.text.hint,
marginTop: 2,
alignSelf: 'flex-end',
},
// 主题选择样式
themeList: {
flexDirection: 'row',
@@ -240,106 +258,56 @@ function createStyles(colors: AppColors) {
marginTop: 4,
color: colors.text.secondary,
},
// 设置项样式 - 扁平化
settingItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 16,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.divider,
},
settingItemLast: {
borderBottomWidth: 0,
},
settingItemLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
iconContainer: {
width: 24,
height: 24,
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.lg,
},
settingTitle: {
fontSize: fontSizes.md,
color: colors.text.primary,
},
settingSubtitle: {
fontSize: fontSizes.sm,
color: colors.text.secondary,
marginTop: 2,
},
// 滑块容器 - 扁平化
sliderContainer: {
paddingVertical: 8,
},
// 开关样式
switch: {
width: 50,
height: 28,
borderRadius: 14,
backgroundColor: colors.divider,
padding: 2,
},
switchActive: {
backgroundColor: colors.primary.main,
},
switchThumb: {
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: colors.background.paper,
},
});
}
export const ChatSettingsScreen: React.FC = () => {
const router = useRouter();
const colors = useAppColors();
const styles = useMemo(() => createStyles(colors), [colors]);
const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 });
// 获取当前用户信息
const currentUser = useCurrentUser();
// 从 store 获取状态
const fontSize = useChatSettingsStore((s) => s.fontSize);
const messageRadius = useChatSettingsStore((s) => s.messageRadius);
const themeIndex = useChatSettingsStore((s) => s.themeIndex);
const nightMode = useChatSettingsStore((s) => s.nightMode);
// 获取操作方法
const { setFontSize, setMessageRadius, setTheme, toggleNightMode } = useChatSettingsActions();
const { setFontSize, setMessageRadius, setTheme } = useChatSettingsActions();
const currentTheme = CHAT_THEMES[themeIndex];
// 用户显示名称
const userName = currentUser?.nickname || currentUser?.username || '我';
// 渲染聊天预览
const renderChatPreview = () => (
<View style={styles.section}>
<Text style={styles.sectionTitle}></Text>
<View style={[styles.previewContainer, { backgroundColor: currentTheme.secondary }]}>
<View style={[styles.previewMessage, { borderRadius: messageRadius }]}>
<Text style={[styles.previewText, { fontSize }]}>{userName}</Text>
<Text style={[styles.previewText, { fontSize: fontSize - 2 }]}>
<View style={[
styles.previewMessage,
{
borderRadius: messageRadius,
borderTopLeftRadius: Math.max(4, messageRadius * 0.3),
}
]}>
<Text style={[styles.previewText, { fontSize }]}>
👋
</Text>
<Text style={[styles.previewText, { fontSize: fontSize - 2 }]}>
<Text style={[styles.previewText, { fontSize }]}>
</Text>
<Text style={styles.previewTime}>AM12:42</Text>
</View>
<View style={[styles.previewReply, { borderRadius: messageRadius, backgroundColor: currentTheme.bubble }]}>
<View style={[
styles.previewReply,
{
borderRadius: messageRadius,
borderBottomRightRadius: Math.max(4, messageRadius * 0.3),
backgroundColor: currentTheme.bubble
}
]}>
<Text style={[styles.previewText, { fontSize }]}>
😎
</Text>
<Text style={styles.previewTime}>AM12:57</Text>
</View>
</View>
</View>
@@ -410,87 +378,18 @@ export const ChatSettingsScreen: React.FC = () => {
</View>
);
// 渲染其他设置项
const renderOtherSettings = () => (
<View style={styles.section}>
<Text style={styles.sectionTitle}></Text>
<TouchableOpacity style={styles.settingItem}>
<View style={styles.settingItemLeft}>
<View style={styles.iconContainer}>
<MaterialCommunityIcons name="image-outline" size={22} color={colors.text.secondary} />
</View>
<View>
<Text style={styles.settingTitle}></Text>
</View>
</View>
<MaterialCommunityIcons name="chevron-right" size={20} color={colors.text.hint} />
</TouchableOpacity>
<TouchableOpacity style={[styles.settingItem, styles.settingItemLast]}>
<View style={styles.settingItemLeft}>
<View style={styles.iconContainer}>
<MaterialCommunityIcons name="palette-outline" size={22} color={colors.text.secondary} />
</View>
<View>
<Text style={styles.settingTitle}></Text>
<Text style={styles.settingSubtitle}>MiMQy</Text>
</View>
</View>
<MaterialCommunityIcons name="chevron-right" size={20} color={colors.text.hint} />
</TouchableOpacity>
</View>
);
// 渲染夜间模式开关
const renderNightMode = () => (
<View style={styles.section}>
<View style={[styles.settingItem, styles.settingItemLast]}>
<View style={styles.settingItemLeft}>
<View style={styles.iconContainer}>
<MaterialCommunityIcons name="weather-night" size={22} color={colors.text.secondary} />
</View>
<Text style={styles.settingTitle}></Text>
</View>
<TouchableOpacity
style={[styles.switch, nightMode && styles.switchActive]}
onPress={() => toggleNightMode()}
>
<View style={[styles.switchThumb, { alignSelf: nightMode ? 'flex-end' : 'flex-start' }]} />
</TouchableOpacity>
</View>
</View>
);
// 渲染浏览其他主题
const renderBrowseThemes = () => (
<View style={styles.section}>
<TouchableOpacity style={[styles.settingItem, styles.settingItemLast]}>
<View style={styles.settingItemLeft}>
<View style={styles.iconContainer}>
<MaterialCommunityIcons name="apps" size={22} color={colors.text.secondary} />
</View>
<Text style={styles.settingTitle}></Text>
</View>
<MaterialCommunityIcons name="chevron-right" size={20} color={colors.text.hint} />
</TouchableOpacity>
</View>
);
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<ScrollView
contentContainerStyle={[
styles.scrollContent,
{ paddingHorizontal: responsivePadding }
{ paddingHorizontal: responsivePadding, paddingBottom: 80 }
]}
>
{renderFontSizeSlider()}
{renderChatPreview()}
{renderThemeColors()}
{renderRadiusSlider()}
{renderOtherSettings()}
{renderNightMode()}
{renderBrowseThemes()}
</ScrollView>
</SafeAreaView>
);

View File

@@ -0,0 +1,320 @@
/**
* 隐私政策页面 PrivacyPolicyScreen
* 胡萝卜BBS - 隐私政策
* 扁平化设计风格
*/
import React, { useMemo } from 'react';
import {
View,
StyleSheet,
ScrollView,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import {
useAppColors,
spacing,
type AppColors,
} from '../../theme';
import { Text } from '../../components/common';
import { useResponsive, useResponsiveSpacing } from '../../hooks';
// 内容最大宽度
const CONTENT_MAX_WIDTH = 400;
// 胡萝卜橙主题色
const THEME_COLORS = {
primary: '#FF6B35',
};
// 政策最后更新日期
const LAST_UPDATED = '2026年4月4日';
// 隐私政策内容
const PRIVACY_SECTIONS = [
{
title: '引言',
content: `萝卜社区(以下简称"我们"或"本应用")非常重视用户的隐私和个人信息保护。我们深知个人信息对您的重要性,并会尽全力保护您的个人信息安全可靠。
本隐私政策旨在帮助您了解我们如何收集、使用、存储、共享和保护您的个人信息,以及您享有的相关权利。请您在使用我们的服务前,仔细阅读并理解本隐私政策的全部内容。
【敏感个人信息提示】请您注意,您发布的照片、视频可能包含生物识别信息,您的位置信息属于行踪轨迹信息,通讯录信息属于社交关系信息,以上均属于敏感个人信息。我们收集这些信息是为了实现校园社交的核心功能,请您谨慎考虑是否提供。拒绝提供敏感个人信息仅会影响相关功能的使用,不会影响您使用其他服务。`,
},
{
title: '一、我们收集的信息',
content: `1.1 您主动提供的信息
当您注册、登录或使用我们的服务时,您可能需要提供以下信息:
• 账号信息:手机号、邮箱地址、用户名、密码等
• 个人资料:昵称、头像、性别、生日、学校、专业等
• 联系信息:真实姓名、地址(用于特定功能)
• 内容信息:您发布的内容、评论、私信等
1.2 我们在您使用服务时收集的信息
• 设备信息包括设备型号、操作系统版本、设备设置、唯一设备标识符如IMEI、Android ID、IDFA、OAID、MAC地址等、IP地址等
• 日志信息IP地址、访问时间、浏览记录、操作日志等
• 位置信息:为了向您提供"校园圈"、"附近的人"等基于位置的服务,经您授权,我们会收集您的精确地理位置信息。该信息属于敏感个人信息,拒绝提供仅会影响相关功能,不影响其他服务的使用
• 相机/相册权限:用于发布动态、更换头像。我们仅在您主动触发相关功能时申请权限,且不会在后台持续收集
• 通讯录权限:经您授权后用于查找好友
1.3 从第三方获取的信息
如您使用第三方账号登录功能(具体以应用实际提供的登录方式为准),我们会从第三方获取您授权共享的账号信息(如昵称、头像等)。`,
},
{
title: '二、我们如何使用信息',
content: `2.1 我们使用收集的信息用于以下目的:
• 为您提供注册、登录及身份验证服务
• 为您展示个性化的内容推荐
• 实现社交功能,如好友推荐、内容分享、附近的人
• 向您发送服务通知、安全提示
• 改善和优化我们的产品和服务
• 保护账号安全,防范欺诈和恶意行为
• 遵守法律法规的要求
2.2 我们使用Cookie和同类技术
我们使用Cookie和同类技术如Web Beacon、Pixel标签、本地存储来确保应用正常运转、记住您的偏好、分析您使用我们服务的情况。具体包括
• 用于记住您的登录状态
• 了解您的使用偏好,提供个性化服务
• 分析服务使用情况,优化产品体验
• 用于广告归因和效果分析
我们不会将Cookie用于本政策所述目的之外的任何用途。您可以通过设备设置管理Cookie但这可能影响某些功能的使用。`,
},
{
title: '三、信息的共享与披露',
content: `3.1 我们不会将您的个人信息出售给任何第三方,但在以下情况下可能会共享或披露您的信息:
3.2 经您同意的情况
在获得您的明确同意后,我们会与第三方共享您的个人信息。
3.3 与我们的关联公司共享
为向您提供一致的服务体验,我们可能会与关联公司共享必要的个人信息。
3.4 与服务提供商共享
我们可能会委托第三方服务提供商处理您的信息,如云服务提供商、支付服务提供商、数据分析服务商、推送服务商等。这些服务提供商仅能在我们授权的范围内使用您的信息,且我们要求其遵守严格的保密义务。
3.5 法律法规要求及无需征得同意的情形
在以下情况下,我们可能会披露您的信息,且无需征得您的授权同意:
1与国家安全、国防安全有关的
2与公共安全、公共卫生、重大公共利益有关的
3与犯罪侦查、起诉、审判和判决执行等有关的
4出于维护个人信息主体或其他个人的生命、财产等重大合法权益但又很难得到本人同意的
5所收集的个人信息是个人信息主体自行向社会公众公开的
6从合法公开披露的信息中收集个人信息的如合法的新闻报道、政府信息公开等渠道
7根据个人信息主体要求签订和履行合同所必需的
8为配合学校、教育机构或相关主管部门履行法定管理职责如学籍管理、校园安全、突发事件处理等
9法律法规规定的其他情形。
3.6 匿名化信息
我们可能会对您的个人信息进行匿名化处理,使其无法识别特定个人,然后使用或共享这些匿名化信息。`,
},
{
title: '四、信息的存储与保护',
content: `4.1 信息存储
• 原则上,我们在中华人民共和国境内收集和产生的个人信息,将存储在中华人民共和国境内
• 如部分服务需要使用境外服务器(如跨境数据传输),我们会单独征得您的授权同意,并确保符合法律法规要求
• 我们会采取合理的技术和管理措施保护您的信息安全
• 我们只会在达成本隐私政策所述目的所需的期限内保留您的信息
4.2 安全措施
• 使用加密技术保护数据传输安全
• 实施访问控制,限制对个人信息的访问
• 定期进行安全审计和风险评估
• 对员工进行隐私保护培训
4.3 安全事件处理
如发生个人信息泄露等安全事件,我们会:
• 及时采取补救措施
• 按照法律法规要求通知您和监管部门
• 配合相关部门进行调查`,
},
{
title: '五、您的权利',
content: `5.1 访问和更正
您有权访问和更正您的个人信息。您可以在应用内的"设置"-"编辑资料"中查看和修改大部分个人信息。
5.2 删除
在以下情况下,您可以要求我们删除您的个人信息:
• 我们处理信息的行为违反法律法规
• 我们未经您同意收集、使用您的信息
• 您不再使用我们的服务并要求删除
• 我们违反与您的约定处理您的信息
5.3 撤回同意
您可以随时撤回对我们收集、使用您个人信息的同意。但请注意,撤回同意可能会影响您使用某些服务。
5.4 注销账号
您可以在"设置"-"账号安全"中申请注销账号。注销后我们将停止为您提供服务并删除或匿名化您的个人信息。但根据法律法规规定如《网络安全法》要求的日志留存不少于6个月我们需要保留部分必要信息以配合监管、处理历史纠纷或履行法定义务。
5.5 投诉与建议
如果您对我们的隐私政策或信息处理有任何疑问或建议请联系我们system@qczlit.cn`,
},
{
title: '六、未成年人保护',
content: `6.1 我们非常重视对未成年人个人信息的保护。我们的服务主要面向大学生及成年用户。
6.2 如果您是未满18周岁的未成年人在使用我们的服务前请确保在监护人的陪同下阅读本隐私政策并在获得监护人同意后再使用我们的服务。
6.3 如果您是未成年人的监护人,请监督您的被监护人使用本服务。
6.4 如果我们发现自己在未事先获得监护人同意的情况下收集了未成年人的个人信息,我们会尽快删除相关数据。`,
},
{
title: '七、第三方SDK目录',
content: `为了保障App的稳定运行或实现特定功能我们可能接入第三方SDK。截至本隐私政策更新之日我们主要使用以下服务
• Expo推送服务用于消息推送功能。可能收集设备标识符、设备信息。
如您后续接入其他第三方SDK如微信登录、分享等功能我们将在本章节更新相关说明并告知您对应SDK收集的信息类型和用途。更新后的SDK目录将在应用内公布请以最新版本为准。`,
},
{
title: '八、隐私政策的更新',
content: `8.1 我们可能会不时更新本隐私政策。更新后的隐私政策将在应用内公布,并标注更新日期。
8.2 对于重大变更,我们会在变更生效前通过应用内通知、弹窗等方式告知您。
8.3 如您不同意变更后的内容,应立即停止使用本服务。如您继续使用本服务,即视为您已阅读并同意受变更后的隐私政策约束。`,
},
{
title: '九、联系我们',
content: `如果您对本隐私政策有任何疑问、意见或建议,或者您希望行使您的权利,请通过以下方式与我们联系:
• 邮箱system@qczlit.cn
• 公司:青春之旅电子信息科技(威海)有限公司
• 地址:山东省威海市环翠区哈尔滨工业大学(威海)
我们将在收到您的请求后的15个工作日内予以回复。对于复杂的请求我们可能会延长回复期限并及时告知您延长的原因和预计回复时间。`,
},
];
function createPrivacyStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
scrollContent: {
paddingHorizontal: 28,
paddingTop: 24,
},
// 内容区域
content: {
maxWidth: CONTENT_MAX_WIDTH,
alignSelf: 'center',
width: '100%',
},
// 更新日期
lastUpdated: {
fontSize: 13,
color: colors.text.hint,
marginBottom: 20,
},
// 重点提示框
highlightBox: {
backgroundColor: '#FFF5F0',
borderRadius: 12,
padding: 16,
marginBottom: 20,
borderLeftWidth: 4,
borderLeftColor: THEME_COLORS.primary,
},
highlightTitle: {
fontSize: 15,
fontWeight: '600',
color: THEME_COLORS.primary,
marginBottom: 8,
},
highlightText: {
fontSize: 14,
lineHeight: 22,
color: colors.text.secondary,
},
// 隐私政策内容卡片
privacyCard: {
backgroundColor: '#F5F5F7',
borderRadius: 16,
padding: 20,
marginBottom: 24,
},
section: {
marginBottom: 24,
},
sectionLast: {
marginBottom: 0,
},
sectionTitle: {
fontSize: 16,
fontWeight: '600',
color: THEME_COLORS.primary,
marginBottom: 12,
},
sectionContent: {
fontSize: 14,
lineHeight: 24,
color: colors.text.primary,
},
// 底部版权
footer: {
alignItems: 'center',
},
footerText: {
fontSize: 13,
color: colors.text.hint,
textAlign: 'center',
marginBottom: 4,
},
});
}
export const PrivacyPolicyScreen: React.FC = () => {
const colors = useAppColors();
const styles = useMemo(() => createPrivacyStyles(colors), [colors]);
const insets = useSafeAreaInsets();
const { isMobile } = useResponsive();
const responsivePadding = useResponsiveSpacing({ xs: 20, sm: 24, md: 28, lg: 32, xl: 40 });
// 底部间距,避免被 TabBar 遮挡
const scrollBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.lg;
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<ScrollView
contentContainerStyle={[styles.scrollContent, { paddingHorizontal: responsivePadding, paddingBottom: scrollBottomInset }]}
showsVerticalScrollIndicator={false}
>
<View style={styles.content}>
{/* 更新日期 */}
<Text style={styles.lastUpdated}>{LAST_UPDATED}</Text>
{/* 重点提示 */}
<View style={styles.highlightBox}>
<Text style={styles.highlightTitle}></Text>
<Text style={styles.highlightText}>
</Text>
</View>
{/* 隐私政策内容 */}
<View style={styles.privacyCard}>
{PRIVACY_SECTIONS.map((section, index) => (
<View key={index} style={[styles.section, index === PRIVACY_SECTIONS.length - 1 && styles.sectionLast]}>
<Text style={styles.sectionTitle}>{section.title}</Text>
<Text style={styles.sectionContent}>{section.content}</Text>
</View>
))}
</View>
{/* 底部版权 */}
<View style={styles.footer}>
<Text style={styles.footerText}>© 2026 </Text>
</View>
</View>
</ScrollView>
</SafeAreaView>
);
};
export default PrivacyPolicyScreen;

View File

@@ -62,7 +62,6 @@ const SETTINGS_GROUPS_BASE = [
icon: 'bell-outline',
items: [
{ key: 'notification_settings', title: '通知设置', icon: 'bell-cog-outline', showArrow: true, subtitle: '推送、震动、提示音' },
{ key: 'language', title: '语言设置', icon: 'translate', showArrow: true, subtitle: '简体中文' },
{ key: 'data_storage', title: '数据与存储', icon: 'database-outline', showArrow: true },
],
},
@@ -72,8 +71,6 @@ const SETTINGS_GROUPS_BASE = [
items: [
{ key: 'about', title: '关于我们', icon: 'information-outline', showArrow: true, subtitle: `版本 ${APP_VERSION}` },
{ key: 'help', title: '帮助与反馈', icon: 'help-circle-outline', showArrow: true },
{ key: 'terms', title: '用户协议', icon: 'file-document-outline', showArrow: true },
{ key: 'privacy_policy', title: '隐私政策', icon: 'shield-outline', showArrow: true },
],
},
];
@@ -240,31 +237,18 @@ export const SettingsScreen: React.FC = () => {
);
break;
}
case 'language': {
Alert.alert(
'语言设置',
'选择应用显示语言',
[
{ text: '取消', style: 'cancel' },
{ text: '简体中文', onPress: () => {} },
{ text: '繁體中文', onPress: () => {} },
{ text: 'English', onPress: () => {} },
]
);
break;
}
case 'data_storage': {
Alert.alert('数据与存储', '缓存清理功能即将上线!');
break;
}
case 'terms': {
Alert.alert('用户协议', '用户协议页面即将上线!');
case 'about':
router.push(hrefs.hrefProfileAbout());
break;
}
case 'privacy_policy': {
Alert.alert('隐私政策', '隐私政策页面即将上线!');
case 'help':
Alert.alert('帮助与反馈', '帮助中心即将上线!');
break;
}
case 'edit_profile':
router.push(hrefs.hrefProfileEdit());
break;
@@ -372,7 +356,7 @@ export const SettingsScreen: React.FC = () => {
v{APP_VERSION}
</Text>
<Text variant="caption" color={colors.text.hint} style={styles.copyright}>
© 2024 Carrot BBS. All rights reserved.
© 2026
</Text>
</View>
</>

View File

@@ -0,0 +1,248 @@
/**
* 用户协议页面 TermsOfServiceScreen
* 胡萝卜BBS - 用户服务协议
* 扁平化设计风格
*/
import React, { useMemo } from 'react';
import {
View,
StyleSheet,
ScrollView,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import {
useAppColors,
spacing,
type AppColors,
} from '../../theme';
import { Text } from '../../components/common';
import { useResponsive, useResponsiveSpacing } from '../../hooks';
// 内容最大宽度
const CONTENT_MAX_WIDTH = 400;
// 胡萝卜橙主题色
const THEME_COLORS = {
primary: '#FF6B35',
};
// 协议最后更新日期
const LAST_UPDATED = '2026年4月4日';
// 协议条款内容
const TERMS_SECTIONS = [
{
title: '一、协议的范围',
content: `1.1 本协议是您与萝卜社区(以下简称"我们"或"本应用")之间关于您使用本应用服务所订立的协议。
1.2 本协议内容包括协议正文及所有我们已经发布或将来可能发布的各类规则、公告或通知。所有规则为本协议不可分割的组成部分,与协议正文具有同等法律效力。
1.3 我们有权根据法律法规的变化及业务调整的需要,不时修改本协议及各类规则。修改后的协议将在应用内公示。如您不同意变更后的内容,应立即停止使用本服务。如您继续使用本服务,即视为您已阅读并同意受变更后的协议约束。`,
},
{
title: '二、账号注册与使用',
content: `2.1 您确认在您开始注册程序使用本应用服务前您应当具备中华人民共和国法律规定的与您行为相适应的民事行为能力。若您是未满18周岁的未成年人应在监护人监护、指导下并获得监护人同意后使用本应用服务。
2.2 您可以使用手机号、邮箱进行注册(具体以应用实际提供的注册方式为准)。您应当提供真实、准确、完整、合法有效的注册信息,并在信息变更时及时更新。
2.3 您的账号仅限于您本人使用,禁止赠与、借用、租用、转让或售卖。我们有权对账号进行安全验证,如发现异常登录或使用情况,有权采取相应措施。
2.4 您应妥善保管账号和密码,对账号下的所有行为承担法律责任。因您保管不善导致的账号被盗或密码泄露,我们不承担责任。`,
},
{
title: '三、服务内容',
content: `3.1 本应用向您提供校园社交服务,包括但不限于:发布动态、评论互动、私信聊天、群组交流、学习资料分享等功能。
3.2 我们有权根据实际情况随时调整服务内容,包括但不限于增加、删除或修改功能。服务调整将在应用内进行公告。
3.3 我们致力于为您提供优质的服务,但不保证服务不会中断,对服务的及时性、安全性、准确性不作担保。`,
},
{
title: '四、用户行为规范',
content: `4.1 您在使用本应用服务时,必须遵守中华人民共和国相关法律法规,不得利用本应用从事违法违规活动。
4.2 您不得发布、传播以下内容:
1反对宪法所确定的基本原则的
2危害国家安全泄露国家秘密颠覆国家政权破坏国家统一的
3损害国家荣誉和利益的
4煽动民族仇恨、民族歧视破坏民族团结的
5破坏国家宗教政策宣扬邪教和封建迷信的
6散布谣言扰乱社会秩序破坏社会稳定的
7散布淫秽、色情、赌博、暴力、凶杀、恐怖或者教唆犯罪的
8侮辱或者诽谤他人侵害他人合法权益的
9含有法律、行政法规禁止的其他内容的。
4.3 您不得从事以下行为:
1冒充他人或使用虚假身份
2恶意注册账号包括但不限于批量注册、使用非法手段注册
3利用技术手段批量发布内容或进行其他操作
4干扰或破坏本应用服务或服务器
5侵犯他人知识产权、隐私权等合法权益
6发布垃圾信息、广告信息或进行其他骚扰行为。
4.4 若您是未满18周岁的未成年人应在监护人监护、指导下并获得监护人同意后使用本应用服务。我们非常重视对未成年人个人信息的保护若您是未成年人的监护人请监督您的被监护人使用本服务。
4.5 若您认为本应用内的内容侵犯了您的合法权益,或您发现任何违法违规内容,请通过 system@qczlit.cn 联系我们进行投诉举报,我们将在收到通知后依法尽快处理。`,
},
{
title: '五、知识产权',
content: `5.1 本应用的商标、标识、界面设计、程序代码、数据库等所有知识产权均归我们及我们的关联公司所有或已获得合法授权。
5.2 您在使用本应用服务过程中产生的内容(包括但不限于文字、图片、视频等),您保留对该内容的知识产权。您授予我们一项全球范围内、免费、非独家、可转授权的许可,允许我们使用、复制、修改、改编、出版、翻译、据以创作衍生作品、传播、表演和展示此等内容,以及用于本应用的运营、推广、宣传和商业用途。
5.3 未经我们书面许可,您不得复制、修改、传播、出售、出租本应用或其任何部分,不得对本应用进行反向工程、反编译或试图以其他方式发现其源代码。`,
},
{
title: '六、隐私保护',
content: `6.1 我们非常重视您的隐私保护,具体隐私政策请参见《隐私政策》。
6.2 您同意我们可以按照《隐私政策》的规定收集、使用、存储和分享您的个人信息。
6.3 我们将采取合理的技术和管理措施保护您的个人信息安全,但不对因不可抗力或第三方原因导致的信息泄露承担责任。`,
},
{
title: '七、免责声明',
content: `7.1 您理解并同意,本应用服务是按照现有技术和条件所能达到的现状提供的。我们会尽最大努力确保服务的连贯性和安全性,但不能随时预见和防范法律、技术以及其他风险。
7.2 您理解并同意,我们对以下情形不承担责任:
1因不可抗力导致的服务中断或数据丢失
2因您的过错导致的任何损失
3因第三方原因导致的服务中断或信息泄露
4其他非因我们原因导致的损失。
7.3 您通过本应用与其他用户进行交往或交易,由此产生的任何纠纷由您自行解决并承担责任。`,
},
{
title: '八、违约责任',
content: `8.1 如果我们发现或收到他人举报您违反本协议约定的,我们有权根据情节轻重对相关内容进行删除、屏蔽,并视行为情节对违规账号处以包括但不限于警告、限制功能、暂停使用、永久封禁等处罚。因法律法规要求或紧急情况下,我们可能在不提前通知的情况下采取上述措施。
8.2 账号被封禁或注销后,账号内的虚拟资产(包括但不限于积分、虚拟币、会员权益等)将不予退还或清零,您不得因此向我们主张任何赔偿或补偿。
8.3 因您违反本协议或相关规则,导致或产生任何第三方主张的任何索赔、要求或损失,您应当独立承担责任,并赔偿我们因此遭受的损失。`,
},
{
title: '九、协议的终止',
content: `9.1 您有权随时终止使用本应用服务,可以通过注销账号的方式终止本协议。
9.2 我们有权在以下情况下终止本协议:
1您违反本协议或相关规则
2根据法律法规或政府部门要求
3因不可抗力导致无法提供服务。
9.3 协议终止后,我们有权保留或删除您的账号信息及内容,且不承担任何责任。`,
},
{
title: '十、其他',
content: `10.1 本协议的订立、执行和解释及争议的解决均应适用中华人民共和国法律。
10.2 因本协议引起的或与本协议有关的任何争议,由协议各方协商解决,也可由有关部门调解。协商或调解不成的,应向山东省威海市环翠区人民法院提起诉讼。
10.3 本协议中的任何条款无论因何种原因完全或部分无效或不具有执行力,本协议的其余条款仍应有效并且有约束力。`,
},
];
function createTermsStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
scrollContent: {
paddingHorizontal: 28,
paddingTop: 24,
},
// 内容区域
content: {
maxWidth: CONTENT_MAX_WIDTH,
alignSelf: 'center',
width: '100%',
},
// 更新日期
lastUpdated: {
fontSize: 13,
color: colors.text.hint,
marginBottom: 20,
},
// 协议内容卡片
termsCard: {
backgroundColor: '#F5F5F7',
borderRadius: 16,
padding: 20,
marginBottom: 24,
},
section: {
marginBottom: 24,
},
sectionLast: {
marginBottom: 0,
},
sectionTitle: {
fontSize: 16,
fontWeight: '600',
color: THEME_COLORS.primary,
marginBottom: 12,
},
sectionContent: {
fontSize: 14,
lineHeight: 24,
color: colors.text.primary,
},
// 底部版权
footer: {
alignItems: 'center',
},
footerText: {
fontSize: 13,
color: colors.text.hint,
textAlign: 'center',
marginBottom: 4,
},
});
}
export const TermsOfServiceScreen: React.FC = () => {
const colors = useAppColors();
const styles = useMemo(() => createTermsStyles(colors), [colors]);
const insets = useSafeAreaInsets();
const { isMobile } = useResponsive();
const responsivePadding = useResponsiveSpacing({ xs: 20, sm: 24, md: 28, lg: 32, xl: 40 });
// 底部间距,避免被 TabBar 遮挡
const scrollBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.lg;
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<ScrollView
contentContainerStyle={[styles.scrollContent, { paddingHorizontal: responsivePadding, paddingBottom: scrollBottomInset }]}
showsVerticalScrollIndicator={false}
>
<View style={styles.content}>
{/* 更新日期 */}
<Text style={styles.lastUpdated}>{LAST_UPDATED}</Text>
{/* 协议内容 */}
<View style={styles.termsCard}>
{TERMS_SECTIONS.map((section, index) => (
<View key={index} style={[styles.section, index === TERMS_SECTIONS.length - 1 && styles.sectionLast]}>
<Text style={styles.sectionTitle}>{section.title}</Text>
<Text style={styles.sectionContent}>{section.content}</Text>
</View>
))}
</View>
{/* 底部版权 */}
<View style={styles.footer}>
<Text style={styles.footerText}>© 2026 </Text>
</View>
</View>
</ScrollView>
</SafeAreaView>
);
};
export default TermsOfServiceScreen;

View File

@@ -14,6 +14,9 @@ export { default as FollowListScreen } from './FollowListScreen';
export { NotificationSettingsScreen } from './NotificationSettingsScreen';
export { BlockedUsersScreen } from './BlockedUsersScreen';
export { AccountSecurityScreen } from './AccountSecurityScreen';
export { AboutScreen } from './AboutScreen';
export { TermsOfServiceScreen } from './TermsOfServiceScreen';
export { PrivacyPolicyScreen } from './PrivacyPolicyScreen';
// 导出 Hook 供需要自定义的场景使用
export { useUserProfile, createSharedProfileStyles, TABS, TAB_ICONS } from './useUserProfile';

View File

@@ -16,6 +16,7 @@ import {
TextInput,
Alert,
ActivityIndicator,
Platform,
} from 'react-native';
import { PanGestureHandler, State } from 'react-native-gesture-handler';
import type { PanGestureHandlerStateChangeEvent } from 'react-native-gesture-handler';
@@ -212,6 +213,13 @@ export const ScheduleScreen: React.FC = () => {
const [syncPassword, setSyncPassword] = useState('');
const [isSyncing, setIsSyncing] = useState(false);
useEffect(() => {
if ((isAddModalVisible || isSettingsModalVisible || isSyncModalVisible) && Platform.OS === 'web') {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
active?.blur?.();
}
}, [isAddModalVisible, isSettingsModalVisible, isSyncModalVisible]);
// 组件卸载时设置标记
useEffect(() => {
return () => {

View File

@@ -1,767 +0,0 @@
/**
* 本地数据库服务
* 使用 SQLite 存储聊天记录
* 每个用户使用独立的数据库文件,避免数据串用
*
* Web/OPFS 关键设计:
* - expo-sqlite 在 Web 上使用 OPFS Worker文件句柄严格唯一
* - 数据库打开后永不关闭(直到用户切换或登出)
* - 使用全局单例 + 互斥锁,避免并发打开
* - React Strict Mode 双次 useEffect 会被幂等处理
*/
import * as SQLite from 'expo-sqlite';
import type {
UserDTO,
GroupResponse,
GroupMemberResponse,
ConversationResponse,
ConversationDetailResponse,
} from '../types/dto';
let db: SQLite.SQLiteDatabase | null = null;
let currentDbUserId: string | null = null;
let writeQueue: Promise<void> = Promise.resolve();
let initPromise: Promise<void> | null = null;
let initTargetUserId: string | null = null;
let isInitialized = false;
let dbMutex: Promise<void> = Promise.resolve();
const ensureInitialized = async (): Promise<void> => {
if (isInitialized && db) return;
if (initPromise) {
await initPromise;
return;
}
throw new Error('数据库未初始化');
};
async function withMutex<T>(fn: () => Promise<T>): Promise<T> {
const prev = dbMutex;
let release: () => void = () => {};
dbMutex = new Promise<void>((r) => { release = r; });
try {
await prev;
return await fn();
} finally {
release();
}
}
async function openDatabaseWithRetry(dbName: string, maxRetries: number = 3): Promise<SQLite.SQLiteDatabase> {
const isWeb = typeof window !== 'undefined';
if (isWeb) {
try {
const tempDb = await SQLite.openDatabaseAsync(dbName);
await tempDb.closeAsync();
console.log('[Database] Web: 预打开成功,已释放可能残留的句柄');
} catch (e) {
console.warn('[Database] Web: 预打开失败:', String(e));
}
await new Promise(r => setTimeout(r, 50));
}
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const database = await SQLite.openDatabaseAsync(dbName);
return database;
} catch (error) {
const msg = String(error);
console.error(`[Database] 打开失败 (尝试 ${attempt + 1}/${maxRetries + 1}):`, msg);
if (attempt < maxRetries) {
const delay = isWeb ? 500 * (attempt + 1) : 100;
console.warn(`[Database] ${delay}ms 后重试...`);
await new Promise(r => setTimeout(r, delay));
if (msg.includes('Invalid VFS state')) {
try {
await SQLite.deleteDatabaseAsync(dbName);
console.warn('[Database] 已删除损坏的数据库文件');
} catch {}
}
} else {
throw error;
}
}
}
throw new Error('打开数据库失败');
}
async function createTables(database: SQLite.SQLiteDatabase): Promise<void> {
if (typeof window !== 'undefined') {
try {
// 关键:使用 EXCLUSIVE 锁定模式,避免 OPFS 多句柄冲突
// MEMORY journal 避免 journal 文件创建
// MEMORY temp_store 避免 temp 文件创建
await database.execAsync(`PRAGMA locking_mode = EXCLUSIVE;`);
await database.execAsync(`PRAGMA journal_mode = MEMORY;`);
await database.execAsync(`PRAGMA temp_store = MEMORY;`);
console.log('[Database] Web: 已设置 EXCLUSIVE locking + MEMORY journal/temp 模式');
} catch (e) {
console.warn('[Database] 设置 PRAGMA 失败:', e);
}
}
// 拆分为单独的 execAsync 调用,避免 OPFS 句柄冲突
await database.execAsync(`CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY NOT NULL,
conversationId TEXT NOT NULL,
senderId TEXT NOT NULL,
content TEXT,
type TEXT DEFAULT 'text',
isRead INTEGER DEFAULT 0,
createdAt TEXT NOT NULL,
seq INTEGER DEFAULT 0,
status TEXT DEFAULT 'normal',
segments TEXT
)`);
await database.execAsync(`CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY NOT NULL,
participantId TEXT NOT NULL,
lastMessageId TEXT,
lastSeq INTEGER DEFAULT 0,
unreadCount INTEGER DEFAULT 0,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`);
await database.execAsync(`CREATE TABLE IF NOT EXISTS conversation_cache (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`);
await database.execAsync(`CREATE TABLE IF NOT EXISTS conversation_list_cache (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`);
await database.execAsync(`CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`);
await database.execAsync(`CREATE TABLE IF NOT EXISTS current_user_cache (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`);
await database.execAsync(`CREATE TABLE IF NOT EXISTS groups (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`);
await database.execAsync(`CREATE TABLE IF NOT EXISTS group_members (
groupId TEXT NOT NULL,
userId TEXT NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL,
PRIMARY KEY (groupId, userId)
)`);
await database.execAsync(`CREATE INDEX IF NOT EXISTS idx_messages_conversationId ON messages(conversationId)`);
await database.execAsync(`CREATE INDEX IF NOT EXISTS idx_group_members_groupId ON group_members(groupId)`);
await migrateDatabase(database);
await database.execAsync(`CREATE INDEX IF NOT EXISTS idx_messages_seq ON messages(seq)`);
await database.execAsync(`CREATE INDEX IF NOT EXISTS idx_messages_conversation_seq ON messages(conversationId, seq)`);
}
async function migrateDatabase(database: SQLite.SQLiteDatabase): Promise<void> {
try {
const tableInfo = await database.getAllAsync<any>(`PRAGMA table_info(messages)`);
const columns = tableInfo.map(col => col.name);
if (!columns.includes('seq')) {
await database.execAsync(`ALTER TABLE messages ADD COLUMN seq INTEGER DEFAULT 0`);
}
if (!columns.includes('status')) {
await database.execAsync(`ALTER TABLE messages ADD COLUMN status TEXT DEFAULT 'normal'`);
}
if (!columns.includes('segments')) {
await database.execAsync(`ALTER TABLE messages ADD COLUMN segments TEXT`);
}
const convTableInfo = await database.getAllAsync<any>(`PRAGMA table_info(conversations)`);
const convColumns = convTableInfo.map(col => col.name);
if (!convColumns.includes('lastSeq')) {
await database.execAsync(`ALTER TABLE conversations ADD COLUMN lastSeq INTEGER DEFAULT 0`);
}
} catch (error) {
console.error('[Database] 迁移失败:', error);
}
}
export const initDatabase = async (userId?: string): Promise<void> => {
const targetUserId = userId || null;
const callId = Date.now();
console.log(`[Database ${callId}] initDatabase 开始, userId=${targetUserId}, 当前db=${!!db}, 当前userId=${currentDbUserId}`);
if (db && currentDbUserId === targetUserId) {
console.log(`[Database ${callId}] 已是目标数据库,直接返回`);
isInitialized = true;
return;
}
if (initPromise && initTargetUserId === targetUserId) {
console.log(`[Database ${callId}] 等待进行中的初始化`);
await initPromise;
console.log(`[Database ${callId}] 初始化完成`);
return;
}
if (initPromise) {
console.log(`[Database ${callId}] 等待其他初始化完成`);
await initPromise.catch(() => {});
}
if (db && currentDbUserId === targetUserId) {
console.log(`[Database ${callId}] 其他初始化已完成目标,直接返回`);
isInitialized = true;
return;
}
initTargetUserId = targetUserId;
console.log(`[Database ${callId}] 开始实际初始化`);
initPromise = withMutex(async () => {
const mutexId = callId;
console.log(`[Database ${mutexId}] 获得互斥锁`);
if (db && currentDbUserId === targetUserId) {
console.log(`[Database ${mutexId}] 互斥锁内检查:已是目标数据库`);
isInitialized = true;
return;
}
const isWeb = typeof window !== 'undefined';
if (db) {
console.log(`[Database ${mutexId}] 关闭旧数据库连接...`);
try {
await db.closeAsync();
console.log(`[Database ${mutexId}] 旧连接已关闭`);
} catch (e) {
console.warn(`[Database ${mutexId}] 关闭旧连接失败:`, e);
}
db = null;
currentDbUserId = null;
isInitialized = false;
if (isWeb) {
await new Promise(r => setTimeout(r, 100));
}
}
const dbName = targetUserId ? `carrot_bbs_${targetUserId}.db` : 'carrot_bbs.db';
console.log(`[Database ${mutexId}] 打开数据库: ${dbName}`);
const database = await openDatabaseWithRetry(dbName);
console.log(`[Database ${mutexId}] 数据库已打开,创建表`);
await createTables(database);
console.log(`[Database ${mutexId}] 表创建完成`);
db = database;
currentDbUserId = targetUserId;
isInitialized = true;
console.log(`[Database ${mutexId}] 初始化完成`);
}).finally(() => {
console.log(`[Database ${callId}] 清理初始化状态`);
initPromise = null;
initTargetUserId = null;
});
await initPromise;
console.log(`[Database ${callId}] initDatabase 返回`);
};
export const closeDatabase = async (): Promise<void> => {
if (initPromise) {
await initPromise.catch(() => {});
}
await withMutex(async () => {
if (!db) return;
try {
await db.closeAsync();
console.log('[Database] 数据库已关闭');
} catch (e) {
console.warn('[Database] 关闭失败:', e);
}
db = null;
currentDbUserId = null;
isInitialized = false;
});
};
export const getCurrentDbUserId = (): string | null => currentDbUserId;
export const isDbInitialized = (): boolean => isInitialized && db !== null;
export const getDbInstance = (): SQLite.SQLiteDatabase | null => db;
const getDb = async (timeout: number = 10000): Promise<SQLite.SQLiteDatabase> => {
if (isInitialized && db) return db;
if (initPromise) {
const timeoutPromise = new Promise<null>((_, reject) =>
setTimeout(() => reject(new Error('等待数据库初始化超时')), timeout)
);
await Promise.race([initPromise, timeoutPromise]);
if (db) return db;
}
throw new Error('数据库未初始化,请先调用 initDatabase(userId)');
};
const enqueueWrite = async <T>(op: (db: SQLite.SQLiteDatabase) => Promise<T>): Promise<T> => {
let result: T;
const wrapped = async () => {
result = await withMutex(async () => {
const database = await getDb();
return op(database);
});
};
const queued = writeQueue.then(wrapped, wrapped);
writeQueue = queued.then(() => {}, () => {});
await queued;
return result!;
};
const withDbRead = async <T>(op: (db: SQLite.SQLiteDatabase) => Promise<T>): Promise<T> => {
return withMutex(async () => {
const database = await getDb();
return op(database);
});
};
export interface CachedMessage {
id: string;
conversationId: string;
senderId: string;
content?: string;
type?: string;
isRead: boolean;
createdAt: string;
seq: number;
status: string;
segments?: any;
}
export const saveMessage = async (message: CachedMessage): Promise<void> => {
await enqueueWrite(async (database) => {
await database.runAsync(
`INSERT OR REPLACE INTO messages (id, conversationId, senderId, content, type, isRead, createdAt, seq, status, segments)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[message.id, message.conversationId, message.senderId, message.content || '', message.type || 'text',
message.isRead ? 1 : 0, message.createdAt, message.seq || 0, message.status || 'normal',
message.segments ? JSON.stringify(message.segments) : null]
);
});
};
export const saveMessagesBatch = async (messages: CachedMessage[]): Promise<void> => {
if (!messages?.length) return;
await enqueueWrite(async (database) => {
for (const msg of messages) {
await database.runAsync(
`INSERT OR REPLACE INTO messages (id, conversationId, senderId, content, type, isRead, createdAt, seq, status, segments)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[msg.id, msg.conversationId, msg.senderId, msg.content || '', msg.type || 'text',
msg.isRead ? 1 : 0, msg.createdAt, msg.seq || 0, msg.status || 'normal',
msg.segments ? JSON.stringify(msg.segments) : null]
);
}
});
};
export const getMessagesByConversation = async (conversationId: string, limit: number = 20): Promise<CachedMessage[]> => {
return withDbRead(async (database) => {
const rows = await database.getAllAsync<any>(
`SELECT * FROM messages WHERE conversationId = ? ORDER BY seq DESC LIMIT ?`,
[conversationId, limit]
);
return rows.reverse().map(r => ({
...r, isRead: r.isRead === 1, segments: r.segments ? JSON.parse(r.segments) : undefined
}));
});
};
export const getMaxSeq = async (conversationId: string): Promise<number> => {
return withDbRead(async (database) => {
const r = await database.getFirstAsync<{ maxSeq: number }>(
`SELECT MAX(seq) as maxSeq FROM messages WHERE conversationId = ?`, [conversationId]
);
return r?.maxSeq || 0;
});
};
export const getMinSeq = async (conversationId: string): Promise<number> => {
return withDbRead(async (database) => {
const r = await database.getFirstAsync<{ minSeq: number }>(
`SELECT MIN(seq) as minSeq FROM messages WHERE conversationId = ?`, [conversationId]
);
return r?.minSeq || 0;
});
};
export const getMessagesBeforeSeq = async (conversationId: string, beforeSeq: number, limit: number = 20): Promise<CachedMessage[]> => {
return withDbRead(async (database) => {
const rows = await database.getAllAsync<any>(
`SELECT * FROM messages WHERE conversationId = ? AND seq < ? ORDER BY seq DESC LIMIT ?`,
[conversationId, beforeSeq, limit]
);
return rows.map(r => ({ ...r, isRead: r.isRead === 1, segments: r.segments ? JSON.parse(r.segments) : undefined })).reverse();
});
};
export const getLocalMessageCountBeforeSeq = async (conversationId: string, beforeSeq: number): Promise<number> => {
return withDbRead(async (database) => {
const r = await database.getFirstAsync<{ count: number }>(
`SELECT COUNT(*) as count FROM messages WHERE conversationId = ? AND seq < ?`, [conversationId, beforeSeq]
);
return r?.count || 0;
});
};
export const getMessageCount = async (conversationId: string): Promise<number> => {
return withDbRead(async (database) => {
const r = await database.getFirstAsync<{ count: number }>(
`SELECT COUNT(*) as count FROM messages WHERE conversationId = ?`, [conversationId]
);
return r?.count || 0;
});
};
export const markMessageAsRead = async (messageId: string): Promise<void> => {
await enqueueWrite(async (db) => {
await db.runAsync(`UPDATE messages SET isRead = 1 WHERE id = ?`, [messageId]);
});
};
export const markConversationAsRead = async (conversationId: string): Promise<void> => {
await enqueueWrite(async (db) => {
await db.runAsync(`UPDATE messages SET isRead = 1 WHERE conversationId = ?`, [conversationId]);
});
};
export const deleteMessage = async (messageId: string): Promise<void> => {
await enqueueWrite(async (db) => {
await db.runAsync(`DELETE FROM messages WHERE id = ?`, [messageId]);
});
};
export const updateMessageStatus = async (messageId: string, status: string, clearContent: boolean = false): Promise<void> => {
await enqueueWrite(async (db) => {
if (clearContent) {
await db.runAsync(`UPDATE messages SET status = ?, content = '', segments = '[]' WHERE id = ?`, [status, messageId]);
} else {
await db.runAsync(`UPDATE messages SET status = ? WHERE id = ?`, [status, messageId]);
}
});
};
export const clearConversationMessages = async (conversationId: string): Promise<void> => {
await enqueueWrite(async (db) => {
await db.runAsync(`DELETE FROM messages WHERE conversationId = ?`, [conversationId]);
});
};
export const saveConversation = async (conv: {
id: string; participantId: string; lastMessageId?: string; lastSeq?: number; unreadCount?: number;
createdAt: string; updatedAt: string;
}): Promise<void> => {
await enqueueWrite(async (db) => {
await db.runAsync(
`INSERT OR REPLACE INTO conversations (id, participantId, lastMessageId, lastSeq, unreadCount, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[conv.id, conv.participantId, conv.lastMessageId || null, conv.lastSeq || 0, conv.unreadCount || 0, conv.createdAt, conv.updatedAt]
);
});
};
export const getAllConversations = async (): Promise<any[]> => {
return withDbRead(async (db) => db.getAllAsync<any>(`SELECT * FROM conversations ORDER BY updatedAt DESC`));
};
export const updateConversationUnreadCount = async (id: string, count: number): Promise<void> => {
await enqueueWrite(async (db) => {
await db.runAsync(`UPDATE conversations SET unreadCount = ? WHERE id = ?`, [count, id]);
});
};
export const updateConversationLastMessage = async (id: string, lastMessageId: string, lastSeq: number, updatedAt: string): Promise<void> => {
await enqueueWrite(async (db) => {
await db.runAsync(`UPDATE conversations SET lastMessageId = ?, lastSeq = ?, updatedAt = ? WHERE id = ?`,
[lastMessageId, lastSeq, updatedAt, id]);
});
};
export const deleteConversation = async (conversationId: string): Promise<void> => {
await enqueueWrite(async (db) => {
await db.runAsync(`DELETE FROM messages WHERE conversationId = ?`, [conversationId]);
await db.runAsync(`DELETE FROM conversations WHERE id = ?`, [conversationId]);
});
};
export const searchMessages = async (keyword: string): Promise<CachedMessage[]> => {
return withDbRead(async (db) => {
const rows = await db.getAllAsync<any>(
`SELECT * FROM messages WHERE content LIKE ? ORDER BY createdAt DESC LIMIT 50`, [`%${keyword}%`]
);
return rows.map(r => ({ ...r, isRead: r.isRead === 1, segments: r.segments ? JSON.parse(r.segments) : undefined }));
});
};
export const getDatabaseStats = async (): Promise<{ totalMessages: number; totalConversations: number }> => {
return withDbRead(async (db) => {
const msgCount = await db.getFirstAsync<{ count: number }>(`SELECT COUNT(*) as count FROM messages`);
const convCount = await db.getFirstAsync<{ count: number }>(`SELECT COUNT(*) as count FROM conversations`);
return { totalMessages: msgCount?.count || 0, totalConversations: convCount?.count || 0 };
});
};
export const clearAllData = async (): Promise<void> => {
await enqueueWrite(async (db) => {
await db.execAsync(`DELETE FROM messages`);
await db.execAsync(`DELETE FROM conversations`);
await db.execAsync(`DELETE FROM conversation_cache`);
await db.execAsync(`DELETE FROM conversation_list_cache`);
await db.execAsync(`DELETE FROM users`);
await db.execAsync(`DELETE FROM current_user_cache`);
await db.execAsync(`DELETE FROM groups`);
await db.execAsync(`DELETE FROM group_members`);
});
};
const safeParseJson = <T>(value: string): T | null => {
try { return JSON.parse(value) as T; } catch { return null; }
};
export const saveUserCache = async (user: UserDTO): Promise<void> => {
await enqueueWrite(async (db) => {
await db.runAsync(`INSERT OR REPLACE INTO users (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(user.id), JSON.stringify(user), new Date().toISOString()]);
});
};
export const saveUsersCache = async (users: UserDTO[]): Promise<void> => {
if (!users?.length) return;
await enqueueWrite(async (db) => {
const now = new Date().toISOString();
for (const u of users) {
await db.runAsync(`INSERT OR REPLACE INTO users (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(u.id), JSON.stringify(u), now]);
}
});
};
export const getUserCache = async (userId: string): Promise<UserDTO | null> => {
return withDbRead(async (db) => {
const r = await db.getFirstAsync<{ data: string }>(`SELECT data FROM users WHERE id = ?`, [String(userId)]);
return r?.data ? safeParseJson<UserDTO>(r.data) : null;
});
};
export const getLatestUserCache = async (): Promise<UserDTO | null> => {
return withDbRead(async (db) => {
const r = await db.getFirstAsync<{ data: string }>(`SELECT data FROM users ORDER BY updatedAt DESC LIMIT 1`);
return r?.data ? safeParseJson<UserDTO>(r.data) : null;
});
};
export const saveCurrentUserCache = async (user: UserDTO): Promise<void> => {
await enqueueWrite(async (db) => {
await db.runAsync(`INSERT OR REPLACE INTO current_user_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
['me', JSON.stringify(user), new Date().toISOString()]);
});
};
export const getCurrentUserCache = async (): Promise<UserDTO | null> => {
return withDbRead(async (db) => {
const r = await db.getFirstAsync<{ data: string }>(`SELECT data FROM current_user_cache WHERE id = 'me'`);
return r?.data ? safeParseJson<UserDTO>(r.data) : null;
});
};
export const clearCurrentUserCache = async (): Promise<void> => {
await enqueueWrite(async (db) => {
await db.runAsync(`DELETE FROM current_user_cache WHERE id = 'me'`);
});
};
export const saveGroupCache = async (group: GroupResponse): Promise<void> => {
await enqueueWrite(async (db) => {
await db.runAsync(`INSERT OR REPLACE INTO groups (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(group.id), JSON.stringify(group), new Date().toISOString()]);
});
};
export const saveGroupsCache = async (groups: GroupResponse[]): Promise<void> => {
if (!groups?.length) return;
await enqueueWrite(async (db) => {
const now = new Date().toISOString();
for (const g of groups) {
await db.runAsync(`INSERT OR REPLACE INTO groups (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(g.id), JSON.stringify(g), now]);
}
});
};
export const getGroupCache = async (groupId: string): Promise<GroupResponse | null> => {
return withDbRead(async (db) => {
const r = await db.getFirstAsync<{ data: string }>(`SELECT data FROM groups WHERE id = ?`, [String(groupId)]);
return r?.data ? safeParseJson<GroupResponse>(r.data) : null;
});
};
export const getAllGroupsCache = async (): Promise<GroupResponse[]> => {
return withDbRead(async (db) => {
const rows = await db.getAllAsync<{ data: string }>(`SELECT data FROM groups ORDER BY updatedAt DESC`);
return rows.map(r => safeParseJson<GroupResponse>(r.data)).filter((g): g is GroupResponse => Boolean(g));
});
};
export const saveGroupMembersCache = async (groupId: string, members: GroupMemberResponse[]): Promise<void> => {
await enqueueWrite(async (db) => {
const now = new Date().toISOString();
await db.runAsync(`DELETE FROM group_members WHERE groupId = ?`, [String(groupId)]);
for (const m of members) {
await db.runAsync(`INSERT OR REPLACE INTO group_members (groupId, userId, data, updatedAt) VALUES (?, ?, ?, ?)`,
[String(groupId), String(m.user_id), JSON.stringify(m), now]);
}
});
};
export const getGroupMembersCache = async (groupId: string): Promise<GroupMemberResponse[]> => {
return withDbRead(async (db) => {
const rows = await db.getAllAsync<{ data: string }>(
`SELECT data FROM group_members WHERE groupId = ? ORDER BY updatedAt DESC`, [String(groupId)]
);
return rows.map(r => safeParseJson<GroupMemberResponse>(r.data)).filter((m): m is GroupMemberResponse => Boolean(m));
});
};
type ConversationCacheData = ConversationResponse | ConversationDetailResponse;
export const saveConversationCache = async (conv: ConversationCacheData): Promise<void> => {
await enqueueWrite(async (db) => {
const updatedAt = ('updated_at' in conv && conv.updated_at) ? String(conv.updated_at) : new Date().toISOString();
await db.runAsync(`INSERT OR REPLACE INTO conversation_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(conv.id), JSON.stringify(conv), updatedAt]);
});
};
export const saveConversationsCache = async (convs: ConversationResponse[]): Promise<void> => {
if (!convs?.length) return;
await enqueueWrite(async (db) => {
for (const c of convs) {
await db.runAsync(`INSERT OR REPLACE INTO conversation_list_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(c.id), JSON.stringify(c), c.updated_at || new Date().toISOString()]);
}
});
};
export const getConversationCache = async (id: string): Promise<ConversationCacheData | null> => {
return withDbRead(async (db) => {
const r = await db.getFirstAsync<{ data: string }>(`SELECT data FROM conversation_cache WHERE id = ?`, [String(id)]);
return r?.data ? safeParseJson<ConversationCacheData>(r.data) : null;
});
};
export const getConversationListCache = async (): Promise<ConversationResponse[]> => {
return withDbRead(async (db) => {
let rows = await db.getAllAsync<{ data: string }>(`SELECT data FROM conversation_list_cache ORDER BY updatedAt DESC`);
if (!rows.length) {
rows = await db.getAllAsync<{ data: string }>(`SELECT data FROM conversation_cache ORDER BY updatedAt DESC`);
}
const list = rows.map(r => safeParseJson<ConversationResponse>(r.data))
.filter((c): c is ConversationResponse => Boolean(c))
.filter(c => typeof c.id !== 'undefined' && typeof c.type !== 'undefined');
return list.sort((a, b) => {
const aPinned = a.is_pinned ? 1 : 0;
const bPinned = b.is_pinned ? 1 : 0;
if (aPinned !== bPinned) return bPinned - aPinned;
const aTime = new Date(a.last_message_at || a.updated_at || 0).getTime();
const bTime = new Date(b.last_message_at || b.updated_at || 0).getTime();
return bTime - aTime;
});
});
};
export const updateConversationListCacheEntry = async (id: string, updates: Partial<ConversationResponse>): Promise<void> => {
await enqueueWrite(async (db) => {
const r = await db.getFirstAsync<{ data: string }>(`SELECT data FROM conversation_list_cache WHERE id = ?`, [String(id)]);
if (!r?.data) return;
const conv = safeParseJson<ConversationResponse>(r.data);
if (!conv) return;
const updated = { ...conv, ...updates };
const updatedAt = updates.last_message_at || updated.last_message_at || new Date().toISOString();
await db.runAsync(`UPDATE conversation_list_cache SET data = ?, updatedAt = ? WHERE id = ?`,
[JSON.stringify(updated), updatedAt, String(id)]);
});
};
export const updateConversationCacheUnreadCount = async (id: string, count: number, myLastReadSeq?: number): Promise<void> => {
await enqueueWrite(async (db) => {
const listRow = await db.getFirstAsync<{ data: string }>(`SELECT data FROM conversation_list_cache WHERE id = ?`, [String(id)]);
if (listRow?.data) {
const conv = safeParseJson<ConversationResponse>(listRow.data);
if (conv) {
conv.unread_count = count;
if (typeof myLastReadSeq === 'number' && !Number.isNaN(myLastReadSeq)) conv.my_last_read_seq = myLastReadSeq;
await db.runAsync(`UPDATE conversation_list_cache SET data = ? WHERE id = ?`, [JSON.stringify(conv), String(id)]);
}
}
const cacheRow = await db.getFirstAsync<{ data: string }>(`SELECT data FROM conversation_cache WHERE id = ?`, [String(id)]);
if (cacheRow?.data) {
const conv = safeParseJson<Record<string, unknown>>(cacheRow.data);
if (conv) {
conv.unread_count = count;
if (typeof myLastReadSeq === 'number' && !Number.isNaN(myLastReadSeq)) conv.my_last_read_seq = myLastReadSeq;
await db.runAsync(`UPDATE conversation_cache SET data = ? WHERE id = ?`, [JSON.stringify(conv), String(id)]);
}
}
});
};
export const saveConversationsWithRelatedCache = async (
convs: ConversationResponse[], users?: UserDTO[], groups?: GroupResponse[]
): Promise<void> => {
if (!convs?.length) return;
const now = new Date().toISOString();
await enqueueWrite(async (db) => {
for (const c of convs) {
await db.runAsync(`INSERT OR REPLACE INTO conversation_list_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(c.id), JSON.stringify(c), c.updated_at || now]);
}
if (users?.length) {
for (const u of users) {
await db.runAsync(`INSERT OR REPLACE INTO users (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(u.id), JSON.stringify(u), now]);
}
}
if (groups?.length) {
for (const g of groups) {
await db.runAsync(`INSERT OR REPLACE INTO groups (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(g.id), JSON.stringify(g), now]);
}
}
});
};

View File

@@ -22,14 +22,7 @@ import {
CursorPaginationRequest,
CursorPaginationResponse,
} from '../types/dto';
import {
getConversationCache,
getConversationListCache,
saveConversationCache,
saveConversationsWithRelatedCache,
updateConversationCacheUnreadCount,
saveUsersCache,
} from './database';
import { conversationRepository, userCacheRepository } from '@/database';
/** 远端会话列表分页offset / cursor统一结果拉取成功后均已执行本地缓存同步 */
export interface RemoteConversationListPageResult {
@@ -55,7 +48,7 @@ class MessageService {
const groups = list
.map(conv => conv.group)
.filter((group): group is NonNullable<typeof group> => Boolean(group));
await saveConversationsWithRelatedCache(list, users, groups);
await conversationRepository.saveWithRelated(list, users, groups);
}
/** offset 会话列表单页:请求 + 落库 */
@@ -152,12 +145,12 @@ class MessageService {
const response = await api.get<ConversationDetailResponse>(
`/conversations/${encodeURIComponent(id)}`
);
await saveConversationCache(response.data);
await conversationRepository.saveCache(response.data);
if (response.data.participants?.length) {
await saveUsersCache(response.data.participants);
await userCacheRepository.saveBatch(response.data.participants);
}
if (response.data.last_message?.sender) {
await saveUsersCache([response.data.last_message.sender]);
await userCacheRepository.save(response.data.last_message.sender);
}
return response.data;
}
@@ -195,7 +188,7 @@ class MessageService {
): Promise<ConversationListResponse> {
// 如果强制刷新,直接从服务器获取,不使用缓存
if (!forceRefresh) {
const cachedList = await getConversationListCache();
const cachedList = await conversationRepository.getListCache();
if (cachedList.length > 0) {
// 后台刷新数据,但不阻塞当前返回
this.refreshConversationsFromServer(page, pageSize).catch(error => {
@@ -235,9 +228,9 @@ class MessageService {
const response = await api.post<ConversationResponse>('/conversations', {
user_id: userId,
});
await saveConversationCache(response.data);
await conversationRepository.saveCache(response.data);
if (response.data.participants?.length) {
await saveUsersCache(response.data.participants);
await userCacheRepository.saveBatch(response.data.participants);
}
return response.data;
}
@@ -247,7 +240,7 @@ class MessageService {
* GET /api/v1/conversations/:id
*/
async getConversationById(id: string): Promise<ConversationDetailResponse> {
const cached = await getConversationCache(id);
const cached = await conversationRepository.getCache(id);
if (cached) {
this.refreshConversationDetailFromServer(id).catch(error => {
console.error('后台刷新会话详情失败:', error);
@@ -564,7 +557,7 @@ class MessageService {
last_read_seq: Number(seq),
});
// 立即清零本地缓存中的 unread_count并写入已读游标避免冷启动仍显示旧红点
updateConversationCacheUnreadCount(conversationId, 0, Number(seq)).catch(error => {
conversationRepository.updateCacheUnreadCount(conversationId, 0, Number(seq)).catch(error => {
console.error('更新本地会话未读数失败:', error);
});
}

View File

@@ -20,10 +20,10 @@ import { useSessionStore } from './sessionStore';
import {
initDatabase,
closeDatabase,
saveUserCache,
saveCurrentUserCache,
clearCurrentUserCache,
} from '../services/database';
isDbInitialized,
getCurrentDbUserId,
} from '@/database';
import { userCacheRepository } from '@/database';
// AsyncStorage 中保存已登录用户 ID 的 key
const USER_ID_KEY = 'auth_user_id';
@@ -54,8 +54,8 @@ async function clearUserId(): Promise<void> {
// ── 写用户缓存DB 已就绪后调用)──
async function cacheUser(user: User): Promise<void> {
try {
await saveUserCache(user);
await saveCurrentUserCache(user);
await userCacheRepository.save(user);
await userCacheRepository.saveCurrent(user);
} catch (err) {
console.warn('[AuthStore] 写用户缓存失败:', err);
}
@@ -225,7 +225,7 @@ export const useAuthStore = create<AuthState>((set) => ({
// 3. 重置通话状态(清理所有事件订阅和定时器)
callStore.getState().reset();
// 4. 清除 DB 中的用户缓存DB 此时一定已初始化)
await clearCurrentUserCache().catch(() => {});
await userCacheRepository.clearCurrent().catch(() => {});
// 5. 关闭数据库连接
await closeDatabase();
// 6. 清除持久化的 userId
@@ -262,7 +262,7 @@ export const useAuthStore = create<AuthState>((set) => ({
try {
await initDatabase(savedUserId);
} catch (dbErr) {
console.warn('[AuthStore] DB 预初始化失败,将尝试在 API 成功后重新初始化:', dbErr);
console.warn('[AuthStore] DB 预初始化失败,将在 API 成功后重:', dbErr);
}
}
@@ -272,16 +272,21 @@ export const useAuthStore = create<AuthState>((set) => ({
if (user) {
const userId = String(user.id);
// 4. 若 userId 发生变化(极少数情况),重新初始化 DB 并更新持久化
if (!savedUserId || savedUserId !== userId) {
// 4. 确保 DB 已初始化(如果预初始化失败,这里会重试)
const dbReady = isDbInitialized() && getCurrentDbUserId() === userId;
if (!dbReady) {
await initDatabase(userId);
}
// 5. 更新持久化的 userId
if (!savedUserId || savedUserId !== userId) {
await saveUserId(userId);
}
// 5. DB 已就绪,更新用户缓存
// 6. DB 已就绪,更新用户缓存
await cacheUser(user);
// 6. 同步 userId 到 sessionStore
// 7. 同步 userId 到 sessionStore
useSessionStore.getState().setUserId(userId);
set({
@@ -290,7 +295,7 @@ export const useAuthStore = create<AuthState>((set) => ({
isLoading: false,
});
// 7. 启动 SSE
// 8. 启动 SSE
await startRealtime();
} else {
// Token 已失效或不存在

View File

@@ -81,6 +81,132 @@ export const CHAT_THEMES = [
textPrimary: '#1A1A1A',
textSecondary: '#666666',
},
{
id: 'coral',
name: '珊瑚橙',
primary: '#FF6B6B',
secondary: '#FFF0F0',
bubble: '#FFD4D4',
icon: '🐠',
card: '#FFFFFF',
inputBg: '#FFFFFF',
panelBg: '#FFF5F5',
headerBg: '#FFFFFF',
textPrimary: '#1A1A1A',
textSecondary: '#666666',
},
{
id: 'rose',
name: '玫瑰粉',
primary: '#E91E63',
secondary: '#FCE4EC',
bubble: '#F8BBD9',
icon: '🌹',
card: '#FFFFFF',
inputBg: '#FFFFFF',
panelBg: '#FCE4EC',
headerBg: '#FFFFFF',
textPrimary: '#1A1A1A',
textSecondary: '#666666',
},
{
id: 'indigo',
name: '靛蓝紫',
primary: '#3F51B5',
secondary: '#E8EAF6',
bubble: '#C5CAE9',
icon: '🐉',
card: '#FFFFFF',
inputBg: '#FFFFFF',
panelBg: '#E8EAF6',
headerBg: '#FFFFFF',
textPrimary: '#1A1A1A',
textSecondary: '#666666',
},
{
id: 'mint',
name: '薄荷绿',
primary: '#00BCD4',
secondary: '#E0F7FA',
bubble: '#B2EBF2',
icon: '🧊',
card: '#FFFFFF',
inputBg: '#FFFFFF',
panelBg: '#E0F7FA',
headerBg: '#FFFFFF',
textPrimary: '#1A1A1A',
textSecondary: '#666666',
},
{
id: 'cherry',
name: '樱桃红',
primary: '#D32F2F',
secondary: '#FFEBEE',
bubble: '#FFCDD2',
icon: '🍒',
card: '#FFFFFF',
inputBg: '#FFFFFF',
panelBg: '#FFEBEE',
headerBg: '#FFFFFF',
textPrimary: '#1A1A1A',
textSecondary: '#666666',
},
{
id: 'lavender',
name: '薰衣草',
primary: '#7E57C2',
secondary: '#EDE7F6',
bubble: '#D1C4E9',
icon: '💜',
card: '#FFFFFF',
inputBg: '#FFFFFF',
panelBg: '#EDE7F6',
headerBg: '#FFFFFF',
textPrimary: '#1A1A1A',
textSecondary: '#666666',
},
{
id: 'sunset',
name: '落日橙',
primary: '#FF5722',
secondary: '#FBE9E7',
bubble: '#FFCCBC',
icon: '🌅',
card: '#FFFFFF',
inputBg: '#FFFFFF',
panelBg: '#FBE9E7',
headerBg: '#FFFFFF',
textPrimary: '#1A1A1A',
textSecondary: '#666666',
},
{
id: 'ocean',
name: '深海蓝',
primary: '#006064',
secondary: '#B2EBF2',
bubble: '#80DEEA',
icon: '🌊',
card: '#FFFFFF',
inputBg: '#FFFFFF',
panelBg: '#E0F7FA',
headerBg: '#FFFFFF',
textPrimary: '#1A1A1A',
textSecondary: '#666666',
},
{
id: 'forest',
name: '森林绿',
primary: '#1B5E20',
secondary: '#C8E6C9',
bubble: '#A5D6A7',
icon: '🌳',
card: '#FFFFFF',
inputBg: '#FFFFFF',
panelBg: '#E8F5E9',
headerBg: '#FFFFFF',
textPrimary: '#1A1A1A',
textSecondary: '#666666',
},
] as const;
export type ChatThemeId = typeof CHAT_THEMES[number]['id'];

View File

@@ -4,7 +4,7 @@
import { ConversationResponse } from '../types/dto';
import { messageService } from '../services/messageService';
import { getConversationListCache } from '../services/database';
import { conversationRepository } from '@/database';
export const CONVERSATION_LIST_PAGE_SIZE = 20;
@@ -94,7 +94,7 @@ export class SqliteConversationListPagedSource implements IConversationListPaged
}
this.consumed = true;
try {
const items = await getConversationListCache();
const items = await conversationRepository.getListCache();
return { items, hasMore: false };
} catch (e) {
console.warn('[SqliteConversationListPagedSource] 读取会话列表缓存失败:', e);

View File

@@ -1,10 +1,3 @@
/**
* GroupManager - 蝢斤<E89DA2>蝞∠<E89D9E><E288A0><EFBFBD><E79285><EFBFBD><E59A97><EFBFBD><EFBFBD><EFBFBD>嚗?
*
* 雿輻鍂 Zustand store 餈𥡝<E9A488><F0A5A19D><EFBFBD><E59786><EFBFBD>?
* 靽脲<E99DBD>銝擧唂 API <20><><EFBFBD><EFBFBD>𤾸<EFBFBD>摰?
*/
import type {
GroupListResponse,
GroupMemberListResponse,
@@ -12,15 +5,7 @@ import type {
GroupResponse,
} from '../../types/dto';
import { groupService } from '../../services/groupService';
import {
getAllGroupsCache,
getGroupCache,
getGroupMembersCache,
saveGroupCache,
saveGroupMembersCache,
saveGroupsCache,
saveUsersCache,
} from '../../services/database';
import { groupCacheRepository, userCacheRepository } from '@/database';
import { useGroupManagerStore } from './groupStore';
import { createCacheEntry, isCacheExpired, DEFAULT_TTL } from '../utils/cache';
import {
@@ -33,8 +18,6 @@ import {
GROUP_MEMBER_LIST_PAGE_SIZE,
} from '../groupListSources';
// ==================== 颲<>𨭌<EFBFBD>賣㺭 ====================
function buildMembersKey(groupId: string, page: number, pageSize: number): string {
return `members:${groupId}:${page}:${pageSize}`;
}
@@ -63,14 +46,7 @@ function buildMemberListResponse(
};
}
// ==================== GroupManager 蝐?====================
class GroupManager {
// ==================== 蝢斤<E89DA2><E696A4>𡑒”<F0A19192><EFBFBD> ====================
/**
* <20><EFBFBD>蝢斤<E89DA2><E696A4>𡑒”
*/
async getGroups(
page = 1,
pageSize = GROUP_LIST_PAGE_SIZE,
@@ -79,20 +55,17 @@ class GroupManager {
const store = useGroupManagerStore.getState();
const entry = store.groupsListEntry;
// 蝚砌<E89D9A>憿萎<E686BF><E8908E><EFBFBD><E39787><EFBFBD><EFBFBD>摮?
if (page === 1 && !forceRefresh && entry && !isCacheExpired(entry)) {
return buildGroupListResponse(entry.data, pageSize);
}
// 蝚砌<E89D9A>憿萎<E686BF>蝻枏<E89DBB><EFBFBD><E9A488>嚗𡁜<E59A97><F0A1819C><EFBFBD><E595A3>?
if (page === 1 && !forceRefresh && entry && isCacheExpired(entry)) {
this.refreshGroupsInBackground(page, pageSize);
return buildGroupListResponse(entry.data, pageSize);
}
// 蝚砌<E89D9A>憿蛛<E686BF>撠肽<E692A0><E882BD>砍𧑐蝻枏<E89DBB>
if (page === 1 && !forceRefresh) {
const localGroups = await getAllGroupsCache();
const localGroups = await groupCacheRepository.getAll();
if (localGroups.length > 0) {
const newEntry = createCacheEntry(localGroups, DEFAULT_TTL.GROUP);
store.setGroupsListEntry(newEntry);
@@ -101,7 +74,6 @@ class GroupManager {
}
}
// <20>𤏸絲霂瑟<E99C82>
return this.dedupe(`groups:list:${page}:${pageSize}`, async () => {
const store = useGroupManagerStore.getState();
const response = await groupService.getGroups(page, pageSize);
@@ -109,7 +81,7 @@ class GroupManager {
if (page === 1) {
store.setGroupsList(groups);
}
await saveGroupsCache(groups).catch(() => {});
await groupCacheRepository.saveBatch(groups).catch(() => {});
return response;
});
}
@@ -122,16 +94,13 @@ class GroupManager {
if (page === 1) {
store.setGroupsList(groups);
}
saveGroupsCache(groups).catch(() => {});
groupCacheRepository.saveBatch(groups).catch(() => {});
return response;
}).catch((error) => {
console.error('[GroupManager] refreshGroupsInBackground error:', error);
});
}
/**
* <20>𥕦遣蝢斤<E89DA2><E696A4>𡑒”<F0A19192>唳旿皞琜<E79A9E><E7909C><EFBFBD> Sources 璅<E79285>嚗?
*/
createGroupListSource(
kind: RemoteGroupListSourceKind = 'cursor',
pageSize: number = GROUP_LIST_PAGE_SIZE
@@ -139,29 +108,21 @@ class GroupManager {
return createRemoteGroupListSource(kind, pageSize);
}
// ==================== 蝢斤<E89DA2>霂行<E99C82><E8A18C><EFBFBD> ====================
/**
* <20><EFBFBD>蝢斤<E89DA2>霂行<E99C82>
*/
async getGroup(groupId: string, forceRefresh = false): Promise<GroupResponse> {
const store = useGroupManagerStore.getState();
const entry = store.groupsMap.get(groupId);
// <20><EFBFBD>蝻枏<E89DBB>
if (!forceRefresh && entry && !isCacheExpired(entry)) {
return entry.data;
}
// 餈<><E9A488>蝻枏<E89DBB>嚗𡁜<E59A97><F0A1819C><EFBFBD><E595A3>?
if (!forceRefresh && entry && isCacheExpired(entry)) {
this.refreshGroupInBackground(groupId);
return entry.data;
}
// 撠肽<E692A0><E882BD>砍𧑐蝻枏<E89DBB>
if (!forceRefresh) {
const localGroup = await getGroupCache(groupId);
const localGroup = await groupCacheRepository.get(groupId);
if (localGroup) {
store.setGroup(groupId, localGroup);
this.refreshGroupInBackground(groupId);
@@ -169,12 +130,11 @@ class GroupManager {
}
}
// <20>𤏸絲霂瑟<E99C82>
return this.dedupe(`groups:detail:${groupId}`, async () => {
const store = useGroupManagerStore.getState();
const group = await groupService.getGroup(groupId);
store.setGroup(groupId, group);
await saveGroupCache(group).catch(() => {});
await groupCacheRepository.save(group).catch(() => {});
return group;
});
}
@@ -184,18 +144,13 @@ class GroupManager {
const store = useGroupManagerStore.getState();
const group = await groupService.getGroup(groupId);
store.setGroup(groupId, group);
saveGroupCache(group).catch(() => {});
groupCacheRepository.save(group).catch(() => {});
return group;
}).catch((error) => {
console.error('[GroupManager] refreshGroupInBackground error:', error);
});
}
// ==================== 蝢斤<E89DA2><E696A4>𣂼<EFBFBD><F0A382BC><EFBFBD> ====================
/**
* <20><EFBFBD>蝢斤<E89DA2><E696A4>𣂼<EFBFBD><F0A382BC>𡑒”
*/
async getMembers(
groupId: string,
page = 1,
@@ -206,20 +161,17 @@ class GroupManager {
const store = useGroupManagerStore.getState();
const entry = store.membersMap.get(key);
// <20><EFBFBD>蝻枏<E89DBB>
if (!forceRefresh && entry && !isCacheExpired(entry)) {
return buildMemberListResponse(entry.data, page, pageSize);
}
// 餈<><E9A488>蝻枏<E89DBB>嚗𡁜<E59A97><F0A1819C><EFBFBD><E595A3>?
if (!forceRefresh && entry && isCacheExpired(entry)) {
this.refreshMembersInBackground(groupId, page, pageSize);
return buildMemberListResponse(entry.data, page, pageSize);
}
// 蝚砌<E89D9A>憿蛛<E686BF>撠肽<E692A0><E882BD>砍𧑐蝻枏<E89DBB>
if (page === 1 && !forceRefresh) {
const localMembers = await getGroupMembersCache(groupId);
const localMembers = await groupCacheRepository.getMembers(groupId);
if (localMembers.length > 0) {
store.setMembers(groupId, localMembers, page, pageSize);
this.refreshMembersInBackground(groupId, page, pageSize);
@@ -227,16 +179,15 @@ class GroupManager {
}
}
// <20>𤏸絲霂瑟<E99C82>
return this.dedupe(`groups:members:${key}`, async () => {
const store = useGroupManagerStore.getState();
const response = await groupService.getMembers(groupId, page, pageSize);
const members = response.list || [];
store.setMembers(groupId, members, page, pageSize);
if (page === 1) {
await saveGroupMembersCache(groupId, members).catch(() => {});
await groupCacheRepository.saveMembers(groupId, members).catch(() => {});
}
await saveUsersCache(
await userCacheRepository.saveBatch(
members
.map((member) => member.user)
.filter((user): user is NonNullable<typeof user> => Boolean(user))
@@ -257,9 +208,9 @@ class GroupManager {
const members = response.list || [];
store.setMembers(groupId, members, page, pageSize);
if (page === 1) {
saveGroupMembersCache(groupId, members).catch(() => {});
groupCacheRepository.saveMembers(groupId, members).catch(() => {});
}
saveUsersCache(
userCacheRepository.saveBatch(
members
.map((member) => member.user)
.filter((user): user is NonNullable<typeof user> => Boolean(user))
@@ -270,9 +221,6 @@ class GroupManager {
});
}
/**
* <20>𥕦遣蝢斤<E89DA2><E696A4>𣂼<EFBFBD><F0A382BC>𡑒”<F0A19192>唳旿皞琜<E79A9E><E7909C><EFBFBD> Sources 璅<E79285>嚗?
*/
createGroupMemberListSource(
groupId: string,
kind: RemoteGroupListSourceKind = 'cursor',
@@ -281,11 +229,6 @@ class GroupManager {
return createRemoteGroupMemberListSource(groupId, kind, pageSize);
}
// ==================== 蝻枏<E89DBB>蝞∠<E89D9E> ====================
/**
* 雿輻<E99BBF>摮睃仃<E79D83>?
*/
invalidate(groupId?: string): void {
if (!groupId) {
useGroupManagerStore.getState().invalidateAll();
@@ -294,15 +237,10 @@ class GroupManager {
useGroupManagerStore.getState().invalidateGroup(groupId);
}
/**
* 皜<><EFBFBD><E88684><EFBFBD>㗇㺭<E39787>?
*/
clear(): void {
useGroupManagerStore.getState().reset();
}
// ==================== 霂瑟<E99C82><E7919F><EFBFBD> ====================
private async dedupe<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
const store = useGroupManagerStore.getState();
const pending = store.getPendingRequest<T>(key);
@@ -316,11 +254,8 @@ class GroupManager {
}
}
// ==================== <20><EFBFBD>撖澆枂 ====================
export const groupManager = new GroupManager();
// 撖澆枂蝐颱誑<E9A2B1><EFBFBD>蝐餃<E89D90><EFBFBD><E79289><EFBFBD>瘚贝<E7989A>
export { GroupManager };
export default groupManager;
export default groupManager;

View File

@@ -10,7 +10,7 @@ import {
CursorPaginationRequest,
} from '../types/dto';
import { groupService } from '../services/groupService';
import { getAllGroupsCache, getGroupMembersCache } from '../services/database';
import { groupCacheRepository } from '@/database';
export const GROUP_LIST_PAGE_SIZE = 20;
export const GROUP_MEMBER_LIST_PAGE_SIZE = 50;
@@ -130,7 +130,7 @@ export class SqliteGroupListPagedSource implements IGroupListPagedSource {
}
this.consumed = true;
try {
const items = await getAllGroupsCache();
const items = await groupCacheRepository.getAll();
return { items, hasMore: false };
} catch (e) {
console.warn('[SqliteGroupListPagedSource] 读取群组列表缓存失败:', e);
@@ -265,7 +265,7 @@ export class SqliteGroupMemberListPagedSource implements IGroupMemberListPagedSo
}
this.consumed = true;
try {
const items = await getGroupMembersCache(this.groupId);
const items = await groupCacheRepository.getMembers(this.groupId);
return { items, hasMore: false };
} catch (e) {
console.warn('[SqliteGroupMemberListPagedSource] 读取成员列表缓存失败:', e);

View File

@@ -1,5 +1,5 @@
import { GroupMemberResponse, UserDTO } from '../types/dto';
import { getUserCache } from '../services/database';
import { userCacheRepository } from '@/database';
import { userManager } from './userManager';
/**
@@ -19,7 +19,7 @@ export async function enrichGroupMembersWithUserProfiles(
await Promise.all(
userIds.map(async (userId) => {
try {
const cachedUser = await getUserCache(userId);
const cachedUser = await userCacheRepository.get(userId);
if (cachedUser) {
userMap.set(userId, cachedUser);
return;

View File

@@ -9,7 +9,7 @@
import type { ConversationResponse } from '../../../types/dto';
import { messageService } from '../../../services/messageService';
import { deleteConversation } from '../../../services/database';
import { conversationRepository } from '@/database';
import type { IConversationOperations } from '../types';
import { useMessageStore, normalizeConversationId } from '../store';
@@ -60,7 +60,7 @@ export class ConversationOperations implements IConversationOperations {
store.removeConversation(conversationId);
// 删除本地数据库中的会话
deleteConversation(conversationId).catch(error => {
conversationRepository.delete(conversationId).catch(error => {
console.error('[ConversationOperations] 删除本地会话失败:', error);
});
}

View File

@@ -9,7 +9,7 @@
import type { MessageResponse, MessageSegment, ConversationResponse } from '../../../types/dto';
import { messageService } from '../../../services/messageService';
import { saveMessage } from '../../../services/database';
import { messageRepository } from '@/database';
import type { IMessageSendService } from '../types';
import { useMessageStore, mergeMessagesById } from '../store';
@@ -64,7 +64,7 @@ export class MessageSendService implements IMessageSendService {
.map((s: any) => s.data?.text || '')
.join('') || '';
saveMessage({
messageRepository.saveMessage({
id: response.id,
conversationId,
senderId: currentUserId || '',

View File

@@ -9,13 +9,7 @@
import type { ConversationResponse, MessageResponse } from '../../../types/dto';
import { messageService } from '../../../services/messageService';
import {
getMessagesByConversation,
getMaxSeq,
saveMessagesBatch,
saveConversationsWithRelatedCache,
getMessagesBeforeSeq,
} from '../../../services/database';
import { messageRepository, conversationRepository } from '@/database';
import {
type IConversationListPagedSource,
SqliteConversationListPagedSource,
@@ -213,8 +207,8 @@ export class MessageSyncService implements IMessageSyncService {
// 先从本地数据库加载
if (!hasInMemoryMessages) {
try {
const localMessages = await getMessagesByConversation(conversationId, 20);
const localMaxSeq = await getMaxSeq(conversationId);
const localMessages = await messageRepository.getByConversation(conversationId, 20);
const localMaxSeq = await messageRepository.getMaxSeq(conversationId);
baselineMaxSeq = localMaxSeq;
if (localMessages.length > 0) {
@@ -249,7 +243,7 @@ export class MessageSyncService implements IMessageSyncService {
store.setMessages(conversationId, mergedSnapshot);
// 持久化到本地
saveMessagesBatch(snapshotMessages.map((m: any) => ({
messageRepository.saveMessagesBatch(snapshotMessages.map((m: any) => ({
id: m.id,
conversationId: m.conversation_id || conversationId,
senderId: m.sender_id,
@@ -276,7 +270,7 @@ export class MessageSyncService implements IMessageSyncService {
const mergedMessages = mergeMessagesById(existingMessages, newMessages);
store.setMessages(conversationId, mergedMessages);
saveMessagesBatch(newMessages.map((m: any) => ({
messageRepository.saveMessagesBatch(newMessages.map((m: any) => ({
id: m.id,
conversationId: m.conversation_id || conversationId,
senderId: m.sender_id,
@@ -306,20 +300,20 @@ export class MessageSyncService implements IMessageSyncService {
store.setMessages(conversationId, mergedMessages);
saveMessagesBatch(newMessages.map((m: any) => ({
id: m.id,
conversationId: m.conversation_id || conversationId,
senderId: m.sender_id,
content: m.content,
type: m.type || 'text',
isRead: m.is_read || false,
createdAt: m.created_at,
seq: m.seq,
status: m.status || 'normal',
segments: m.segments,
}))).catch(error => {
console.error('[MessageSyncService] 保存消息到本地失败:', error);
});
messageRepository.saveMessagesBatch(newMessages.map((m: any) => ({
id: m.id,
conversationId: m.conversation_id || conversationId,
senderId: m.sender_id,
content: m.content,
type: m.type || 'text',
isRead: m.is_read || false,
createdAt: m.created_at,
seq: m.seq,
status: m.status || 'normal',
segments: m.segments,
}))).catch(error => {
console.error('[MessageSyncService] 保存消息到本地失败:', error);
});
}
}
} catch (error) {
@@ -348,7 +342,7 @@ export class MessageSyncService implements IMessageSyncService {
try {
// 先从本地获取
const localMessages = await getMessagesBeforeSeq(conversationId, beforeSeq, limit);
const localMessages = await messageRepository.getBeforeSeq(conversationId, beforeSeq, limit);
if (localMessages.length >= limit) {
const formattedMessages: MessageResponse[] = [...localMessages].reverse().map(m => ({
@@ -374,7 +368,7 @@ export class MessageSyncService implements IMessageSyncService {
if (response?.messages && response.messages.length > 0) {
const serverMessages = response.messages;
await saveMessagesBatch(serverMessages.map((m: any) => ({
await messageRepository.saveMessagesBatch(serverMessages.map((m: any) => ({
id: m.id,
conversationId: m.conversation_id || conversationId,
senderId: m.sender_id,
@@ -497,7 +491,7 @@ export class MessageSyncService implements IMessageSyncService {
.map(conv => conv.group)
.filter((group): group is NonNullable<ConversationResponse['group']> => Boolean(group));
saveConversationsWithRelatedCache(list, users, groups).catch(error => {
conversationRepository.saveWithRelated(list, users, groups).catch(error => {
console.error('[MessageSyncService] 持久化会话列表失败:', error);
});
}

View File

@@ -9,7 +9,7 @@
import type { ConversationResponse } from '../../../types/dto';
import { messageService } from '../../../services/messageService';
import { markConversationAsRead, updateConversationCacheUnreadCount } from '../../../services/database';
import { messageRepository, conversationRepository } from '@/database';
import type { ReadStateRecord, IReadReceiptManager } from '../types';
import { READ_STATE_PROTECTION_DELAY } from '../constants';
import { useMessageStore, normalizeConversationId } from '../store';
@@ -78,7 +78,7 @@ export class ReadReceiptManager implements IReadReceiptManager {
store.setUnreadCount(newTotalUnread, currentUnread.system);
// 3. 更新本地数据库
markConversationAsRead(normalizedId).catch(console.error);
messageRepository.markConversationAsRead(normalizedId).catch(console.error);
// 4. 调用 API完成后设置延迟清除保护
try {

View File

@@ -5,7 +5,7 @@
import type { MessageResponse, UserDTO } from '../../../types/dto';
import { api } from '../../../services/api';
import { getUserCache, saveUserCache } from '../../../services/database';
import { userCacheRepository } from '@/database';
import type { IUserCacheService } from '../types';
export class UserCacheService implements IUserCacheService {
@@ -17,7 +17,7 @@ export class UserCacheService implements IUserCacheService {
*/
async getSenderInfo(userId: string): Promise<UserDTO | null> {
// 1. 先检查本地缓存
const cachedUser = await getUserCache(userId);
const cachedUser = await userCacheRepository.get(userId);
if (cachedUser) {
return cachedUser;
}
@@ -49,7 +49,7 @@ export class UserCacheService implements IUserCacheService {
const response = await api.get<UserDTO>(`/users/${userId}`);
if (response.code === 0 && response.data) {
// 缓存到本地数据库
await saveUserCache(response.data);
await userCacheRepository.save(response.data);
return response.data;
}
return null;

View File

@@ -19,7 +19,7 @@ import type {
} from '../../../services/wsService';
import { wsService, GroupNoticeType } from '../../../services/wsService';
import { vibrateOnMessage } from '../../../services/messageVibrationService';
import { saveMessage, updateMessageStatus } from '../../../services/database';
import { messageRepository } from '@/database';
import type { MessageResponse, ConversationResponse } from '../../../types/dto';
import type {
IWSMessageHandler,
@@ -318,7 +318,7 @@ export class WSMessageHandler implements IWSMessageHandler {
// 异步保存到本地数据库
const textContent = segments?.filter((s: any) => s.type === 'text').map((s: any) => s.data?.text || '').join('') || '';
saveMessage({
messageRepository.saveMessage({
id,
conversationId: normalizedConversationId,
senderId: sender_id || '',
@@ -358,7 +358,7 @@ export class WSMessageHandler implements IWSMessageHandler {
this.markMessageAsRecalled(normalizedConversationId, message_id);
this.syncConversationLastMessageOnRecall(normalizedConversationId, message_id);
updateMessageStatus(message_id, 'recalled', true).catch(error => {
messageRepository.updateStatus(message_id, 'recalled', true).catch(error => {
console.error('[WSMessageHandler] 更新本地消息撤回状态失败:', error);
});
}
@@ -373,7 +373,7 @@ export class WSMessageHandler implements IWSMessageHandler {
this.markMessageAsRecalled(normalizedConversationId, message_id);
this.syncConversationLastMessageOnRecall(normalizedConversationId, message_id);
updateMessageStatus(message_id, 'recalled', true).catch(error => {
messageRepository.updateStatus(message_id, 'recalled', true).catch(error => {
console.error('[WSMessageHandler] 更新本地消息撤回状态失败:', error);
});
}

View File

@@ -1,46 +1,34 @@
/**
* UserManager - 用户管ç<EFBFBD>†æ ¸å¿ƒæ¨¡å<EFBFBD>—(é‡<EFBFBD>构版ï¼?
* UserManager - 用户管理核心模块(重构版)
*
* 使用 Zustand store è¿è¡Œçжæ€<EFBFBD>管ç<EFBFBD>?
* ä¿<EFBFBD>æŒ<EFBFBD>与旧 API çš„å<E2809E>å<E28098>Žå…¼å®?
* 使用 Zustand store 进行状态管理
* 保持与旧 API 的向后兼容
*/
import type { UserDTO } from '../../types/dto';
import { authService } from '../../services/authService';
import {
getCurrentUserCache,
getUserCache,
saveCurrentUserCache,
saveUserCache,
} from '../../services/database';
import { userCacheRepository } from '@/database';
import { useUserManagerStore } from './userStore';
import { createCacheEntry, isCacheExpired, DEFAULT_TTL } from '../utils/cache';
// ==================== UserManager ç±?====================
// ==================== UserManager ====================
class UserManager {
/**
* 获å<C2B7>当å‰<C3A5>用户
* 支æŒ<C3A6>缓存过期检查åŒå<C592>Žå<C5BD>°åˆ·æ°
*/
async getCurrentUser(forceRefresh = false): Promise<UserDTO | null> {
const store = useUserManagerStore.getState();
const entry = store.currentUserEntry;
// 有效缓存
if (!forceRefresh && entry && !isCacheExpired(entry)) {
return entry.data;
}
// 过期缓存:å<C5A1>Žå<C5BD>°åˆ·æ°ï¼Œç«å<E280B9>³è¿”åžæ—§æ•°æ<C2B0>?
if (!forceRefresh && entry && isCacheExpired(entry)) {
this.refreshCurrentUserInBackground();
return entry.data;
}
// å°<C3A5>试从本地数æ<C2B0>®åº“加载
if (!forceRefresh) {
const cachedFromDb = await getCurrentUserCache();
const cachedFromDb = await userCacheRepository.getCurrent();
if (cachedFromDb) {
const newEntry = createCacheEntry(cachedFromDb, DEFAULT_TTL.USER);
store.setCurrentUserEntry(newEntry);
@@ -49,23 +37,19 @@ class UserManager {
}
}
// å<>èµ· API 请求
return this.dedupe('users:current', async () => {
const user = await authService.fetchCurrentUserFromAPI();
const newEntry = createCacheEntry(user, DEFAULT_TTL.USER);
store.setCurrentUserEntry(newEntry);
if (user) {
saveCurrentUserCache(user).catch(() => {});
saveUserCache(user).catch(() => {});
userCacheRepository.saveCurrent(user).catch(() => {});
userCacheRepository.save(user).catch(() => {});
}
return user;
});
}
/**
* å<>Žå<C5BD>°åˆ·æ°å½“å‰<C3A5>用户
*/
private refreshCurrentUserInBackground(): void {
this.dedupe('users:current:bg', async () => {
const store = useUserManagerStore.getState();
@@ -74,8 +58,8 @@ class UserManager {
store.setCurrentUserEntry(newEntry);
if (user) {
saveCurrentUserCache(user).catch(() => {});
saveUserCache(user).catch(() => {});
userCacheRepository.saveCurrent(user).catch(() => {});
userCacheRepository.save(user).catch(() => {});
}
return user;
}).catch((error) => {
@@ -83,27 +67,21 @@ class UserManager {
});
}
/**
* æ ¹æ<C2B9>® ID 获å<C2B7>用户
*/
async getUserById(userId: string, forceRefresh = false): Promise<UserDTO | null> {
const store = useUserManagerStore.getState();
const entry = store.usersMap.get(userId);
// 有效缓存
if (!forceRefresh && entry && !isCacheExpired(entry)) {
return entry.data;
}
// 过期缓存:å<C5A1>Žå<C5BD>°åˆ·æ°ï¼Œç«å<E280B9>³è¿”åžæ—§æ•°æ<C2B0>?
if (!forceRefresh && entry && isCacheExpired(entry)) {
this.refreshUserByIdInBackground(userId);
return entry.data;
}
// å°<C3A5>试从本地数æ<C2B0>®åº“加载
if (!forceRefresh) {
const cachedFromDb = await getUserCache(userId);
const cachedFromDb = await userCacheRepository.get(userId);
if (cachedFromDb) {
const newEntry = createCacheEntry(cachedFromDb, DEFAULT_TTL.USER);
store.setUser(userId, cachedFromDb);
@@ -112,7 +90,6 @@ class UserManager {
}
}
// å<>èµ· API 请求
return this.dedupe(`users:detail:${userId}`, async () => {
const store = useUserManagerStore.getState();
const user = await authService.fetchUserByIdFromAPI(userId);
@@ -120,15 +97,12 @@ class UserManager {
store.setUser(userId, user);
if (user) {
saveUserCache(user).catch(() => {});
userCacheRepository.save(user).catch(() => {});
}
return user;
});
}
/**
* å<>Žå<C5BD>°åˆ·æ°ç”¨æˆ·
*/
private refreshUserByIdInBackground(userId: string): void {
this.dedupe(`users:detail:bg:${userId}`, async () => {
const store = useUserManagerStore.getState();
@@ -137,7 +111,7 @@ class UserManager {
store.setUser(userId, user);
if (user) {
saveUserCache(user).catch(() => {});
userCacheRepository.save(user).catch(() => {});
}
return user;
}).catch((error) => {
@@ -145,16 +119,10 @@ class UserManager {
});
}
/**
* 使缓存失�
*/
invalidateUsers(): void {
useUserManagerStore.getState().invalidateAll();
}
/**
* 请æ±åŽ»é‡<C3A9>
*/
private async dedupe<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
const store = useUserManagerStore.getState();
const pending = store.getPendingRequest<T>(key);
@@ -168,11 +136,8 @@ class UserManager {
}
}
// ==================== å<>•ä¾å¯¼å‡º ====================
export const userManager = new UserManager();
// 导出类以支æŒ<C3A6>ç±»åžæ£€æŸ¥åŒæµè¯•
export { UserManager };
export default userManager;
export default userManager;