diff --git a/app/(app)/(tabs)/profile/_layout.tsx b/app/(app)/(tabs)/profile/_layout.tsx index f5ac872..78a343b 100644 --- a/app/(app)/(tabs)/profile/_layout.tsx +++ b/app/(app)/(tabs)/profile/_layout.tsx @@ -42,6 +42,8 @@ export default function ProfileStackLayout() { + + ); } diff --git a/app/(app)/(tabs)/profile/account-deletion.tsx b/app/(app)/(tabs)/profile/account-deletion.tsx new file mode 100644 index 0000000..d13bedc --- /dev/null +++ b/app/(app)/(tabs)/profile/account-deletion.tsx @@ -0,0 +1,5 @@ +import { AccountDeletionScreen } from '../../../../src/screens/profile'; + +export default function AccountDeletionRoute() { + return ; +} diff --git a/app/(app)/(tabs)/profile/privacy-settings.tsx b/app/(app)/(tabs)/profile/privacy-settings.tsx new file mode 100644 index 0000000..4f0609b --- /dev/null +++ b/app/(app)/(tabs)/profile/privacy-settings.tsx @@ -0,0 +1,5 @@ +import { PrivacySettingsScreen } from '../../../../src/screens/profile'; + +export default function PrivacySettingsRoute() { + return ; +} diff --git a/app/_layout.tsx b/app/_layout.tsx index 936bfc8..e06725e 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -52,6 +52,25 @@ if (Platform.OS === 'web' && typeof document !== 'undefined') { } *:focus { outline: none !important; } *:focus-visible { outline: none !important; } + /* 修复移动端 FlatList/ScrollView 滑动问题 */ + html, body { + overscroll-behavior: none; + touch-action: pan-y; + } + /* React Native Web 生成的滚动容器 */ + [class*="css-"] { + touch-action: pan-y !important; + -webkit-overflow-scrolling: touch !important; + } + /* 确保所有可滚动元素都可以垂直滑动 */ + div[style*="overflow"] { + touch-action: pan-y !important; + -webkit-overflow-scrolling: touch !important; + } + /* 禁用水平滑动,只允许垂直滑动 */ + * { + touch-action: pan-y pinch-zoom; + } `; document.head.appendChild(style); } diff --git a/src/data/datasources/interfaces.ts b/src/data/datasources/interfaces.ts index 78bd3d9..0469aeb 100644 --- a/src/data/datasources/interfaces.ts +++ b/src/data/datasources/interfaces.ts @@ -22,6 +22,8 @@ export interface ILocalDataSource { batch(operations: (db: ILocalDataSource) => Promise): Promise; enqueueWrite(operation: () => Promise): Promise; initialize(userId?: string): Promise; + switchDatabase(userId?: string | null): Promise; + closeDatabase(): Promise; } // 缓存数据源接口 diff --git a/src/data/repositories/PostRepository.ts b/src/data/repositories/PostRepository.ts index 407df0e..f710698 100644 --- a/src/data/repositories/PostRepository.ts +++ b/src/data/repositories/PostRepository.ts @@ -23,8 +23,8 @@ import type { * API帖子响应类型 */ interface PostApiResponse { - id: number; - user_id: number; + id: string; + user_id: string; title: string; content: string; images?: Array<{ url: string; width?: number; height?: number; thumbnail_url?: string }>; @@ -44,7 +44,7 @@ interface PostApiResponse { content_edited_at?: string; channel?: { id: string; name: string } | null; author?: { - id: number; + id: string; username: string; nickname?: string; avatar?: string; @@ -179,7 +179,6 @@ export class PostRepository implements IPostRepository { */ private async getFromLocalCache(id: string): Promise { try { - await this.localDb.initialize(); const result = await this.localDb.getFirst( 'SELECT * FROM posts_cache WHERE id = ?', [id] @@ -200,7 +199,6 @@ export class PostRepository implements IPostRepository { */ private async saveToLocalCache(post: Post): Promise { try { - await this.localDb.initialize(); await this.localDb.enqueueWrite(async () => { await this.localDb.run( `INSERT OR REPLACE INTO posts_cache (id, data, updatedAt) VALUES (?, ?, ?)`, @@ -217,7 +215,6 @@ export class PostRepository implements IPostRepository { */ private async clearLocalCache(id: string): Promise { try { - await this.localDb.initialize(); await this.localDb.enqueueWrite(async () => { await this.localDb.run('DELETE FROM posts_cache WHERE id = ?', [id]); }); @@ -554,7 +551,6 @@ export class PostRepository implements IPostRepository { async clearAllCache(): Promise { this.memoryCache.clear(); try { - await this.localDb.initialize(); await this.localDb.enqueueWrite(async () => { await this.localDb.run('DELETE FROM posts_cache'); }); diff --git a/src/database/LocalDataSource.ts b/src/database/LocalDataSource.ts index be45a82..3514d7d 100644 --- a/src/database/LocalDataSource.ts +++ b/src/database/LocalDataSource.ts @@ -1,123 +1,88 @@ import * as SQLite from 'expo-sqlite'; -import { ILocalDataSource, DataSourceError } from '@/data/datasources/interfaces'; +import type { ILocalDataSource } from '@/data/datasources/interfaces'; import { databaseManager } from './core/DatabaseManager'; -let writeQueue: Promise = Promise.resolve(); +class OperationQueue { + private queue: Promise = Promise.resolve(); -export class LocalDataSource implements ILocalDataSource { - async initialize(userId?: string): Promise { - await databaseManager.initialize(userId); + enqueue(operation: () => Promise): Promise { + const result = this.queue.then(() => operation(), () => operation()); + this.queue = result.catch(() => {}); + return result; } - private async ensureDb(): Promise { - return databaseManager.getDb(); - } - - async query(sql: string, params?: any[]): Promise { - try { - const db = await this.ensureDb(); - if (params && params.length > 0) { - return await db.getAllAsync(sql, params as any); - } - return await db.getAllAsync(sql); - } catch (error) { - this.handleError(error, 'QUERY'); - } - } - - async getFirst(sql: string, params?: any[]): Promise { - try { - const db = await this.ensureDb(); - if (params && params.length > 0) { - return await db.getFirstAsync(sql, params as any); - } - return await db.getFirstAsync(sql); - } catch (error) { - this.handleError(error, 'GET_FIRST'); - } - } - - async execute(sql: string, params?: any[]): Promise { - try { - const db = await this.ensureDb(); - await db.execAsync(sql); - } catch (error) { - this.handleError(error, 'EXECUTE'); - } - } - - async run(sql: string, params?: any[]): Promise<{ lastInsertRowId: number; changes: number }> { - try { - 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) { - this.handleError(error, 'RUN'); - } - } - - async transaction(operations: () => Promise): Promise { - try { - const db = await this.ensureDb(); - await db.withTransactionAsync(async () => { - await operations(); - }); - } catch (error) { - this.handleError(error, 'TRANSACTION'); - } - } - - async batch(operations: (db: ILocalDataSource) => Promise): Promise { - try { - await this.transaction(async () => { - await operations(this); - }); - } catch (error) { - this.handleError(error, 'BATCH'); - } - } - - async enqueueWrite(operation: () => Promise): Promise { - const wrappedOperation = async () => { - try { - return await operation(); - } catch (error) { - if (this.isRecoverableError(error)) { - console.error('[LocalDataSource] 数据库写入异常,尝试重连后重试:', error); - await databaseManager.initialize(databaseManager.getCurrentUserId()); - return operation(); - } - throw error; - } - }; - - const queued = writeQueue.then(wrappedOperation, wrappedOperation); - writeQueue = queued.then(() => undefined, () => undefined); - return queued; - } - - private isRecoverableError(error: unknown): boolean { - const message = String(error); - return ( - message.includes('NativeDatabase.prepareAsync') || - message.includes('NullPointerException') || - message.includes('database is locked') || - message.includes('finalizing statement') || - 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 - ); + async drain(): Promise { + await this.queue; } } -export const localDataSource = new LocalDataSource(); \ No newline at end of file +const operationQueue = new OperationQueue(); + +export class LocalDataSource implements ILocalDataSource { + private async getDb(): Promise { + return databaseManager.getDb(); + } + + async initialize(userId?: string): Promise { + if (databaseManager.isReady()) { + return; + } + await operationQueue.enqueue(() => databaseManager.initialize(userId)); + } + + async query(sql: string, params?: any[]): Promise { + const db = await this.getDb(); + if (params && params.length > 0) { + return db.getAllAsync(sql, params as any); + } + return db.getAllAsync(sql); + } + + async getFirst(sql: string, params?: any[]): Promise { + const db = await this.getDb(); + if (params && params.length > 0) { + return db.getFirstAsync(sql, params as any); + } + return db.getFirstAsync(sql); + } + + async execute(sql: string, params?: any[]): Promise { + const db = await this.getDb(); + await db.execAsync(sql); + } + + async run(sql: string, params?: any[]): Promise<{ lastInsertRowId: number; changes: number }> { + const db = await this.getDb(); + if (params && params.length > 0) { + return db.runAsync(sql, params as any); + } + return db.runAsync(sql); + } + + async transaction(operations: () => Promise): Promise { + const db = await this.getDb(); + await db.withTransactionAsync(operations); + } + + async batch(operations: (db: ILocalDataSource) => Promise): Promise { + await this.transaction(async () => { + await operations(this); + }); + } + + async enqueueWrite(operation: () => Promise): Promise { + return operationQueue.enqueue(operation); + } + + async switchDatabase(userId?: string | null): Promise { + await operationQueue.drain(); + await operationQueue.enqueue(() => databaseManager.initialize(userId)); + } + + async closeDatabase(): Promise { + await operationQueue.drain(); + await operationQueue.enqueue(() => databaseManager.close()); + } +} + +export const localDataSource = new LocalDataSource(); diff --git a/src/database/core/DatabaseConfig.ts b/src/database/core/DatabaseConfig.ts index 0e09882..d87dcfb 100644 --- a/src/database/core/DatabaseConfig.ts +++ b/src/database/core/DatabaseConfig.ts @@ -1,14 +1,6 @@ -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; +const DEFAULT_DB_NAME = 'carrot_bbs.db'; +const DB_NAME_PREFIX = 'carrot_bbs_'; export function getDbName(userId: string | null): string { - return userId ? `${DB_CONFIG.DB_NAME_PREFIX}${userId}.db` : DB_CONFIG.DEFAULT_DB_NAME; + return userId ? `${DB_NAME_PREFIX}${userId}.db` : DEFAULT_DB_NAME; } \ No newline at end of file diff --git a/src/database/core/DatabaseManager.ts b/src/database/core/DatabaseManager.ts index 4af18ab..789d204 100644 --- a/src/database/core/DatabaseManager.ts +++ b/src/database/core/DatabaseManager.ts @@ -1,6 +1,6 @@ import * as SQLite from 'expo-sqlite'; -import { LockManager } from './LockManager'; -import { DB_CONFIG, getDbName } from './DatabaseConfig'; +import { Platform } from 'react-native'; +import { getDbName } from './DatabaseConfig'; import { runMigrations } from '../schema/MigrationManager'; type SQLiteDatabase = SQLite.SQLiteDatabase; @@ -10,8 +10,6 @@ class DatabaseManager { private db: SQLiteDatabase | null = null; private currentUserId: string | null = null; private initPromise: Promise | null = null; - private initTargetUserId: string | null = null; - private isInitialized = false; static getInstance(): DatabaseManager { if (!DatabaseManager.instance) { @@ -22,119 +20,81 @@ class DatabaseManager { async initialize(userId?: string | null): Promise { 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(() => {}); + await this.initPromise; + if (this.db && this.currentUserId === targetUserId) { + return; + } } - if (this.db && this.currentUserId === targetUserId) { - console.log(`[DatabaseManager ${callId}] 其他初始化已完成目标,直接返回`); - this.isInitialized = true; + this.initPromise = this.doInitialize(targetUserId); + + try { + await this.initPromise; + } finally { + this.initPromise = null; + } + } + + private async doInitialize(targetUserId: string | null): Promise { + if (this.db) { + this.forceClose(this.db); + this.db = null; + this.currentUserId = null; + } + + const dbName = getDbName(targetUserId); + const isWeb = Platform.OS === 'web'; + + if (isWeb) { + try { + const db = await this.tryOpenDatabase(dbName); + await runMigrations(db); + this.db = db; + this.currentUserId = targetUserId; + } catch (error) { + console.warn('[DatabaseManager] Web数据库打开失败,使用内存数据库:', error); + const db = await SQLite.openDatabaseAsync(':memory:'); + await runMigrations(db); + this.db = db; + this.currentUserId = targetUserId; + } 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 返回`); + const db = await this.tryOpenDatabase(dbName); + await runMigrations(db); + this.db = db; + this.currentUserId = targetUserId; } - private async openDatabaseWithRetry(dbName: string): Promise { - const isWeb = typeof window !== 'undefined'; - - return LockManager.withWebLock(`${DB_CONFIG.LOCK_PREFIX}${dbName}`, async () => { - let lastError: unknown; - 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) { - lastError = error; - const msg = String(error); - console.error(`[DatabaseManager] 打开失败 (尝试 ${attempt + 1}/${DB_CONFIG.MAX_RETRIES + 1}):`, msg); - - if (isWeb && msg.includes('cannot create file')) { - console.log('[DatabaseManager] OPFS 不可用,回退到内存数据库'); - const memoryDb = await SQLite.openDatabaseAsync(':memory:'); - return memoryDb; - } - - 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)); - } - } + private async tryOpenDatabase(dbName: string): Promise { + try { + return await SQLite.openDatabaseAsync(dbName, { + useNewConnection: true, + }); + } catch (error) { + const msg = String(error); + if (msg.includes('cannot create file') || msg.includes('SQLITE_CANTOPEN') || msg.includes('database is locked')) { + return await SQLite.openDatabaseAsync(':memory:'); } - throw lastError; - }); + throw error; + } + } + + private forceClose(db: SQLiteDatabase): void { + try { + db.closeSync(); + } catch { + try { + db.closeAsync().catch(() => {}); + } catch {} + } } async close(): Promise { @@ -142,43 +102,22 @@ class DatabaseManager { 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; - }); - }); + if (this.db) { + this.forceClose(this.db); + this.db = null; + this.currentUserId = null; + } } - async getDb(timeout: number = 10000): Promise { - if (this.isInitialized && this.db) return this.db; + async getDb(): Promise { + if (this.db) return this.db; if (this.initPromise) { - const timeoutPromise = new Promise((_, reject) => - setTimeout(() => reject(new Error('等待数据库初始化超时')), timeout) - ); - - await Promise.race([this.initPromise, timeoutPromise]); - + await this.initPromise; if (this.db) return this.db; } - throw new Error('数据库未初始化,请先调用 initialize(userId)'); + throw new Error('数据库未初始化'); } getDbSync(): SQLiteDatabase | null { @@ -190,11 +129,7 @@ class DatabaseManager { } isReady(): boolean { - return this.isInitialized && this.db !== null; - } - - async withMutex(fn: () => Promise): Promise { - return LockManager.withMutex(fn); + return this.db !== null; } } diff --git a/src/database/index.ts b/src/database/index.ts index 398d5b4..a14b494 100644 --- a/src/database/index.ts +++ b/src/database/index.ts @@ -33,8 +33,12 @@ export const initDatabase = async (userId?: string | null): Promise => { await databaseManager.initialize(userId); }; +export const switchDatabase = async (userId?: string | null): Promise => { + await localDataSource.switchDatabase(userId); +}; + export const closeDatabase = async (): Promise => { - await databaseManager.close(); + await localDataSource.closeDatabase(); }; export const getCurrentDbUserId = (): string | null => { @@ -49,6 +53,6 @@ export const getDbInstance = () => { return databaseManager.getDbSync(); }; -export const getDb = async (timeout?: number) => { - return databaseManager.getDb(timeout); +export const getDb = async () => { + return databaseManager.getDb(); }; \ No newline at end of file diff --git a/src/database/schema/MigrationManager.ts b/src/database/schema/MigrationManager.ts index 790145d..6a71a5a 100644 --- a/src/database/schema/MigrationManager.ts +++ b/src/database/schema/MigrationManager.ts @@ -23,33 +23,42 @@ import { CREATE_POSTS_CACHE_TABLE, } from './PostSchema'; -export async function runMigrations(db: SQLiteDatabase): Promise { - if (typeof window !== 'undefined') { +async function execWithRetry(db: SQLiteDatabase, sql: string, maxRetries = 5): Promise { + for (let i = 0; i < maxRetries; i++) { 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(sql); + return; + } catch (error) { + const msg = String(error); + if (msg.includes('database is locked') && i < maxRetries - 1) { + const delay = 200 * (i + 1); + console.warn(`[MigrationManager] 数据库锁定,${delay}ms 后重试 (${i + 1}/${maxRetries})`); + await new Promise(r => setTimeout(r, delay)); + continue; + } + throw error; } } +} - 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); +export async function runMigrations(db: SQLiteDatabase): Promise { + await execWithRetry(db, 'PRAGMA busy_timeout = 10000;'); + + await execWithRetry(db, CREATE_MESSAGES_TABLE); + await execWithRetry(db, CREATE_CONVERSATIONS_TABLE); + await execWithRetry(db, CREATE_CONVERSATION_CACHE_TABLE); + await execWithRetry(db, CREATE_CONVERSATION_LIST_CACHE_TABLE); + await execWithRetry(db, CREATE_USERS_TABLE); + await execWithRetry(db, CREATE_CURRENT_USER_CACHE_TABLE); + await execWithRetry(db, CREATE_GROUPS_TABLE); + await execWithRetry(db, CREATE_GROUP_MEMBERS_TABLE); + await execWithRetry(db, CREATE_POSTS_CACHE_TABLE); for (const indexSql of CREATE_MESSAGES_INDEXES) { - await db.execAsync(indexSql); + await execWithRetry(db, indexSql); } for (const indexSql of CREATE_GROUP_INDEXES) { - await db.execAsync(indexSql); + await execWithRetry(db, indexSql); } await MIGRATE_MESSAGES_TABLE(db); diff --git a/src/infrastructure/cache/MediaCacheManager.ts b/src/infrastructure/cache/MediaCacheManager.ts index 96c3a9e..0fdc61e 100644 --- a/src/infrastructure/cache/MediaCacheManager.ts +++ b/src/infrastructure/cache/MediaCacheManager.ts @@ -4,7 +4,7 @@ * @description 管理图片、视频、音频缓存,支持多种清理策略 */ -import { File, Paths } from 'expo-file-system'; +import { File, Directory, Paths } from 'expo-file-system'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { Platform } from 'react-native'; import { @@ -28,7 +28,7 @@ const CACHE_RECORD_PREFIX = 'media_cache_record_'; * 获取缓存目录路径 */ function getCacheDir(): string { - const cachePath = Paths.cache; + const cachePath = Paths.cache.uri; return `${cachePath}media_cache/`; } @@ -127,11 +127,16 @@ export class MediaCacheManager { if (Platform.OS === 'web') return; try { - const dirs = [getImageDir(), getVideoDir(), getAudioDir()]; - for (const dir of dirs) { - const dirFile = new File(dir); - if (!await dirFile.exists) { - await dirFile.create(); + const cacheDir = new Directory(Paths.cache, 'media_cache'); + if (!await cacheDir.exists) { + await cacheDir.create(); + } + + const subDirs = ['images', 'videos', 'audios']; + for (const subDir of subDirs) { + const dir = new Directory(cacheDir, subDir); + if (!await dir.exists) { + await dir.create(); } } } catch (error) { diff --git a/src/navigation/hrefs.ts b/src/navigation/hrefs.ts index 1f62a04..e263792 100644 --- a/src/navigation/hrefs.ts +++ b/src/navigation/hrefs.ts @@ -210,3 +210,11 @@ export function hrefVerificationForm(identity?: string): string { export function hrefProfileDataStorage(): string { return '/profile/data-storage'; } + +export function hrefProfilePrivacySettings(): string { + return '/profile/privacy-settings'; +} + +export function hrefProfileDeletion(): string { + return '/profile/account-deletion'; +} diff --git a/src/screens/create/CreatePostScreen.tsx b/src/screens/create/CreatePostScreen.tsx index 12dc697..0318b4d 100644 --- a/src/screens/create/CreatePostScreen.tsx +++ b/src/screens/create/CreatePostScreen.tsx @@ -388,7 +388,7 @@ export const CreatePostScreen: React.FC = (props) => { content: content.trim(), images: imageUrls, channel_id: selectedChannelId || undefined, - vote_options: validOptions, + vote_options: validOptions.map(opt => ({ content: opt })), }); showPrompt({ type: 'info', diff --git a/src/screens/home/PostDetailScreen.tsx b/src/screens/home/PostDetailScreen.tsx index 0998030..d5cd861 100644 --- a/src/screens/home/PostDetailScreen.tsx +++ b/src/screens/home/PostDetailScreen.tsx @@ -33,7 +33,7 @@ import { useAppColors, type AppColors, } from '../../theme'; -import { Post, Comment, VoteResultDTO } from '../../types'; +import { Post, Comment, VoteResultDTO, VoteOptionDTO } from '../../types'; import { useUserStore } from '../../stores'; import { useCurrentUser } from '../../stores/authStore'; import { postService, commentService, uploadService, authService, showPrompt, voteService } from '../../services'; @@ -549,14 +549,14 @@ export const PostDetailScreen: React.FC = () => { const oldVoteResult = { ...voteResult }; // 乐观更新 - setVoteResult(prev => { + setVoteResult((prev: VoteResultDTO | null) => { if (!prev) return null; return { ...prev, has_voted: true, voted_option_id: optionId, total_votes: prev.total_votes + 1, - options: prev.options.map(opt => + options: prev.options.map((opt: VoteOptionDTO) => opt.id === optionId ? { ...opt, votes_count: opt.votes_count + 1 } : opt @@ -591,14 +591,14 @@ export const PostDetailScreen: React.FC = () => { const oldVoteResult = { ...voteResult }; // 乐观更新 - setVoteResult(prev => { + setVoteResult((prev: VoteResultDTO | null) => { if (!prev) return null; return { ...prev, has_voted: false, voted_option_id: undefined, total_votes: Math.max(0, prev.total_votes - 1), - options: prev.options.map(opt => + options: prev.options.map((opt: VoteOptionDTO) => opt.id === votedOptionId ? { ...opt, votes_count: Math.max(0, opt.votes_count - 1) } : opt diff --git a/src/screens/message/ChatScreen.tsx b/src/screens/message/ChatScreen.tsx index fe4e238..c9525dd 100644 --- a/src/screens/message/ChatScreen.tsx +++ b/src/screens/message/ChatScreen.tsx @@ -444,6 +444,8 @@ export const ChatScreen: React.FC = () => { contentContainerStyle={listContentStyle as any} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag" + scrollEnabled={true} initialNumToRender={14} maxToRenderPerBatch={10} updateCellsBatchingPeriod={50} diff --git a/src/screens/message/MessageListScreen.tsx b/src/screens/message/MessageListScreen.tsx index 2faca66..c381458 100644 --- a/src/screens/message/MessageListScreen.tsx +++ b/src/screens/message/MessageListScreen.tsx @@ -727,6 +727,9 @@ export const MessageListScreen: React.FC = () => { }} contentContainerStyle={[styles.searchResultsContent, { paddingBottom: listBottomInset }]} showsVerticalScrollIndicator={false} + scrollEnabled={true} + keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag" ListEmptyComponent={renderSearchEmpty} /> @@ -783,6 +786,9 @@ export const MessageListScreen: React.FC = () => { ListEmptyComponent={renderEmpty} onEndReached={onEndReached} onEndReachedThreshold={0.5} + scrollEnabled={true} + keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag" refreshControl={ void }> = ({ onBack ListFooterComponent={renderFooter} onEndReached={onEndReached} onEndReachedThreshold={0.3} + scrollEnabled={true} + keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag" refreshControl={ void }> = ({ onBack ListFooterComponent={renderFooter} onEndReached={onEndReached} onEndReachedThreshold={0.3} + scrollEnabled={true} + keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag" refreshControl={ = ({ conversation, onBack keyExtractor={(item) => String(item.id)} contentContainerStyle={styles.listContent} showsVerticalScrollIndicator={true} + scrollEnabled={true} + keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag" onLayout={() => { if (messages.length > 0) { flatListRef.current?.scrollToEnd({ animated: false }); diff --git a/src/screens/profile/AccountDeletionScreen.tsx b/src/screens/profile/AccountDeletionScreen.tsx new file mode 100644 index 0000000..ff8e35f --- /dev/null +++ b/src/screens/profile/AccountDeletionScreen.tsx @@ -0,0 +1,410 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + ActivityIndicator, + Alert, + ScrollView, + StyleSheet, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { useRouter } from 'expo-router'; +import { authService } from '../../services'; +import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../theme'; +import { Text } from '../../components/common'; +import { useResponsive, useResponsiveSpacing } from '../../hooks'; +import { useAuthStore } from '../../stores'; +import type { DeletionStatusDTO } from '../../types/dto'; +import * as hrefs from '../../navigation/hrefs'; + +const CONTENT_MAX_WIDTH = 720; + +// 胡萝卜橙主题色 +const THEME_COLORS = { + primary: '#FF6B35', + primaryLight: '#FF8C5A', + primaryDark: '#E55A2B', +}; + +function createAccountDeletionStyles(colors: AppColors) { + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background.paper, + }, + loadingWrap: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + scrollContent: { + paddingVertical: spacing.lg, + }, + content: { + maxWidth: CONTENT_MAX_WIDTH, + alignSelf: 'center', + width: '100%', + }, + section: { + marginBottom: spacing['2xl'], + }, + // 警告卡片 - 扁平化风格 + warningCard: { + backgroundColor: colors.error.light + '20', + borderRadius: 14, + padding: spacing.lg, + marginHorizontal: spacing['2xl'], + marginBottom: spacing.xl, + }, + warningTitle: { + fontWeight: '700', + fontSize: fontSizes.md, + marginBottom: spacing.sm, + color: colors.error.main, + }, + warningText: { + color: colors.error.dark, + marginBottom: spacing.xs, + fontSize: fontSizes.sm, + }, + // 状态卡片 - 扁平化风格 + statusCard: { + backgroundColor: colors.warning.light + '30', + borderRadius: 14, + padding: spacing.lg, + marginHorizontal: spacing['2xl'], + marginBottom: spacing.xl, + }, + statusTitle: { + fontWeight: '700', + fontSize: fontSizes.md, + marginBottom: spacing.sm, + color: colors.warning.dark, + }, + daysText: { + fontSize: 32, + fontWeight: '700', + color: colors.warning.dark, + textAlign: 'center', + marginVertical: spacing.md, + }, + // 内容区域 + sectionContent: { + marginHorizontal: spacing['2xl'], + marginBottom: spacing.xl, + }, + sectionTitle: { + fontWeight: '600', + fontSize: fontSizes.sm, + color: colors.text.secondary, + textTransform: 'uppercase', + letterSpacing: 0.5, + marginBottom: spacing.md, + }, + listItem: { + flexDirection: 'row', + alignItems: 'flex-start', + marginBottom: spacing.sm, + }, + listBullet: { + marginRight: spacing.sm, + marginTop: 2, + }, + // 输入框 - 扁平化风格 + input: { + backgroundColor: '#F5F5F7', + borderRadius: 14, + padding: spacing.md, + fontSize: 16, + color: colors.text.primary, + height: 56, + }, + // 按钮行 + buttonRow: { + flexDirection: 'row', + gap: spacing.md, + marginTop: spacing.lg, + marginHorizontal: spacing['2xl'], + }, + // 扁平化按钮 + primaryButton: { + flex: 1, + height: 56, + borderRadius: 14, + backgroundColor: THEME_COLORS.primary, + alignItems: 'center', + justifyContent: 'center', + }, + primaryButtonText: { + color: '#fff', + fontSize: fontSizes.md, + fontWeight: '600', + }, + secondaryButton: { + flex: 1, + height: 56, + borderRadius: 14, + backgroundColor: 'transparent', + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1.5, + borderColor: colors.divider, + }, + secondaryButtonText: { + color: colors.text.primary, + fontSize: fontSizes.md, + fontWeight: '600', + }, + dangerButton: { + flex: 1, + height: 56, + borderRadius: 14, + backgroundColor: colors.error.main, + alignItems: 'center', + justifyContent: 'center', + }, + dangerButtonText: { + color: '#fff', + fontSize: fontSizes.md, + fontWeight: '600', + }, + buttonDisabled: { + opacity: 0.6, + }, + cancelButton: { + marginTop: spacing.lg, + marginHorizontal: spacing['2xl'], + }, + }); +} + +export const AccountDeletionScreen: React.FC = () => { + const colors = useAppColors(); + const styles = useMemo(() => createAccountDeletionStyles(colors), [colors]); + const router = useRouter(); + const { isMobile } = useResponsive(); + const insets = useSafeAreaInsets(); + const { logout } = useAuthStore(); + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [password, setPassword] = useState(''); + + const scrollBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.md; + + const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 }); + + const loadStatus = useCallback(async () => { + try { + const result = await authService.getDeletionStatus(); + setStatus(result); + } catch (error) { + console.error('加载注销状态失败:', error); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadStatus(); + }, [loadStatus]); + + const handleRequestDeletion = useCallback(async () => { + if (!password.trim()) { + Alert.alert('提示', '请输入密码确认'); + return; + } + + Alert.alert( + '确认注销', + '注销后,您的账号将在90天后永久删除。在此期间,您可以通过重新登录来取消注销。确定要继续吗?', + [ + { text: '取消', style: 'cancel' }, + { + text: '确定注销', + style: 'destructive', + onPress: async () => { + setSubmitting(true); + try { + const result = await authService.requestAccountDeletion(password); + if (result) { + setStatus(result); + setPassword(''); + Alert.alert('已申请注销', '您的账号将在90天后永久删除。在此期间登录即可取消注销。'); + await logout(); + router.replace(hrefs.hrefAuthLogin()); + } else { + Alert.alert('失败', '密码错误或申请失败,请重试'); + } + } catch (error) { + console.error('申请注销失败:', error); + Alert.alert('失败', '申请注销失败,请稍后重试'); + } finally { + setSubmitting(false); + } + }, + }, + ] + ); + }, [password, logout, router]); + + const handleCancelDeletion = useCallback(async () => { + Alert.alert( + '取消注销', + '确定要取消注销申请吗?您的账号将恢复正常使用。', + [ + { text: '继续注销', style: 'cancel' }, + { + text: '确定取消', + onPress: async () => { + setSubmitting(true); + try { + const ok = await authService.cancelAccountDeletion(); + if (ok) { + setStatus({ is_pending_deletion: false }); + Alert.alert('已取消', '注销申请已取消,您的账号已恢复正常使用'); + } else { + Alert.alert('失败', '取消注销失败,请稍后重试'); + } + } catch (error) { + console.error('取消注销失败:', error); + Alert.alert('失败', '取消注销失败,请稍后重试'); + } finally { + setSubmitting(false); + } + }, + }, + ] + ); + }, []); + + if (loading) { + return ( + + + + + + ); + } + + return ( + + + + {status?.is_pending_deletion ? ( + + + + 账号注销申请中 + + + 您的账号将在以下天数后永久删除: + + + {status.cool_down_days || 90} 天 + + + 在此期间,您可以通过重新登录或点击下方按钮来取消注销申请。 + + + {submitting ? ( + + ) : ( + 取消注销申请 + )} + + + + ) : ( + + {/* 警告卡片 */} + + + 警告:账号注销后将无法恢复 + + + 注销后,您的账号将在90天后永久删除。 + + + 在此期间,您可以通过重新登录来取消注销。 + + + + {/* 注销说明 */} + + 注销后将发生什么 + + + + 您的个人资料将被删除 + + + + + + 您发布的帖子、评论将保留,但显示为「已注销用户」 + + + + + + 您的关注、粉丝关系将被清除 + + + + + + 您的收藏、点赞记录将被删除 + + + + + {/* 密码确认 */} + + 请输入密码确认 + + + + {/* 按钮行 */} + + router.back()} + > + 返回 + + + {submitting ? ( + + ) : ( + 确认注销 + )} + + + + )} + + + + ); +}; + +export default AccountDeletionScreen; diff --git a/src/screens/profile/AccountSecurityScreen.tsx b/src/screens/profile/AccountSecurityScreen.tsx index 67ce997..70262ba 100644 --- a/src/screens/profile/AccountSecurityScreen.tsx +++ b/src/screens/profile/AccountSecurityScreen.tsx @@ -213,12 +213,13 @@ export const AccountSecurityScreen: React.FC = () => { {/* 邮箱验证分组 */} - - - 邮箱验证 - - - + + + + 邮箱验证 + + + {/* 状态显示 */} 当前状态 @@ -285,12 +286,13 @@ export const AccountSecurityScreen: React.FC = () => { {/* 修改密码分组 */} - - - 修改密码 - - - + + + + 修改密码 + + + {/* 当前密码 */} @@ -382,7 +384,7 @@ function createAccountSecurityStyles(colors: AppColors) { return StyleSheet.create({ container: { flex: 1, - backgroundColor: '#fff', + backgroundColor: colors.background.paper, }, scrollContent: { paddingVertical: spacing.lg, @@ -392,37 +394,33 @@ function createAccountSecurityStyles(colors: AppColors) { alignSelf: 'center', width: '100%', }, - // 分组标题 - groupHeader: { + // 分组样式 - 扁平化风格 + section: { + marginBottom: spacing['2xl'], + }, + sectionHeader: { paddingHorizontal: spacing['2xl'], marginBottom: spacing.sm, marginTop: spacing.sm, }, - groupTitle: { + sectionTitle: { 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'], - }, - // 状态显示 + // 状态显示 - 扁平化 statusRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', - paddingHorizontal: spacing.md, + paddingHorizontal: spacing['2xl'], paddingVertical: spacing.md, marginBottom: spacing.md, - backgroundColor: '#fff', - borderRadius: 12, + backgroundColor: colors.background.default, + borderRadius: 14, + marginHorizontal: spacing['2xl'], }, statusLabel: { fontSize: 15, @@ -449,15 +447,16 @@ function createAccountSecurityStyles(colors: AppColors) { statusUnverifiedText: { color: '#E65100', }, - // 输入框样式 - 统一 + // 输入框样式 - 扁平化风格,与登录页一致 inputWrapper: { flexDirection: 'row', alignItems: 'center', - backgroundColor: '#fff', + backgroundColor: '#F5F5F7', borderRadius: 14, - paddingHorizontal: spacing.md, + paddingHorizontal: spacing.lg, height: 56, - marginBottom: spacing.sm, + marginHorizontal: spacing['2xl'], + marginBottom: spacing.md, }, inputIcon: { marginRight: spacing.sm, @@ -473,13 +472,15 @@ function createAccountSecurityStyles(colors: AppColors) { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, - marginBottom: spacing.sm, + marginHorizontal: spacing['2xl'], + marginBottom: spacing.md, }, codeInput: { flex: 1, + marginHorizontal: 0, marginBottom: 0, }, - // 发送验证码按钮 + // 发送验证码按钮 - 扁平化 sendCodeButton: { height: 56, minWidth: 110, @@ -494,7 +495,7 @@ function createAccountSecurityStyles(colors: AppColors) { fontSize: fontSizes.sm, fontWeight: '600', }, - // 主按钮样式 - 统一 + // 主按钮样式 - 扁平化 primaryButton: { height: 56, borderRadius: 14, @@ -502,6 +503,7 @@ function createAccountSecurityStyles(colors: AppColors) { alignItems: 'center', justifyContent: 'center', marginTop: spacing.xs, + marginHorizontal: spacing['2xl'], }, primaryButtonText: { color: '#fff', diff --git a/src/screens/profile/PrivacySettingsScreen.tsx b/src/screens/profile/PrivacySettingsScreen.tsx new file mode 100644 index 0000000..ab0c82d --- /dev/null +++ b/src/screens/profile/PrivacySettingsScreen.tsx @@ -0,0 +1,239 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + ActivityIndicator, + Alert, + ScrollView, + StyleSheet, + TouchableOpacity, + View, +} from 'react-native'; +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { useRouter } from 'expo-router'; +import { authService } from '../../services'; +import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../theme'; +import { Text } from '../../components/common'; +import { useResponsive, useResponsiveSpacing } from '../../hooks'; +import type { PrivacySettingsDTO, VisibilityLevel } from '../../types/dto'; + +const CONTENT_MAX_WIDTH = 720; + +// 胡萝卜橙主题色 +const THEME_COLORS = { + primary: '#FF6B35', + primaryLight: '#FF8C5A', + primaryDark: '#E55A2B', +}; + +const VISIBILITY_OPTIONS: { value: VisibilityLevel; label: string; description: string }[] = [ + { value: 'everyone', label: '所有人可见', description: '任何人都可查看' }, + { value: 'following', label: '仅关注的人可见', description: '只有你关注的人可查看' }, + { value: 'self', label: '仅自己可见', description: '只有自己可查看' }, +]; + +const PRIVACY_ITEMS: { key: keyof PrivacySettingsDTO; title: string; description: string }[] = [ + { key: 'followers_visibility', title: '粉丝列表', description: '谁可以查看你的粉丝列表' }, + { key: 'following_visibility', title: '关注列表', description: '谁可以查看你的关注列表' }, + { key: 'posts_visibility', title: '帖子列表', description: '谁可以查看你的帖子列表' }, + { key: 'favorites_visibility', title: '收藏列表', description: '谁可以查看你的收藏列表' }, +]; + +function createPrivacySettingsStyles(colors: AppColors) { + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background.paper, + }, + loadingWrap: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + scrollContent: { + paddingVertical: spacing.lg, + }, + content: { + maxWidth: CONTENT_MAX_WIDTH, + alignSelf: 'center', + width: '100%', + }, + section: { + marginBottom: spacing['2xl'], + }, + sectionHeader: { + paddingHorizontal: spacing['2xl'], + marginBottom: spacing.sm, + marginTop: spacing.sm, + }, + sectionTitle: { + fontWeight: '600', + fontSize: fontSizes.sm, + color: colors.text.secondary, + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + // 隐私设置项 - 扁平化卡片风格 + item: { + backgroundColor: '#F5F5F7', + borderRadius: 14, + padding: spacing.lg, + marginHorizontal: spacing['2xl'], + marginBottom: spacing.md, + }, + itemHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: spacing.sm, + }, + itemTitle: { + fontWeight: '600', + fontSize: fontSizes.md, + color: colors.text.primary, + }, + itemDescription: { + fontSize: fontSizes.sm, + color: colors.text.secondary, + marginBottom: spacing.md, + }, + optionsRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.sm, + }, + // 选项按钮 - 扁平化风格 + optionButton: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: borderRadius.full, + borderWidth: 1, + borderColor: colors.divider, + backgroundColor: colors.background.paper, + }, + optionButtonActive: { + backgroundColor: THEME_COLORS.primary, + borderColor: THEME_COLORS.primary, + }, + optionText: { + fontSize: 13, + color: colors.text.primary, + }, + optionTextActive: { + color: '#fff', + fontWeight: '500', + }, + }); +} + +export const PrivacySettingsScreen: React.FC = () => { + const colors = useAppColors(); + const styles = useMemo(() => createPrivacySettingsStyles(colors), [colors]); + const router = useRouter(); + const { isMobile } = useResponsive(); + const insets = useSafeAreaInsets(); + const [settings, setSettings] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 }); + + const scrollBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.md; + + const loadSettings = useCallback(async () => { + try { + const result = await authService.getPrivacySettings(); + if (result) { + setSettings(result); + } + } catch (error) { + console.error('加载隐私设置失败:', error); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadSettings(); + }, [loadSettings]); + + const updateSetting = useCallback(async (key: keyof PrivacySettingsDTO, value: VisibilityLevel) => { + if (!settings) return; + + const newSettings = { ...settings, [key]: value }; + setSettings(newSettings); + + setSaving(true); + try { + const ok = await authService.updatePrivacySettings({ + [key]: value, + }); + if (!ok) { + Alert.alert('失败', '保存设置失败,请稍后重试'); + setSettings(settings); + } + } catch (error) { + console.error('保存隐私设置失败:', error); + setSettings(settings); + } finally { + setSaving(false); + } + }, [settings]); + + if (loading) { + return ( + + + + + + ); + } + + return ( + + + + + + + 隐私设置 + + + + {PRIVACY_ITEMS.map((item) => ( + + + {item.title} + + + {item.description} + + + {VISIBILITY_OPTIONS.map((option) => { + const isActive = settings?.[item.key] === option.value; + return ( + updateSetting(item.key, option.value)} + activeOpacity={0.7} + > + + {option.label} + + + ); + })} + + + ))} + + + + + ); +}; + +export default PrivacySettingsScreen; diff --git a/src/screens/profile/SettingsScreen.tsx b/src/screens/profile/SettingsScreen.tsx index 789940e..5146f64 100644 --- a/src/screens/profile/SettingsScreen.tsx +++ b/src/screens/profile/SettingsScreen.tsx @@ -56,6 +56,7 @@ const SETTINGS_GROUPS_BASE = [ { key: 'privacy', title: '隐私设置', icon: 'shield-account-outline', showArrow: true }, { key: 'security', title: '账号安全', icon: 'lock-outline', showArrow: true }, { key: 'blocked_users', title: '黑名单', icon: 'account-off-outline', showArrow: true }, + { key: 'account_deletion', title: '注销账号', icon: 'account-remove-outline', showArrow: true, danger: true }, ], }, { @@ -264,6 +265,12 @@ export const SettingsScreen: React.FC = () => { case 'security': router.push(hrefs.hrefProfileSecurity()); break; + case 'privacy': + router.push(hrefs.hrefProfilePrivacySettings()); + break; + case 'account_deletion': + router.push(hrefs.hrefProfileDeletion()); + break; case 'logout': Alert.alert( '退出登录', diff --git a/src/screens/profile/VerificationSettingsScreen.tsx b/src/screens/profile/VerificationSettingsScreen.tsx index 520a95e..79b3aae 100644 --- a/src/screens/profile/VerificationSettingsScreen.tsx +++ b/src/screens/profile/VerificationSettingsScreen.tsx @@ -105,7 +105,8 @@ export const VerificationSettingsScreen: React.FC = () => { ); } - const statusConfig = STATUS_CONFIG[status?.verification_status || 'not_submitted']; + const statusKey = (status?.verification_status || 'not_submitted') as keyof typeof STATUS_CONFIG; + const statusConfig = STATUS_CONFIG[statusKey]; const identityText = IDENTITY_TEXT[status?.identity || 'general']; return ( diff --git a/src/screens/profile/index.ts b/src/screens/profile/index.ts index 2f622a3..b99a146 100644 --- a/src/screens/profile/index.ts +++ b/src/screens/profile/index.ts @@ -19,6 +19,8 @@ export { TermsOfServiceScreen } from './TermsOfServiceScreen'; export { PrivacyPolicyScreen } from './PrivacyPolicyScreen'; export { VerificationSettingsScreen } from './VerificationSettingsScreen'; export { DataStorageScreen } from './DataStorageScreen'; +export { PrivacySettingsScreen } from './PrivacySettingsScreen'; +export { AccountDeletionScreen } from './AccountDeletionScreen'; // 导出 Hook 供需要自定义的场景使用 export { useUserProfile, createSharedProfileStyles, TABS, TAB_ICONS } from './useUserProfile'; diff --git a/src/services/authService.ts b/src/services/authService.ts index 090e98d..2985f8b 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -455,6 +455,60 @@ class AuthService { return { list: [], total: 0 }; } } + + // 隐私设置 + async getPrivacySettings(): Promise { + try { + const response = await api.get('/users/me/privacy'); + return response.data; + } catch (error) { + console.error('获取隐私设置失败:', error); + return null; + } + } + + async updatePrivacySettings(settings: import('../types/dto').PrivacySettingsRequestDTO): Promise { + try { + await api.put('/users/me/privacy', settings); + return true; + } catch (error) { + console.error('更新隐私设置失败:', error); + return false; + } + } + + // 账号注销 + async requestAccountDeletion(password: string): Promise { + try { + const response = await api.post('/users/me/deactivate', { + password, + }); + return response.data; + } catch (error) { + console.error('申请注销失败:', error); + return null; + } + } + + async cancelAccountDeletion(): Promise { + try { + await api.delete('/users/me/deactivate'); + return true; + } catch (error) { + console.error('取消注销失败:', error); + return false; + } + } + + async getDeletionStatus(): Promise { + try { + const response = await api.get('/users/me/deletion-status'); + return response.data; + } catch (error) { + console.error('获取注销状态失败:', error); + return null; + } + } } // ── 二维码登录相关接口 ── diff --git a/src/stores/authStore.ts b/src/stores/authStore.ts index a512332..b650b10 100644 --- a/src/stores/authStore.ts +++ b/src/stores/authStore.ts @@ -18,7 +18,7 @@ import { wsService } from '../services/wsService'; import { callStore } from './callStore'; import { useSessionStore } from './sessionStore'; import { - initDatabase, + switchDatabase, closeDatabase, isDbInitialized, getCurrentDbUserId, @@ -142,7 +142,7 @@ export const useAuthStore = create((set) => ({ const userId = String(user.id); // 2. 初始化用户专属数据库 - await initDatabase(userId); + await switchDatabase(userId); // 3. 持久化 userId(供下次冷启动提前初始化 DB 用) await saveUserId(userId); @@ -187,7 +187,7 @@ export const useAuthStore = create((set) => ({ const userId = String(user.id); - await initDatabase(userId); + await switchDatabase(userId); await saveUserId(userId); await cacheUser(user); @@ -260,7 +260,7 @@ export const useAuthStore = create((set) => ({ if (savedUserId) { // 2. 用已知的 userId 提前初始化 DB(数据库模块内部已处理重试) try { - await initDatabase(savedUserId); + await switchDatabase(savedUserId); } catch (dbErr) { console.warn('[AuthStore] DB 预初始化失败,将在 API 成功后重试:', dbErr); } @@ -275,7 +275,8 @@ export const useAuthStore = create((set) => ({ // 4. 确保 DB 已初始化(如果预初始化失败,这里会重试) const dbReady = isDbInitialized() && getCurrentDbUserId() === userId; if (!dbReady) { - await initDatabase(userId); +await switchDatabase(userId); + } // 5. 更新持久化的 userId diff --git a/src/types/dto.ts b/src/types/dto.ts index 4f4d593..a18c215 100644 --- a/src/types/dto.ts +++ b/src/types/dto.ts @@ -773,15 +773,73 @@ export function extractTextFromSegments(segments?: MessageSegment[]): string { return result.trim(); } +export type VisibilityLevel = 'everyone' | 'following' | 'self'; + +export interface PrivacySettingsDTO { + followers_visibility: VisibilityLevel; + following_visibility: VisibilityLevel; + posts_visibility: VisibilityLevel; + favorites_visibility: VisibilityLevel; +} + +export interface PrivacySettingsRequestDTO { + followers_visibility?: VisibilityLevel; + following_visibility?: VisibilityLevel; + posts_visibility?: VisibilityLevel; + favorites_visibility?: VisibilityLevel; +} + +export interface DeletionStatusDTO { + is_pending_deletion: boolean; + deletion_requested_at?: string; + cool_down_days?: number; +} + +// ==================== Vote DTOs ==================== + +export interface VoteOptionDTO { + id: string; + content: string; + votes_count: number; +} + +export interface VoteResultDTO { + options: VoteOptionDTO[]; + total_votes: number; + has_voted: boolean; + voted_option_id?: string; +} + +export interface CreateVotePostRequest { + title: string; + content?: string; + images?: string[]; + vote_options: Array<{ + content: string; + }>; + channel_id?: string; +} + // ==================== Verification DTOs ==================== -// 用户身份类型 +export type VerificationStatus = 'not_submitted' | 'pending' | 'approved' | 'rejected'; export type UserIdentity = 'general' | 'student' | 'teacher' | 'staff' | 'alumni' | 'external'; -// 认证状态 -export type VerificationStatus = 'not_submitted' | 'pending' | 'approved' | 'rejected'; +export interface VerificationStatusDTO { + verification_status: VerificationStatus; + identity?: UserIdentity; + has_pending_request?: boolean; + rejected_reason?: string; + verified_at?: string; + latest_record?: { + id: string; + reject_reason?: string; + real_name?: string; + department?: string; + created_at?: string; + }; +} -// 认证记录 export interface VerificationRecordDTO { id: string; user_id: string; @@ -792,22 +850,12 @@ export interface VerificationRecordDTO { employee_id?: string; department?: string; proof_materials: string; - reviewed_at?: string; - reviewed_by?: string; - reject_reason?: string; + reviewer_id?: string; + review_note?: string; created_at: string; updated_at: string; } -// 认证状态响应 -export interface VerificationStatusDTO { - identity: UserIdentity; - verification_status: VerificationStatus; - has_pending_request: boolean; - latest_record?: VerificationRecordDTO; -} - -// 提交认证请求 export interface SubmitVerificationRequest { identity: UserIdentity; real_name: string; @@ -817,66 +865,10 @@ export interface SubmitVerificationRequest { proof_materials: string; } -// 管理端认证列表项 -export interface AdminVerificationListItemDTO { - id: string; - user_id: string; - username: string; - nickname: string; - avatar: string; - identity: UserIdentity; - status: VerificationStatus; - real_name: string; - student_id?: string; - employee_id?: string; - department?: string; - created_at: string; - reviewed_at?: string; - reviewer_name?: string; +export interface RequestDeletionRequestDTO { + password: string; } -// 管理端认证详情 -export interface AdminVerificationDetailDTO extends AdminVerificationListItemDTO { - proof_materials: string; - reviewed_by?: string; - reject_reason?: string; -} - -// 审核认证请求 -export interface ReviewVerificationRequest { - approve: boolean; - reject_reason?: string; -} - -// 投票选项 -export interface VoteOptionDTO { - id: string; - content: string; - votes_count: number; -} - -// 投票结果 -export interface VoteResultDTO { - options: VoteOptionDTO[]; - total_votes: number; - has_voted: boolean; - voted_option_id?: string; -} - -// 创建投票帖子请求 -export interface CreateVotePostRequest { - title: string; - content?: string; - channel_id?: string; - images?: string[]; - vote_options: string[]; // 至少2个,最多10个 -} - -/** - * 从消息segments中提取纯文本内容(异步版本) - * 用于会话列表等需要获取AT用户名的场景 - * 优先从本地缓存获取用户信息,fallback到API - */ export async function extractTextFromSegmentsAsync( segments?: MessageSegment[], getUserCache?: (userId: string) => Promise,