<think>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. </think> 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
This commit is contained in:
@@ -42,6 +42,8 @@ export default function ProfileStackLayout() {
|
||||
<Stack.Screen name="privacy" options={{ title: '隐私政策' }} />
|
||||
<Stack.Screen name="verification" options={{ title: '身份认证' }} />
|
||||
<Stack.Screen name="data-storage" options={{ title: '数据与存储' }} />
|
||||
<Stack.Screen name="privacy-settings" options={{ title: '隐私设置' }} />
|
||||
<Stack.Screen name="account-deletion" options={{ title: '注销账号' }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
5
app/(app)/(tabs)/profile/account-deletion.tsx
Normal file
5
app/(app)/(tabs)/profile/account-deletion.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AccountDeletionScreen } from '../../../../src/screens/profile';
|
||||
|
||||
export default function AccountDeletionRoute() {
|
||||
return <AccountDeletionScreen />;
|
||||
}
|
||||
5
app/(app)/(tabs)/profile/privacy-settings.tsx
Normal file
5
app/(app)/(tabs)/profile/privacy-settings.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { PrivacySettingsScreen } from '../../../../src/screens/profile';
|
||||
|
||||
export default function PrivacySettingsRoute() {
|
||||
return <PrivacySettingsScreen />;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface ILocalDataSource {
|
||||
batch(operations: (db: ILocalDataSource) => Promise<void>): Promise<void>;
|
||||
enqueueWrite<T>(operation: () => Promise<T>): Promise<T>;
|
||||
initialize(userId?: string): Promise<void>;
|
||||
switchDatabase(userId?: string | null): Promise<void>;
|
||||
closeDatabase(): Promise<void>;
|
||||
}
|
||||
|
||||
// 缓存数据源接口
|
||||
|
||||
@@ -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<Post | null> {
|
||||
try {
|
||||
await this.localDb.initialize();
|
||||
const result = await this.localDb.getFirst<CachedPost>(
|
||||
'SELECT * FROM posts_cache WHERE id = ?',
|
||||
[id]
|
||||
@@ -200,7 +199,6 @@ export class PostRepository implements IPostRepository {
|
||||
*/
|
||||
private async saveToLocalCache(post: Post): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
this.memoryCache.clear();
|
||||
try {
|
||||
await this.localDb.initialize();
|
||||
await this.localDb.enqueueWrite(async () => {
|
||||
await this.localDb.run('DELETE FROM posts_cache');
|
||||
});
|
||||
|
||||
@@ -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<void> = Promise.resolve();
|
||||
class OperationQueue {
|
||||
private queue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
export class LocalDataSource implements ILocalDataSource {
|
||||
async initialize(userId?: string): Promise<void> {
|
||||
await databaseManager.initialize(userId);
|
||||
enqueue<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = this.queue.then(() => operation(), () => operation());
|
||||
this.queue = result.catch(() => {});
|
||||
return result;
|
||||
}
|
||||
|
||||
private async ensureDb(): Promise<SQLite.SQLiteDatabase> {
|
||||
return databaseManager.getDb();
|
||||
}
|
||||
|
||||
async query<T>(sql: string, params?: any[]): Promise<T[]> {
|
||||
try {
|
||||
const db = await this.ensureDb();
|
||||
if (params && params.length > 0) {
|
||||
return await db.getAllAsync<T>(sql, params as any);
|
||||
}
|
||||
return await db.getAllAsync<T>(sql);
|
||||
} catch (error) {
|
||||
this.handleError(error, 'QUERY');
|
||||
}
|
||||
}
|
||||
|
||||
async getFirst<T>(sql: string, params?: any[]): Promise<T | null> {
|
||||
try {
|
||||
const db = await this.ensureDb();
|
||||
if (params && params.length > 0) {
|
||||
return await db.getFirstAsync<T>(sql, params as any);
|
||||
}
|
||||
return await db.getFirstAsync<T>(sql);
|
||||
} catch (error) {
|
||||
this.handleError(error, 'GET_FIRST');
|
||||
}
|
||||
}
|
||||
|
||||
async execute(sql: string, params?: any[]): Promise<void> {
|
||||
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<void>): Promise<void> {
|
||||
try {
|
||||
const db = await this.ensureDb();
|
||||
await db.withTransactionAsync(async () => {
|
||||
await operations();
|
||||
});
|
||||
} catch (error) {
|
||||
this.handleError(error, 'TRANSACTION');
|
||||
}
|
||||
}
|
||||
|
||||
async batch(operations: (db: ILocalDataSource) => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await this.transaction(async () => {
|
||||
await operations(this);
|
||||
});
|
||||
} catch (error) {
|
||||
this.handleError(error, 'BATCH');
|
||||
}
|
||||
}
|
||||
|
||||
async enqueueWrite<T>(operation: () => Promise<T>): Promise<T> {
|
||||
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<void> {
|
||||
await this.queue;
|
||||
}
|
||||
}
|
||||
|
||||
export const localDataSource = new LocalDataSource();
|
||||
const operationQueue = new OperationQueue();
|
||||
|
||||
export class LocalDataSource implements ILocalDataSource {
|
||||
private async getDb(): Promise<SQLite.SQLiteDatabase> {
|
||||
return databaseManager.getDb();
|
||||
}
|
||||
|
||||
async initialize(userId?: string): Promise<void> {
|
||||
if (databaseManager.isReady()) {
|
||||
return;
|
||||
}
|
||||
await operationQueue.enqueue(() => databaseManager.initialize(userId));
|
||||
}
|
||||
|
||||
async query<T>(sql: string, params?: any[]): Promise<T[]> {
|
||||
const db = await this.getDb();
|
||||
if (params && params.length > 0) {
|
||||
return db.getAllAsync<T>(sql, params as any);
|
||||
}
|
||||
return db.getAllAsync<T>(sql);
|
||||
}
|
||||
|
||||
async getFirst<T>(sql: string, params?: any[]): Promise<T | null> {
|
||||
const db = await this.getDb();
|
||||
if (params && params.length > 0) {
|
||||
return db.getFirstAsync<T>(sql, params as any);
|
||||
}
|
||||
return db.getFirstAsync<T>(sql);
|
||||
}
|
||||
|
||||
async execute(sql: string, params?: any[]): Promise<void> {
|
||||
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<void>): Promise<void> {
|
||||
const db = await this.getDb();
|
||||
await db.withTransactionAsync(operations);
|
||||
}
|
||||
|
||||
async batch(operations: (db: ILocalDataSource) => Promise<void>): Promise<void> {
|
||||
await this.transaction(async () => {
|
||||
await operations(this);
|
||||
});
|
||||
}
|
||||
|
||||
async enqueueWrite<T>(operation: () => Promise<T>): Promise<T> {
|
||||
return operationQueue.enqueue(operation);
|
||||
}
|
||||
|
||||
async switchDatabase(userId?: string | null): Promise<void> {
|
||||
await operationQueue.drain();
|
||||
await operationQueue.enqueue(() => databaseManager.initialize(userId));
|
||||
}
|
||||
|
||||
async closeDatabase(): Promise<void> {
|
||||
await operationQueue.drain();
|
||||
await operationQueue.enqueue(() => databaseManager.close());
|
||||
}
|
||||
}
|
||||
|
||||
export const localDataSource = new LocalDataSource();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<void> | 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<void> {
|
||||
const targetUserId = userId || null;
|
||||
const callId = Date.now();
|
||||
|
||||
console.log(`[DatabaseManager ${callId}] initialize 开始, userId=${targetUserId}, 当前db=${!!this.db}, 当前userId=${this.currentUserId}`);
|
||||
|
||||
if (this.db && this.currentUserId === targetUserId) {
|
||||
console.log(`[DatabaseManager ${callId}] 已是目标数据库,直接返回`);
|
||||
this.isInitialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.initPromise && this.initTargetUserId === targetUserId) {
|
||||
console.log(`[DatabaseManager ${callId}] 等待进行中的初始化`);
|
||||
await this.initPromise;
|
||||
console.log(`[DatabaseManager ${callId}] 初始化完成`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.initPromise) {
|
||||
console.log(`[DatabaseManager ${callId}] 等待其他初始化完成`);
|
||||
await this.initPromise.catch(() => {});
|
||||
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<void> {
|
||||
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<SQLiteDatabase> {
|
||||
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<SQLiteDatabase> {
|
||||
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<void> {
|
||||
@@ -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<SQLiteDatabase> {
|
||||
if (this.isInitialized && this.db) return this.db;
|
||||
async getDb(): Promise<SQLiteDatabase> {
|
||||
if (this.db) return this.db;
|
||||
|
||||
if (this.initPromise) {
|
||||
const timeoutPromise = new Promise<null>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('等待数据库初始化超时')), timeout)
|
||||
);
|
||||
|
||||
await Promise.race([this.initPromise, timeoutPromise]);
|
||||
|
||||
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<T>(fn: () => Promise<T>): Promise<T> {
|
||||
return LockManager.withMutex(fn);
|
||||
return this.db !== null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,8 +33,12 @@ export const initDatabase = async (userId?: string | null): Promise<void> => {
|
||||
await databaseManager.initialize(userId);
|
||||
};
|
||||
|
||||
export const switchDatabase = async (userId?: string | null): Promise<void> => {
|
||||
await localDataSource.switchDatabase(userId);
|
||||
};
|
||||
|
||||
export const closeDatabase = async (): Promise<void> => {
|
||||
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();
|
||||
};
|
||||
@@ -23,33 +23,42 @@ import {
|
||||
CREATE_POSTS_CACHE_TABLE,
|
||||
} from './PostSchema';
|
||||
|
||||
export async function runMigrations(db: SQLiteDatabase): Promise<void> {
|
||||
if (typeof window !== 'undefined') {
|
||||
async function execWithRetry(db: SQLiteDatabase, sql: string, maxRetries = 5): Promise<void> {
|
||||
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<void> {
|
||||
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);
|
||||
|
||||
19
src/infrastructure/cache/MediaCacheManager.ts
vendored
19
src/infrastructure/cache/MediaCacheManager.ts
vendored
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -388,7 +388,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
content: content.trim(),
|
||||
images: imageUrls,
|
||||
channel_id: selectedChannelId || undefined,
|
||||
vote_options: validOptions,
|
||||
vote_options: validOptions.map(opt => ({ content: opt })),
|
||||
});
|
||||
showPrompt({
|
||||
type: 'info',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</View>
|
||||
@@ -783,6 +786,9 @@ export const MessageListScreen: React.FC = () => {
|
||||
ListEmptyComponent={renderEmpty}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={0.5}
|
||||
scrollEnabled={true}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="on-drag"
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
|
||||
@@ -512,6 +512,9 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
|
||||
ListFooterComponent={renderFooter}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={0.3}
|
||||
scrollEnabled={true}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="on-drag"
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
@@ -590,6 +593,9 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
|
||||
ListFooterComponent={renderFooter}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={0.3}
|
||||
scrollEnabled={true}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="on-drag"
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
|
||||
@@ -859,6 +859,9 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ 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 });
|
||||
|
||||
410
src/screens/profile/AccountDeletionScreen.tsx
Normal file
410
src/screens/profile/AccountDeletionScreen.tsx
Normal file
@@ -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<DeletionStatusDTO | null>(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 (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<View style={styles.loadingWrap}>
|
||||
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding }]}>
|
||||
<View style={styles.content}>
|
||||
{status?.is_pending_deletion ? (
|
||||
<View style={styles.section}>
|
||||
<View style={styles.statusCard}>
|
||||
<Text variant="body" style={styles.statusTitle}>
|
||||
账号注销申请中
|
||||
</Text>
|
||||
<Text variant="body" color={colors.text.secondary}>
|
||||
您的账号将在以下天数后永久删除:
|
||||
</Text>
|
||||
<Text variant="body" style={styles.daysText}>
|
||||
{status.cool_down_days || 90} 天
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
在此期间,您可以通过重新登录或点击下方按钮来取消注销申请。
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.secondaryButton, styles.cancelButton, submitting && styles.buttonDisabled]}
|
||||
onPress={handleCancelDeletion}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? (
|
||||
<ActivityIndicator size="small" color={colors.text.primary} />
|
||||
) : (
|
||||
<Text style={styles.secondaryButtonText}>取消注销申请</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.section}>
|
||||
{/* 警告卡片 */}
|
||||
<View style={styles.warningCard}>
|
||||
<Text variant="body" style={styles.warningTitle}>
|
||||
警告:账号注销后将无法恢复
|
||||
</Text>
|
||||
<Text variant="body" style={styles.warningText}>
|
||||
注销后,您的账号将在90天后永久删除。
|
||||
</Text>
|
||||
<Text variant="body" style={styles.warningText}>
|
||||
在此期间,您可以通过重新登录来取消注销。
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 注销说明 */}
|
||||
<View style={styles.sectionContent}>
|
||||
<Text style={styles.sectionTitle}>注销后将发生什么</Text>
|
||||
<View style={styles.listItem}>
|
||||
<MaterialCommunityIcons name="close-circle" size={16} color={colors.error.main} style={styles.listBullet} />
|
||||
<Text variant="body" color={colors.text.secondary}>
|
||||
您的个人资料将被删除
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.listItem}>
|
||||
<MaterialCommunityIcons name="information" size={16} color={colors.warning.main} style={styles.listBullet} />
|
||||
<Text variant="body" color={colors.text.secondary}>
|
||||
您发布的帖子、评论将保留,但显示为「已注销用户」
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.listItem}>
|
||||
<MaterialCommunityIcons name="close-circle" size={16} color={colors.error.main} style={styles.listBullet} />
|
||||
<Text variant="body" color={colors.text.secondary}>
|
||||
您的关注、粉丝关系将被清除
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.listItem}>
|
||||
<MaterialCommunityIcons name="close-circle" size={16} color={colors.error.main} style={styles.listBullet} />
|
||||
<Text variant="body" color={colors.text.secondary}>
|
||||
您的收藏、点赞记录将被删除
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 密码确认 */}
|
||||
<View style={styles.sectionContent}>
|
||||
<Text style={styles.sectionTitle}>请输入密码确认</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="请输入密码"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
secureTextEntry
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 按钮行 */}
|
||||
<View style={styles.buttonRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.secondaryButton}
|
||||
onPress={() => router.back()}
|
||||
>
|
||||
<Text style={styles.secondaryButtonText}>返回</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.dangerButton, submitting && styles.buttonDisabled]}
|
||||
onPress={handleRequestDeletion}
|
||||
disabled={submitting || !password.trim()}
|
||||
>
|
||||
{submitting ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.dangerButtonText}>确认注销</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountDeletionScreen;
|
||||
@@ -213,12 +213,13 @@ export const AccountSecurityScreen: React.FC = () => {
|
||||
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding }]}>
|
||||
<View style={styles.content}>
|
||||
{/* 邮箱验证分组 */}
|
||||
<View style={styles.groupHeader}>
|
||||
<Text variant="caption" style={styles.groupTitle}>
|
||||
邮箱验证
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.card}>
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text variant="caption" style={styles.sectionTitle}>
|
||||
邮箱验证
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 状态显示 */}
|
||||
<View style={styles.statusRow}>
|
||||
<Text style={styles.statusLabel}>当前状态</Text>
|
||||
@@ -285,12 +286,13 @@ export const AccountSecurityScreen: React.FC = () => {
|
||||
</View>
|
||||
|
||||
{/* 修改密码分组 */}
|
||||
<View style={styles.groupHeader}>
|
||||
<Text variant="caption" style={styles.groupTitle}>
|
||||
修改密码
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.card}>
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text variant="caption" style={styles.sectionTitle}>
|
||||
修改密码
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 当前密码 */}
|
||||
<View style={styles.inputWrapper}>
|
||||
<MaterialCommunityIcons name="lock-outline" size={20} color={colors.text.secondary} style={styles.inputIcon} />
|
||||
@@ -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',
|
||||
|
||||
239
src/screens/profile/PrivacySettingsScreen.tsx
Normal file
239
src/screens/profile/PrivacySettingsScreen.tsx
Normal file
@@ -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<PrivacySettingsDTO | null>(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 (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<View style={styles.loadingWrap}>
|
||||
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding }]}>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text variant="caption" style={styles.sectionTitle}>
|
||||
隐私设置
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{PRIVACY_ITEMS.map((item) => (
|
||||
<View key={item.key} style={styles.item}>
|
||||
<Text variant="body" style={styles.itemTitle}>
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.itemDescription}>
|
||||
{item.description}
|
||||
</Text>
|
||||
<View style={styles.optionsRow}>
|
||||
{VISIBILITY_OPTIONS.map((option) => {
|
||||
const isActive = settings?.[item.key] === option.value;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={option.value}
|
||||
style={[styles.optionButton, isActive && styles.optionButtonActive]}
|
||||
onPress={() => updateSetting(item.key, option.value)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text
|
||||
variant="caption"
|
||||
style={[styles.optionText, isActive ? styles.optionTextActive : {}]}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrivacySettingsScreen;
|
||||
@@ -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(
|
||||
'退出登录',
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -455,6 +455,60 @@ class AuthService {
|
||||
return { list: [], total: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// 隐私设置
|
||||
async getPrivacySettings(): Promise<import('../types/dto').PrivacySettingsDTO | null> {
|
||||
try {
|
||||
const response = await api.get<import('../types/dto').PrivacySettingsDTO>('/users/me/privacy');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('获取隐私设置失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async updatePrivacySettings(settings: import('../types/dto').PrivacySettingsRequestDTO): Promise<boolean> {
|
||||
try {
|
||||
await api.put('/users/me/privacy', settings);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('更新隐私设置失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 账号注销
|
||||
async requestAccountDeletion(password: string): Promise<import('../types/dto').DeletionStatusDTO | null> {
|
||||
try {
|
||||
const response = await api.post<import('../types/dto').DeletionStatusDTO>('/users/me/deactivate', {
|
||||
password,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('申请注销失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async cancelAccountDeletion(): Promise<boolean> {
|
||||
try {
|
||||
await api.delete('/users/me/deactivate');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('取消注销失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getDeletionStatus(): Promise<import('../types/dto').DeletionStatusDTO | null> {
|
||||
try {
|
||||
const response = await api.get<import('../types/dto').DeletionStatusDTO>('/users/me/deletion-status');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('获取注销状态失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 二维码登录相关接口 ──
|
||||
|
||||
@@ -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<AuthState>((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<AuthState>((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<AuthState>((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<AuthState>((set) => ({
|
||||
// 4. 确保 DB 已初始化(如果预初始化失败,这里会重试)
|
||||
const dbReady = isDbInitialized() && getCurrentDbUserId() === userId;
|
||||
if (!dbReady) {
|
||||
await initDatabase(userId);
|
||||
await switchDatabase(userId);
|
||||
|
||||
}
|
||||
|
||||
// 5. 更新持久化的 userId
|
||||
|
||||
140
src/types/dto.ts
140
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<UserDTO | null>,
|
||||
|
||||
Reference in New Issue
Block a user