refactor(database): unify database connections and add retry logic
Refactor LocalDataSource to reuse the shared database connection from database.ts, avoiding OPFS file handle conflicts. Add retry logic with exponential backoff for database open operations, particularly important for Web/OPFS environments. Also add dynamic theme support to chat input and message bubble styles, using theme colors instead of hardcoded values for better light/dark mode support. BREAKING CHANGE: Database initialization now requires userId to be passed explicitly for user-specific databases
This commit is contained in:
@@ -1,41 +1,37 @@
|
||||
/**
|
||||
* 本地数据库数据源实现
|
||||
* 封装所有 SQLite 操作,提供统一的错误处理和事务支持
|
||||
* 复用 database.ts 的共享连接,避免 OPFS 句柄冲突
|
||||
*/
|
||||
|
||||
import * as SQLite from 'expo-sqlite';
|
||||
import { ILocalDataSource, DataSourceError } from './interfaces';
|
||||
import {
|
||||
initDatabase,
|
||||
getCurrentDbUserId,
|
||||
isDbInitialized,
|
||||
getDbInstance,
|
||||
} from '../../services/database';
|
||||
|
||||
// 数据库实例管理
|
||||
let dbInstance: SQLite.SQLiteDatabase | null = null;
|
||||
let currentDbName: string | null = null;
|
||||
let writeQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
export interface LocalDataSourceConfig {
|
||||
dbName?: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export class LocalDataSource implements ILocalDataSource {
|
||||
private db: SQLite.SQLiteDatabase | null = null;
|
||||
private dbName: string;
|
||||
private userId: string | null = null;
|
||||
private initialized = false;
|
||||
private initializingPromise: Promise<void> | null = null;
|
||||
|
||||
constructor(config: LocalDataSourceConfig = {}) {
|
||||
// 如果提供了userId,使用用户专属数据库
|
||||
this.dbName = config.dbName || (config.userId ? `carrot_bbs_${config.userId}.db` : 'carrot_bbs.db');
|
||||
this.userId = config.userId || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化数据库连接
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized && this.db) {
|
||||
if (this.initialized && isDbInitialized()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 防止并发初始化导致连接状态竞争
|
||||
if (this.initializingPromise) {
|
||||
await this.initializingPromise;
|
||||
return;
|
||||
@@ -51,131 +47,39 @@ export class LocalDataSource implements ILocalDataSource {
|
||||
|
||||
private async doInitialize(): Promise<void> {
|
||||
try {
|
||||
// 使用全局实例管理
|
||||
if (dbInstance && currentDbName === this.dbName) {
|
||||
this.db = dbInstance;
|
||||
this.initialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 关闭旧连接
|
||||
if (dbInstance) {
|
||||
try {
|
||||
await dbInstance.closeAsync();
|
||||
} catch (e) {
|
||||
console.warn('关闭旧数据库连接失败:', e);
|
||||
// 如果 database.ts 已经初始化,复用它的连接
|
||||
if (isDbInitialized()) {
|
||||
const currentUserId = getCurrentDbUserId();
|
||||
if (this.userId && this.userId !== currentUserId) {
|
||||
await initDatabase(this.userId);
|
||||
}
|
||||
} else if (this.userId) {
|
||||
await initDatabase(this.userId);
|
||||
} else {
|
||||
const currentUserId = getCurrentDbUserId();
|
||||
if (!currentUserId) {
|
||||
console.warn('[LocalDataSource] 没有用户登录,跳过初始化');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新连接
|
||||
this.db = await SQLite.openDatabaseAsync(this.dbName);
|
||||
dbInstance = this.db;
|
||||
currentDbName = this.dbName;
|
||||
const db = getDbInstance();
|
||||
if (db) {
|
||||
await db.execAsync(`
|
||||
CREATE TABLE IF NOT EXISTS posts_cache (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
|
||||
// 初始化数据库表结构
|
||||
await this.createTables();
|
||||
} catch (error) {
|
||||
this.handleError(error, 'INITIALIZE');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建数据库表结构
|
||||
*/
|
||||
private async createTables(): Promise<void> {
|
||||
if (!this.db) return;
|
||||
|
||||
const tables = [
|
||||
// 消息表
|
||||
`CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
conversationId TEXT NOT NULL,
|
||||
senderId TEXT NOT NULL,
|
||||
content TEXT,
|
||||
type TEXT DEFAULT 'text',
|
||||
isRead INTEGER DEFAULT 0,
|
||||
createdAt TEXT NOT NULL,
|
||||
seq INTEGER DEFAULT 0,
|
||||
status TEXT DEFAULT 'normal',
|
||||
segments TEXT
|
||||
)`,
|
||||
// 会话表
|
||||
`CREATE TABLE IF NOT EXISTS conversations (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
participantId TEXT NOT NULL,
|
||||
lastMessageId TEXT,
|
||||
lastSeq INTEGER DEFAULT 0,
|
||||
unreadCount INTEGER DEFAULT 0,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
)`,
|
||||
// 会话缓存表
|
||||
`CREATE TABLE IF NOT EXISTS conversation_cache (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
)`,
|
||||
// 会话列表缓存表
|
||||
`CREATE TABLE IF NOT EXISTS conversation_list_cache (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
)`,
|
||||
// 用户缓存表
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
)`,
|
||||
// 当前登录用户缓存
|
||||
`CREATE TABLE IF NOT EXISTS current_user_cache (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
)`,
|
||||
// 群组缓存表
|
||||
`CREATE TABLE IF NOT EXISTS groups (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
)`,
|
||||
// 群成员缓存表
|
||||
`CREATE TABLE IF NOT EXISTS group_members (
|
||||
groupId TEXT NOT NULL,
|
||||
userId TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL,
|
||||
PRIMARY KEY (groupId, userId)
|
||||
)`,
|
||||
// 帖子缓存表
|
||||
`CREATE TABLE IF NOT EXISTS posts_cache (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
)`,
|
||||
];
|
||||
|
||||
for (const sql of tables) {
|
||||
await this.db.execAsync(sql);
|
||||
}
|
||||
|
||||
// 创建索引
|
||||
const indexes = [
|
||||
`CREATE INDEX IF NOT EXISTS idx_messages_conversationId ON messages(conversationId)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_messages_seq ON messages(seq)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_messages_conversation_seq ON messages(conversationId, seq)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_group_members_groupId ON group_members(groupId)`,
|
||||
];
|
||||
|
||||
for (const sql of indexes) {
|
||||
await this.db.execAsync(sql);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理错误
|
||||
*/
|
||||
private handleError(error: unknown, operation: string): never {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
throw new DataSourceError(
|
||||
@@ -186,24 +90,21 @@ export class LocalDataSource implements ILocalDataSource {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查数据库连接
|
||||
*/
|
||||
private ensureDb(): SQLite.SQLiteDatabase {
|
||||
if (!this.db) {
|
||||
const db = getDbInstance();
|
||||
if (!db) {
|
||||
throw new DataSourceError(
|
||||
'Database not initialized',
|
||||
'DB_NOT_INITIALIZED',
|
||||
'LocalDataSource'
|
||||
);
|
||||
}
|
||||
return this.db;
|
||||
return db;
|
||||
}
|
||||
|
||||
// ==================== ILocalDataSource 实现 ====================
|
||||
|
||||
async query<T>(sql: string, params?: any[]): Promise<T[]> {
|
||||
try {
|
||||
await this.initialize();
|
||||
const db = this.ensureDb();
|
||||
if (params && params.length > 0) {
|
||||
return await db.getAllAsync<T>(sql, params as any);
|
||||
@@ -216,6 +117,7 @@ export class LocalDataSource implements ILocalDataSource {
|
||||
|
||||
async getFirst<T>(sql: string, params?: any[]): Promise<T | null> {
|
||||
try {
|
||||
await this.initialize();
|
||||
const db = this.ensureDb();
|
||||
if (params && params.length > 0) {
|
||||
return await db.getFirstAsync<T>(sql, params as any);
|
||||
@@ -228,6 +130,7 @@ export class LocalDataSource implements ILocalDataSource {
|
||||
|
||||
async execute(sql: string, params?: any[]): Promise<void> {
|
||||
try {
|
||||
await this.initialize();
|
||||
const db = this.ensureDb();
|
||||
await db.execAsync(sql);
|
||||
} catch (error) {
|
||||
@@ -237,6 +140,7 @@ export class LocalDataSource implements ILocalDataSource {
|
||||
|
||||
async run(sql: string, params?: any[]): Promise<{ lastInsertRowId: number; changes: number }> {
|
||||
try {
|
||||
await this.initialize();
|
||||
const db = this.ensureDb();
|
||||
if (params && params.length > 0) {
|
||||
return await db.runAsync(sql, params as any);
|
||||
@@ -244,9 +148,7 @@ export class LocalDataSource implements ILocalDataSource {
|
||||
return await db.runAsync(sql);
|
||||
} catch (error) {
|
||||
if (this.isRecoverableError(error)) {
|
||||
this.db = null;
|
||||
this.initialized = false;
|
||||
dbInstance = null;
|
||||
await this.initialize();
|
||||
const db = this.ensureDb();
|
||||
if (params && params.length > 0) {
|
||||
@@ -260,6 +162,7 @@ export class LocalDataSource implements ILocalDataSource {
|
||||
|
||||
async transaction(operations: () => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await this.initialize();
|
||||
const db = this.ensureDb();
|
||||
await db.withTransactionAsync(async () => {
|
||||
await operations();
|
||||
@@ -279,21 +182,15 @@ export class LocalDataSource implements ILocalDataSource {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 队列写入支持 ====================
|
||||
|
||||
/**
|
||||
* 使用队列执行写入操作(避免并发写入冲突)
|
||||
*/
|
||||
async enqueueWrite<T>(operation: () => Promise<T>): Promise<T> {
|
||||
await this.initialize();
|
||||
const wrappedOperation = async () => {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
// 尝试重连后重试
|
||||
if (this.isRecoverableError(error)) {
|
||||
console.error('数据库写入异常,尝试重连后重试:', error);
|
||||
this.db = null;
|
||||
dbInstance = null;
|
||||
console.error('[LocalDataSource] 数据库写入异常,尝试重连后重试:', error);
|
||||
this.initialized = false;
|
||||
await this.initialize();
|
||||
return operation();
|
||||
}
|
||||
@@ -306,18 +203,16 @@ export class LocalDataSource implements ILocalDataSource {
|
||||
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('database is locked') ||
|
||||
message.includes('finalizing statement') ||
|
||||
message.includes('cannot create file')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例实例
|
||||
export const localDataSource = new LocalDataSource();
|
||||
|
||||
@@ -51,8 +51,8 @@ export const ChatInput: React.FC<ChatInputProps & {
|
||||
// 动态颜色
|
||||
const textPrimary = dynamicStyles.textPrimaryColor || colors.chat.textPrimary;
|
||||
const textSecondary = dynamicStyles.textSecondaryColor || colors.chat.textSecondary;
|
||||
const textMuted = '#999'; // 禁用状态颜色
|
||||
const textDisabled = '#CCC'; // 完全禁用颜色
|
||||
const textMuted = colors.chat.textPlaceholder || '#999'; // 禁用状态颜色
|
||||
const textDisabled = colors.chat.iconMuted || '#CCC'; // 完全禁用颜色
|
||||
|
||||
// 响应式布局
|
||||
const isWideScreen = useBreakpointGTE('lg');
|
||||
@@ -62,6 +62,37 @@ export const ChatInput: React.FC<ChatInputProps & {
|
||||
isWideScreen ? { maxWidth: 900, alignSelf: 'center' as const, width: '100%' as const } : null,
|
||||
]), [isWideScreen, styles.inputContainer]);
|
||||
|
||||
// 附件样式 - 动态生成以使用主题色
|
||||
const attachmentStyles = useMemo(() => StyleSheet.create({
|
||||
stripScroll: {
|
||||
maxHeight: 88,
|
||||
marginBottom: 6,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
stripContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 4,
|
||||
},
|
||||
thumbWrap: {
|
||||
position: 'relative',
|
||||
marginRight: 8,
|
||||
},
|
||||
thumb: {
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 10,
|
||||
backgroundColor: colors.chat.surfaceMuted || '#E8EAED',
|
||||
},
|
||||
removeBtn: {
|
||||
position: 'absolute',
|
||||
top: -6,
|
||||
right: -6,
|
||||
backgroundColor: colors.chat.card || '#FFF',
|
||||
borderRadius: 12,
|
||||
},
|
||||
}), [colors]);
|
||||
|
||||
const canSend =
|
||||
(!!inputText.trim() || pendingAttachments.length > 0) &&
|
||||
!isComposerBusy &&
|
||||
@@ -162,11 +193,11 @@ export const ChatInput: React.FC<ChatInputProps & {
|
||||
onPress={onToggleEmoji}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={activePanel === 'emoji' ? "keyboard" : "emoticon-happy-outline"}
|
||||
size={26}
|
||||
color={isDisabled ? textDisabled : (activePanel === 'emoji' ? colors.primary.main : textSecondary)}
|
||||
/>
|
||||
<MaterialCommunityIcons
|
||||
name={activePanel === 'emoji' ? "keyboard" : "emoticon-happy-outline"}
|
||||
size={26}
|
||||
color={isDisabled ? textDisabled : (activePanel === 'emoji' ? colors.primary.main : textSecondary)}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.inputBox}>
|
||||
@@ -212,11 +243,11 @@ export const ChatInput: React.FC<ChatInputProps & {
|
||||
onPress={onToggleMore}
|
||||
disabled={isDisabled || disableMore}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={activePanel === 'more' ? "close-circle" : "plus-circle-outline"}
|
||||
size={26}
|
||||
color={isDisabled || disableMore ? '#CCC' : (activePanel === 'more' ? colors.primary.main : '#666')}
|
||||
/>
|
||||
<MaterialCommunityIcons
|
||||
name={activePanel === 'more' ? "close-circle" : "plus-circle-outline"}
|
||||
size={26}
|
||||
color={isDisabled || disableMore ? textDisabled : (activePanel === 'more' ? colors.primary.main : textSecondary)}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
@@ -224,34 +255,4 @@ export const ChatInput: React.FC<ChatInputProps & {
|
||||
);
|
||||
};
|
||||
|
||||
const attachmentStyles = StyleSheet.create({
|
||||
stripScroll: {
|
||||
maxHeight: 88,
|
||||
marginBottom: 6,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
stripContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 4,
|
||||
},
|
||||
thumbWrap: {
|
||||
position: 'relative',
|
||||
marginRight: 8,
|
||||
},
|
||||
thumb: {
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 10,
|
||||
backgroundColor: '#E8EAED',
|
||||
},
|
||||
removeBtn: {
|
||||
position: 'absolute',
|
||||
top: -6,
|
||||
right: -6,
|
||||
backgroundColor: '#FFF',
|
||||
borderRadius: 12,
|
||||
},
|
||||
});
|
||||
|
||||
export default ChatInput;
|
||||
|
||||
@@ -41,12 +41,14 @@ export function useChatDynamicStyles() {
|
||||
messageRadius,
|
||||
// 夜间模式时使用系统暗色,否则使用主题色
|
||||
outgoingBubbleColor: isNightMode ? colors.chat.bubbleOutgoing : theme.bubble,
|
||||
// 对方消息气泡背景色 - 使用纯白以区分header和背景
|
||||
theirBubbleBackgroundColor: isNightMode ? colors.chat.card : '#FFFFFF',
|
||||
// 背景色
|
||||
chatBackgroundColor: isNightMode ? colors.chat.screen : theme.secondary,
|
||||
// 卡片/头部背景色
|
||||
cardBackgroundColor: isNightMode ? colors.chat.card : theme.card,
|
||||
// 输入框背景色
|
||||
inputBackgroundColor: isNightMode ? colors.chat.surfaceRaised : theme.inputBg,
|
||||
// 卡片/头部背景色 - 使用主题次级色(稍深)
|
||||
cardBackgroundColor: isNightMode ? colors.chat.card : theme.secondary,
|
||||
// 输入框背景色 - 使用纯白以保持对比度
|
||||
inputBackgroundColor: isNightMode ? colors.chat.surfaceRaised : '#FFFFFF',
|
||||
// 面板背景色
|
||||
panelBackgroundColor: isNightMode ? colors.chat.surfaceMuted : theme.panelBg,
|
||||
// 主要文字颜色
|
||||
@@ -54,12 +56,13 @@ export function useChatDynamicStyles() {
|
||||
// 次要文字颜色
|
||||
textSecondaryColor: isNightMode ? colors.chat.textSecondary : theme.textSecondary,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export function createChatScreenStyles(colors: AppColors, dynamicStyles?: {
|
||||
fontSize?: number;
|
||||
messageRadius?: number;
|
||||
outgoingBubbleColor?: string;
|
||||
theirBubbleBackgroundColor?: string;
|
||||
chatBackgroundColor?: string;
|
||||
cardBackgroundColor?: string;
|
||||
inputBackgroundColor?: string;
|
||||
@@ -72,6 +75,7 @@ export function createChatScreenStyles(colors: AppColors, dynamicStyles?: {
|
||||
|
||||
// 主题颜色(优先使用主题色,否则使用默认色)
|
||||
const myBubbleBg = dynamicStyles?.outgoingBubbleColor || colors.chat.bubbleOutgoing;
|
||||
const theirBubbleBg = dynamicStyles?.theirBubbleBackgroundColor || colors.chat.card;
|
||||
const screenBgColor = dynamicStyles?.chatBackgroundColor || colors.chat.screen;
|
||||
const cardBgColor = dynamicStyles?.cardBackgroundColor || colors.chat.card;
|
||||
const inputBgColor = dynamicStyles?.inputBackgroundColor || colors.chat.surfaceRaised;
|
||||
@@ -329,7 +333,7 @@ export function createChatScreenStyles(colors: AppColors, dynamicStyles?: {
|
||||
borderBottomRightRadius: Math.max(4, messageRadius * 0.3),
|
||||
},
|
||||
theirBubbleInner: {
|
||||
backgroundColor: cardBgColor,
|
||||
backgroundColor: theirBubbleBg,
|
||||
borderTopLeftRadius: Math.max(4, messageRadius * 0.3),
|
||||
},
|
||||
recalledBubble: {
|
||||
@@ -423,7 +427,7 @@ export function createChatScreenStyles(colors: AppColors, dynamicStyles?: {
|
||||
inputWrapper: {
|
||||
backgroundColor: screenBgColor,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: 'rgba(0, 0, 0, 0.06)',
|
||||
borderTopColor: colors.chat.border,
|
||||
},
|
||||
inputContainer: {
|
||||
backgroundColor: 'transparent',
|
||||
|
||||
@@ -53,7 +53,7 @@ function createEmbeddedChatStyles(colors: AppColors) {
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: colors.background.paper,
|
||||
backgroundColor: colors.background.default,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.divider,
|
||||
shadowColor: 'transparent',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -258,11 +258,11 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
const savedUserId = await loadUserId();
|
||||
|
||||
if (savedUserId) {
|
||||
// 2. 用已知的 userId 提前初始化 DB
|
||||
// 2. 用已知的 userId 提前初始化 DB(数据库模块内部已处理重试)
|
||||
try {
|
||||
await initDatabase(savedUserId);
|
||||
} catch (dbErr) {
|
||||
console.warn('[AuthStore] DB 预初始化失败:', dbErr);
|
||||
console.warn('[AuthStore] DB 预初始化失败,将尝试在 API 成功后重新初始化:', dbErr);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
Reference in New Issue
Block a user