From be8f6de8cf8193af639b826c383e59b1ac61949f Mon Sep 17 00:00:00 2001
From: lafay <2021211506@stu.hit.edu.cn>
Date: Wed, 8 Apr 2026 16:48:19 +0800
Subject: [PATCH] Let me analyze the staged changes to generate a proper
conventional commit message.
Looking at the changes:
1. **New files**:
- `app/(app)/(tabs)/profile/account-deletion.tsx` - account deletion screen
- `app/(app)/(tabs)/profile/privacy-settings.tsx` - privacy settings screen
- `src/screens/profile/AccountDeletionScreen.tsx` - account deletion implementation
- `src/screens/profile/PrivacySettingsScreen.tsx` - privacy settings implementation
2. **Modified files**:
- Database changes: `LocalDataSource.ts`, `DatabaseManager.ts`, `DatabaseConfig.ts`, `MigrationManager.ts` - refactoring database layer (removing LockManager, simplified initialization, added retry logic)
- Auth store: `authStore.ts` - changed `initDatabase` to `switchDatabase`
- Auth service: `authService.ts` - added privacy settings and account deletion methods
- DTOs: `dto.ts` - added PrivacySettingsDTO, DeletionStatusDTO, VoteOptionDTO, VoteResultDTO types
- Navigation: `hrefs.ts` - added new href functions for privacy settings and account deletion
- Profile screens: `SettingsScreen.tsx`, `index.ts`, `_layout.tsx` - added new menu items and routes
- Various message screens - added keyboard handling props
- `app/_layout.tsx` - added CSS for mobile touch handling
- `CreatePostScreen.tsx` - fixed vote_options format
- `PostDetailScreen.tsx` - added type annotations for vote handling
- `MediaCacheManager.ts` - updated to use new expo-file-system API
- `VerificationSettingsScreen.tsx` - fixed type safety
- `AccountSecurityScreen.tsx` - styling refactoring
This is a multi-faceted change involving:
Database refactoring, new privacy/account deletion features, bug fixes, and UI improvements. The database changes appear to be refactoring without new functionality. The most significant changes are the privacy settings and account deletion features, plus bug fixes like the vote options format and mobile touch handling.
feat(profile): add privacy settings and account deletion screens
Add privacy settings and account deletion functionality with new screens and APIs. Refactor database initialization to use switchDatabase for user switching. Fix vote options format in post creation and add type safety to vote handling. Improve mobile touch handling in layout and message screens. Update media cache to use new expo-file-system API.
BREAKING CHANGE: Database initialization now uses switchDatabase instead of initDatabase for user switching
---
app/(app)/(tabs)/profile/_layout.tsx | 2 +
app/(app)/(tabs)/profile/account-deletion.tsx | 5 +
app/(app)/(tabs)/profile/privacy-settings.tsx | 5 +
app/_layout.tsx | 19 +
src/data/datasources/interfaces.ts | 2 +
src/data/repositories/PostRepository.ts | 10 +-
src/database/LocalDataSource.ts | 193 ++++-----
src/database/core/DatabaseConfig.ts | 14 +-
src/database/core/DatabaseManager.ts | 211 ++++-----
src/database/index.ts | 10 +-
src/database/schema/MigrationManager.ts | 47 +-
src/infrastructure/cache/MediaCacheManager.ts | 19 +-
src/navigation/hrefs.ts | 8 +
src/screens/create/CreatePostScreen.tsx | 2 +-
src/screens/home/PostDetailScreen.tsx | 10 +-
src/screens/message/ChatScreen.tsx | 2 +
src/screens/message/MessageListScreen.tsx | 6 +
src/screens/message/NotificationsScreen.tsx | 6 +
.../message/components/EmbeddedChat.tsx | 3 +
src/screens/profile/AccountDeletionScreen.tsx | 410 ++++++++++++++++++
src/screens/profile/AccountSecurityScreen.tsx | 72 +--
src/screens/profile/PrivacySettingsScreen.tsx | 239 ++++++++++
src/screens/profile/SettingsScreen.tsx | 7 +
.../profile/VerificationSettingsScreen.tsx | 3 +-
src/screens/profile/index.ts | 2 +
src/services/authService.ts | 54 +++
src/stores/authStore.ts | 11 +-
src/types/dto.ts | 140 +++---
28 files changed, 1092 insertions(+), 420 deletions(-)
create mode 100644 app/(app)/(tabs)/profile/account-deletion.tsx
create mode 100644 app/(app)/(tabs)/profile/privacy-settings.tsx
create mode 100644 src/screens/profile/AccountDeletionScreen.tsx
create mode 100644 src/screens/profile/PrivacySettingsScreen.tsx
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,