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,