Initial frontend repository commit.
Include app source and update .gitignore to exclude local release artifacts and signing files. Made-with: Cursor
This commit is contained in:
309
src/stores/authStore.ts
Normal file
309
src/stores/authStore.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* 认证状态管理
|
||||
*
|
||||
* 链路设计:
|
||||
* authService = 纯 HTTP 层(Token 管理),不碰 SQLite
|
||||
* authStore = 编排层,负责:API → DB初始化 → 写缓存 → 更新状态
|
||||
*
|
||||
* 冷启动问题解法:
|
||||
* 将 userId 持久化到 AsyncStorage,这样 DB 可在 API 调用前提前初始化,
|
||||
* 彻底打破"需要用户ID才能初始化DB,但用户ID来自API"的循环依赖。
|
||||
*/
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { create } from 'zustand';
|
||||
import { User } from '../types';
|
||||
import { authService, resolveAuthApiError, websocketService, LoginRequest, RegisterRequest } from '../services';
|
||||
import {
|
||||
initDatabase,
|
||||
closeDatabase,
|
||||
saveUserCache,
|
||||
saveCurrentUserCache,
|
||||
clearCurrentUserCache,
|
||||
} from '../services/database';
|
||||
|
||||
// AsyncStorage 中保存已登录用户 ID 的 key
|
||||
const USER_ID_KEY = 'auth_user_id';
|
||||
|
||||
// ── userId 持久化辅助函数 ──
|
||||
async function saveUserId(userId: string): Promise<void> {
|
||||
try {
|
||||
await AsyncStorage.setItem(USER_ID_KEY, userId);
|
||||
} catch (err) {
|
||||
console.warn('[AuthStore] 保存 userId 失败:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUserId(): Promise<string | null> {
|
||||
try {
|
||||
return await AsyncStorage.getItem(USER_ID_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function clearUserId(): Promise<void> {
|
||||
try {
|
||||
await AsyncStorage.removeItem(USER_ID_KEY);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ── 写用户缓存(DB 已就绪后调用)──
|
||||
async function cacheUser(user: User): Promise<void> {
|
||||
try {
|
||||
await saveUserCache(user);
|
||||
await saveCurrentUserCache(user);
|
||||
} catch (err) {
|
||||
console.warn('[AuthStore] 写用户缓存失败:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 将 API 错误转换为用户可读的中文提示 ──
|
||||
function resolveLoginError(error: any): string {
|
||||
const code: number = error?.code ?? 0;
|
||||
const msg: string = error?.message ?? '';
|
||||
|
||||
// 网络层错误(无法连接服务器)
|
||||
if (
|
||||
msg.includes('Network request failed') ||
|
||||
msg.includes('网络请求失败') ||
|
||||
msg.includes('Failed to fetch') ||
|
||||
code === 500
|
||||
) {
|
||||
return '无法连接到服务器,请检查网络连接后重试';
|
||||
}
|
||||
|
||||
// 账号或密码错误
|
||||
if (code === 401 || msg.includes('密码') || msg.includes('用户名') || msg.includes('incorrect')) {
|
||||
return '用户名或密码错误,请重新输入';
|
||||
}
|
||||
|
||||
// 账号被禁用/封禁
|
||||
if (code === 403 || msg.includes('禁止') || msg.includes('封禁') || msg.includes('forbidden')) {
|
||||
return '账号已被禁用,请联系管理员';
|
||||
}
|
||||
|
||||
// 服务端明确返回了 message,直接用
|
||||
if (msg && msg !== '登录失败') {
|
||||
return msg;
|
||||
}
|
||||
|
||||
return '登录失败,请稍后重试';
|
||||
}
|
||||
|
||||
// ── 启动 WebSocket 服务 ──
|
||||
async function startWebSocket(): Promise<void> {
|
||||
try {
|
||||
await websocketService.start();
|
||||
console.log('[AuthStore] WebSocket 服务启动成功');
|
||||
} catch (error) {
|
||||
console.error('[AuthStore] 启动 WebSocket 服务失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
currentUser: User | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
|
||||
login: (credentials: LoginRequest) => Promise<boolean>;
|
||||
register: (data: RegisterRequest) => Promise<boolean>;
|
||||
logout: () => Promise<void>;
|
||||
updateUser: (user: Partial<User>) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
fetchCurrentUser: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
isAuthenticated: false,
|
||||
currentUser: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
// ── 登录 ──
|
||||
// 流程:API → 保存Token → 初始化DB → 持久化userId → 写缓存 → 设置状态
|
||||
login: async (credentials: LoginRequest) => {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
// 1. API 登录,authService 只负责 HTTP + Token
|
||||
const response = await authService.login(credentials);
|
||||
const user = response.user;
|
||||
|
||||
if (!user?.id) {
|
||||
throw new Error('登录响应中缺少用户信息');
|
||||
}
|
||||
|
||||
const userId = String(user.id);
|
||||
|
||||
// 2. 初始化用户专属数据库
|
||||
await initDatabase(userId);
|
||||
|
||||
// 3. 持久化 userId(供下次冷启动提前初始化 DB 用)
|
||||
await saveUserId(userId);
|
||||
|
||||
// 4. 写用户缓存(DB 已就绪)
|
||||
await cacheUser(user);
|
||||
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
currentUser: user,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// 5. 启动 WebSocket
|
||||
await startWebSocket();
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
set({
|
||||
isLoading: false,
|
||||
error: resolveLoginError(error),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// ── 注册 ──
|
||||
register: async (data: RegisterRequest) => {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const response = await authService.register(data);
|
||||
const user = response.user;
|
||||
|
||||
if (!user?.id) {
|
||||
throw new Error('注册响应中缺少用户信息');
|
||||
}
|
||||
|
||||
const userId = String(user.id);
|
||||
|
||||
await initDatabase(userId);
|
||||
await saveUserId(userId);
|
||||
await cacheUser(user);
|
||||
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
currentUser: user,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
await startWebSocket();
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
set({
|
||||
isLoading: false,
|
||||
error: resolveAuthApiError(error, '注册失败,请稍后重试'),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// ── 登出 ──
|
||||
logout: async () => {
|
||||
set({ isLoading: true });
|
||||
|
||||
try {
|
||||
// 1. 通知服务端(Token 清理在 authService 内部完成)
|
||||
await authService.logout();
|
||||
// 2. 停止 WebSocket
|
||||
websocketService.stop();
|
||||
// 3. 清除 DB 中的用户缓存(DB 此时一定已初始化)
|
||||
await clearCurrentUserCache().catch(() => {});
|
||||
// 4. 关闭数据库连接
|
||||
await closeDatabase();
|
||||
// 5. 清除持久化的 userId
|
||||
await clearUserId();
|
||||
} catch (error) {
|
||||
console.error('[AuthStore] 登出失败:', error);
|
||||
} finally {
|
||||
set({
|
||||
isAuthenticated: false,
|
||||
currentUser: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// ── 冷启动校验 ──
|
||||
// 流程:读取已保存的userId → 提前初始化DB → 纯API验证Token → 更新缓存 → 设置状态
|
||||
fetchCurrentUser: async () => {
|
||||
set({ isLoading: true });
|
||||
|
||||
try {
|
||||
// 1. 从 AsyncStorage 读取上次登录保存的 userId
|
||||
// 这是关键:让 DB 在 API 调用前就能初始化,打破循环依赖
|
||||
const savedUserId = await loadUserId();
|
||||
|
||||
if (savedUserId) {
|
||||
// 2. 用已知的 userId 提前初始化 DB
|
||||
try {
|
||||
await initDatabase(savedUserId);
|
||||
} catch (dbErr) {
|
||||
console.warn('[AuthStore] DB 预初始化失败:', dbErr);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 纯 API 调用验证 Token 有效性(fetchCurrentUserFromAPI 完全不碰 DB)
|
||||
const user = await authService.fetchCurrentUserFromAPI();
|
||||
|
||||
if (user) {
|
||||
const userId = String(user.id);
|
||||
|
||||
// 4. 若 userId 发生变化(极少数情况),重新初始化 DB 并更新持久化
|
||||
if (!savedUserId || savedUserId !== userId) {
|
||||
await initDatabase(userId);
|
||||
await saveUserId(userId);
|
||||
}
|
||||
|
||||
// 5. DB 已就绪,更新用户缓存
|
||||
await cacheUser(user);
|
||||
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
currentUser: user,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
// 6. 启动 WebSocket
|
||||
await startWebSocket();
|
||||
} else {
|
||||
// Token 已失效或不存在
|
||||
await clearUserId();
|
||||
set({
|
||||
isAuthenticated: false,
|
||||
currentUser: null,
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AuthStore] fetchCurrentUser 异常:', error);
|
||||
set({
|
||||
isAuthenticated: false,
|
||||
currentUser: null,
|
||||
isLoading: false,
|
||||
error: '获取用户信息失败',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
updateUser: (userData: Partial<User>) =>
|
||||
set((state) => ({
|
||||
currentUser: state.currentUser ? { ...state.currentUser, ...userData } : null,
|
||||
})),
|
||||
|
||||
setLoading: (loading: boolean) => set({ isLoading: loading }),
|
||||
|
||||
setError: (error: string | null) => set({ error }),
|
||||
}));
|
||||
|
||||
// 导出 selector hooks 以优化性能
|
||||
export const useCurrentUser = () => useAuthStore((state) => state.currentUser);
|
||||
export const useIsAuthenticated = () => useAuthStore((state) => state.isAuthenticated);
|
||||
export const useAuthLoading = () => useAuthStore((state) => state.isLoading);
|
||||
52
src/stores/cacheBus.ts
Normal file
52
src/stores/cacheBus.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export type CacheEventType =
|
||||
| 'list_updated'
|
||||
| 'detail_updated'
|
||||
| 'cache_state_updated'
|
||||
| 'error';
|
||||
|
||||
export interface CacheEvent<TPayload = any> {
|
||||
type: CacheEventType;
|
||||
payload: TPayload;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export type CacheSubscriber<TEvent extends CacheEvent = CacheEvent> = (event: TEvent) => void;
|
||||
|
||||
/**
|
||||
* 统一缓存总线基座:
|
||||
* - 单一订阅入口
|
||||
* - 统一事件通知
|
||||
* - 新订阅立即收到快照
|
||||
*/
|
||||
export abstract class CacheBus<TSnapshot, TEvent extends CacheEvent = CacheEvent> {
|
||||
private subscribers = new Set<CacheSubscriber<TEvent>>();
|
||||
|
||||
protected abstract getSnapshot(): TSnapshot;
|
||||
|
||||
protected notify(event: TEvent): void {
|
||||
this.subscribers.forEach((subscriber) => {
|
||||
try {
|
||||
subscriber(event);
|
||||
} catch (error) {
|
||||
console.error('[CacheBus] subscriber error:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
subscribe(subscriber: CacheSubscriber<TEvent>): () => void {
|
||||
this.subscribers.add(subscriber);
|
||||
this.emitInitialSnapshot(subscriber);
|
||||
return () => {
|
||||
this.subscribers.delete(subscriber);
|
||||
};
|
||||
}
|
||||
|
||||
protected emitInitialSnapshot(subscriber: CacheSubscriber<TEvent>): void {
|
||||
subscriber({
|
||||
type: 'cache_state_updated',
|
||||
payload: this.getSnapshot(),
|
||||
timestamp: Date.now(),
|
||||
} as TEvent);
|
||||
}
|
||||
}
|
||||
|
||||
323
src/stores/groupManager.ts
Normal file
323
src/stores/groupManager.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import {
|
||||
GroupListResponse,
|
||||
GroupMemberListResponse,
|
||||
GroupMemberResponse,
|
||||
GroupResponse,
|
||||
} from '../types/dto';
|
||||
import { groupService } from '../services/groupService';
|
||||
import {
|
||||
getAllGroupsCache,
|
||||
getGroupCache,
|
||||
getGroupMembersCache,
|
||||
saveGroupCache,
|
||||
saveGroupMembersCache,
|
||||
saveGroupsCache,
|
||||
saveUsersCache,
|
||||
} from '../services/database';
|
||||
import { CacheBus, CacheEvent } from './cacheBus';
|
||||
|
||||
interface GroupManagerSnapshot {
|
||||
groupIds: string[];
|
||||
memberGroupIds: string[];
|
||||
}
|
||||
|
||||
type GroupManagerEvent =
|
||||
| CacheEvent<{ groups: GroupResponse[] }>
|
||||
| CacheEvent<{ groupId: string; group: GroupResponse | null }>
|
||||
| CacheEvent<{ groupId: string; members: GroupMemberResponse[] }>
|
||||
| CacheEvent<GroupManagerSnapshot>
|
||||
| CacheEvent<{ error: unknown; context: string }>;
|
||||
|
||||
const GROUP_TTL = 60 * 1000;
|
||||
const MEMBERS_TTL = 120 * 1000;
|
||||
|
||||
interface CacheEntry<T> {
|
||||
data: T;
|
||||
timestamp: number;
|
||||
ttl: number;
|
||||
}
|
||||
|
||||
class GroupManager extends CacheBus<GroupManagerSnapshot, GroupManagerEvent> {
|
||||
private groupsListCache: CacheEntry<GroupResponse[]> | null = null;
|
||||
private groupDetailCache = new Map<string, CacheEntry<GroupResponse | null>>();
|
||||
private groupMembersCache = new Map<string, CacheEntry<GroupMemberResponse[]>>();
|
||||
private pendingRequests = new Map<string, Promise<any>>();
|
||||
|
||||
protected getSnapshot(): GroupManagerSnapshot {
|
||||
return {
|
||||
groupIds: [...this.groupDetailCache.keys()],
|
||||
memberGroupIds: [...this.groupMembersCache.keys()],
|
||||
};
|
||||
}
|
||||
|
||||
private isExpired(entry: CacheEntry<any>): boolean {
|
||||
return Date.now() - entry.timestamp > entry.ttl;
|
||||
}
|
||||
|
||||
private async dedupe<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
||||
const pending = this.pendingRequests.get(key) as Promise<T> | undefined;
|
||||
if (pending) return pending;
|
||||
const request = fetcher().finally(() => {
|
||||
this.pendingRequests.delete(key);
|
||||
});
|
||||
this.pendingRequests.set(key, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
async getGroups(page = 1, pageSize = 20, forceRefresh = false): Promise<GroupListResponse> {
|
||||
if (page === 1 && !forceRefresh && this.groupsListCache && !this.isExpired(this.groupsListCache)) {
|
||||
return {
|
||||
list: this.groupsListCache.data,
|
||||
total: this.groupsListCache.data.length,
|
||||
page: 1,
|
||||
page_size: pageSize,
|
||||
total_pages: 1,
|
||||
};
|
||||
}
|
||||
|
||||
if (page === 1 && !forceRefresh && this.groupsListCache && this.isExpired(this.groupsListCache)) {
|
||||
this.refreshGroupsInBackground(page, pageSize);
|
||||
return {
|
||||
list: this.groupsListCache.data,
|
||||
total: this.groupsListCache.data.length,
|
||||
page: 1,
|
||||
page_size: pageSize,
|
||||
total_pages: 1,
|
||||
};
|
||||
}
|
||||
|
||||
if (page === 1 && !forceRefresh) {
|
||||
const localGroups = await getAllGroupsCache();
|
||||
if (localGroups.length > 0) {
|
||||
this.groupsListCache = { data: localGroups, timestamp: Date.now(), ttl: GROUP_TTL };
|
||||
this.notify({
|
||||
type: 'list_updated',
|
||||
payload: { groups: localGroups },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
this.refreshGroupsInBackground(page, pageSize);
|
||||
return {
|
||||
list: localGroups,
|
||||
total: localGroups.length,
|
||||
page: 1,
|
||||
page_size: pageSize,
|
||||
total_pages: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return this.dedupe(`groups:list:${page}:${pageSize}`, async () => {
|
||||
const response = await groupService.fetchGroupsFromApi(page, pageSize);
|
||||
const groups = response.list || [];
|
||||
if (page === 1) {
|
||||
this.groupsListCache = { data: groups, timestamp: Date.now(), ttl: GROUP_TTL };
|
||||
}
|
||||
await saveGroupsCache(groups).catch(() => {});
|
||||
this.notify({
|
||||
type: 'list_updated',
|
||||
payload: { groups },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
private refreshGroupsInBackground(page = 1, pageSize = 20): void {
|
||||
this.dedupe(`groups:list:bg:${page}:${pageSize}`, async () => {
|
||||
const response = await groupService.fetchGroupsFromApi(page, pageSize);
|
||||
const groups = response.list || [];
|
||||
if (page === 1) {
|
||||
this.groupsListCache = { data: groups, timestamp: Date.now(), ttl: GROUP_TTL };
|
||||
saveGroupsCache(groups).catch(() => {});
|
||||
}
|
||||
this.notify({
|
||||
type: 'list_updated',
|
||||
payload: { groups },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return response;
|
||||
}).catch((error) => {
|
||||
this.notify({
|
||||
type: 'error',
|
||||
payload: { error, context: 'refreshGroupsInBackground' },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async getGroup(groupId: number | string, forceRefresh = false): Promise<GroupResponse> {
|
||||
const id = String(groupId);
|
||||
const cached = this.groupDetailCache.get(id);
|
||||
if (!forceRefresh && cached && !this.isExpired(cached) && cached.data) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
if (!forceRefresh && cached && this.isExpired(cached) && cached.data) {
|
||||
this.refreshGroupInBackground(id);
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
if (!forceRefresh) {
|
||||
const localGroup = await getGroupCache(id);
|
||||
if (localGroup) {
|
||||
this.groupDetailCache.set(id, { data: localGroup, timestamp: Date.now(), ttl: GROUP_TTL });
|
||||
this.notify({
|
||||
type: 'detail_updated',
|
||||
payload: { groupId: id, group: localGroup },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
this.refreshGroupInBackground(id);
|
||||
return localGroup;
|
||||
}
|
||||
}
|
||||
|
||||
return this.dedupe(`groups:detail:${id}`, async () => {
|
||||
const group = await groupService.fetchGroupFromApi(id);
|
||||
this.groupDetailCache.set(id, { data: group, timestamp: Date.now(), ttl: GROUP_TTL });
|
||||
await saveGroupCache(group).catch(() => {});
|
||||
this.notify({
|
||||
type: 'detail_updated',
|
||||
payload: { groupId: id, group },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return group;
|
||||
});
|
||||
}
|
||||
|
||||
private refreshGroupInBackground(groupId: string): void {
|
||||
this.dedupe(`groups:detail:bg:${groupId}`, async () => {
|
||||
const group = await groupService.fetchGroupFromApi(groupId);
|
||||
this.groupDetailCache.set(groupId, { data: group, timestamp: Date.now(), ttl: GROUP_TTL });
|
||||
saveGroupCache(group).catch(() => {});
|
||||
this.notify({
|
||||
type: 'detail_updated',
|
||||
payload: { groupId, group },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return group;
|
||||
}).catch((error) => {
|
||||
this.notify({
|
||||
type: 'error',
|
||||
payload: { error, context: 'refreshGroupInBackground' },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async getMembers(groupId: number | string, page = 1, pageSize = 50, forceRefresh = false): Promise<GroupMemberListResponse> {
|
||||
const id = String(groupId);
|
||||
const key = `${id}:${page}:${pageSize}`;
|
||||
const cached = this.groupMembersCache.get(key);
|
||||
if (!forceRefresh && cached && !this.isExpired(cached)) {
|
||||
return {
|
||||
list: cached.data,
|
||||
total: cached.data.length,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
total_pages: 1,
|
||||
};
|
||||
}
|
||||
|
||||
if (!forceRefresh && cached && this.isExpired(cached)) {
|
||||
this.refreshMembersInBackground(id, page, pageSize);
|
||||
return {
|
||||
list: cached.data,
|
||||
total: cached.data.length,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
total_pages: 1,
|
||||
};
|
||||
}
|
||||
|
||||
if (page === 1 && !forceRefresh) {
|
||||
const localMembers = await getGroupMembersCache(id);
|
||||
if (localMembers.length > 0) {
|
||||
this.groupMembersCache.set(key, {
|
||||
data: localMembers,
|
||||
timestamp: Date.now(),
|
||||
ttl: MEMBERS_TTL,
|
||||
});
|
||||
this.notify({
|
||||
type: 'detail_updated',
|
||||
payload: { groupId: id, members: localMembers },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
this.refreshMembersInBackground(id, page, pageSize);
|
||||
return {
|
||||
list: localMembers,
|
||||
total: localMembers.length,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
total_pages: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return this.dedupe(`groups:members:${key}`, async () => {
|
||||
const response = await groupService.fetchMembersFromApi(id, page, pageSize);
|
||||
const members = response.list || [];
|
||||
this.groupMembersCache.set(key, { data: members, timestamp: Date.now(), ttl: MEMBERS_TTL });
|
||||
if (page === 1) {
|
||||
await saveGroupMembersCache(id, members).catch(() => {});
|
||||
}
|
||||
await saveUsersCache(
|
||||
members
|
||||
.map((member) => member.user)
|
||||
.filter((user): user is NonNullable<typeof user> => Boolean(user))
|
||||
).catch(() => {});
|
||||
this.notify({
|
||||
type: 'detail_updated',
|
||||
payload: { groupId: id, members },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
private refreshMembersInBackground(groupId: string, page = 1, pageSize = 50): void {
|
||||
this.dedupe(`groups:members:bg:${groupId}:${page}:${pageSize}`, async () => {
|
||||
const response = await groupService.fetchMembersFromApi(groupId, page, pageSize);
|
||||
const members = response.list || [];
|
||||
const key = `${groupId}:${page}:${pageSize}`;
|
||||
this.groupMembersCache.set(key, { data: members, timestamp: Date.now(), ttl: MEMBERS_TTL });
|
||||
if (page === 1) {
|
||||
saveGroupMembersCache(groupId, members).catch(() => {});
|
||||
}
|
||||
saveUsersCache(
|
||||
members
|
||||
.map((member) => member.user)
|
||||
.filter((user): user is NonNullable<typeof user> => Boolean(user))
|
||||
).catch(() => {});
|
||||
this.notify({
|
||||
type: 'detail_updated',
|
||||
payload: { groupId, members },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return response;
|
||||
}).catch((error) => {
|
||||
this.notify({
|
||||
type: 'error',
|
||||
payload: { error, context: 'refreshMembersInBackground' },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
invalidate(groupId?: string): void {
|
||||
if (!groupId) {
|
||||
this.groupsListCache = null;
|
||||
this.groupDetailCache.clear();
|
||||
this.groupMembersCache.clear();
|
||||
return;
|
||||
}
|
||||
this.groupDetailCache.delete(groupId);
|
||||
[...this.groupMembersCache.keys()].forEach((key) => {
|
||||
if (key.startsWith(`${groupId}:`)) {
|
||||
this.groupMembersCache.delete(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const groupManager = new GroupManager();
|
||||
|
||||
38
src/stores/index.ts
Normal file
38
src/stores/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 状态管理 Store 导出
|
||||
*/
|
||||
|
||||
export { useAuthStore, useCurrentUser, useIsAuthenticated, useAuthLoading } from './authStore';
|
||||
export {
|
||||
useUserStore,
|
||||
usePosts,
|
||||
useNotifications,
|
||||
useNotificationBadge,
|
||||
useMessageUnreadCount,
|
||||
useSearchHistory
|
||||
} from './userStore';
|
||||
|
||||
// MessageManager 新架构导出(已替换 conversationStore)
|
||||
export { messageManager } from './messageManager';
|
||||
export type { MessageEvent, MessageEventType, MessageSubscriber } from './messageManager';
|
||||
export { postManager } from './postManager';
|
||||
export { groupManager } from './groupManager';
|
||||
export { userManager } from './userManager';
|
||||
export {
|
||||
useConversations as useMessageManagerConversations,
|
||||
useMessages,
|
||||
useSendMessage,
|
||||
useMarkAsRead,
|
||||
useUnreadCount,
|
||||
useTotalUnreadCount,
|
||||
useConversation as useMessageManagerConversation,
|
||||
useMessageManager,
|
||||
useChat,
|
||||
useMessageList,
|
||||
useMessageListRefresh,
|
||||
useCreateConversation,
|
||||
useUpdateConversation,
|
||||
useSystemUnreadCount as useMessageManagerSystemUnreadCount,
|
||||
useGroupTyping,
|
||||
useGroupMuted,
|
||||
} from './messageManagerHooks';
|
||||
2254
src/stores/messageManager.ts
Normal file
2254
src/stores/messageManager.ts
Normal file
File diff suppressed because it is too large
Load Diff
697
src/stores/messageManagerHooks.ts
Normal file
697
src/stores/messageManagerHooks.ts
Normal file
@@ -0,0 +1,697 @@
|
||||
/**
|
||||
* MessageManager React Hooks
|
||||
*
|
||||
* 提供React组件与MessageManager的集成
|
||||
* 所有hooks都基于MessageManager的订阅机制
|
||||
* 确保组件能实时获取状态更新
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { ConversationResponse, MessageResponse, MessageSegment } from '../types/dto';
|
||||
import { messageManager, MessageEvent } from './messageManager';
|
||||
|
||||
// ==================== useConversations - 获取会话列表 ====================
|
||||
|
||||
interface UseConversationsReturn {
|
||||
conversations: ConversationResponse[];
|
||||
isLoading: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话列表
|
||||
* 用于MessageListScreen等需要显示会话列表的组件
|
||||
*/
|
||||
export function useConversations(): UseConversationsReturn {
|
||||
const [conversations, setConversations] = useState<ConversationResponse[]>(
|
||||
() => messageManager.getConversations()
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(() => messageManager.isLoading());
|
||||
|
||||
useEffect(() => {
|
||||
// 订阅MessageManager的事件
|
||||
const unsubscribe = messageManager.subscribe((event: MessageEvent) => {
|
||||
switch (event.type) {
|
||||
case 'conversations_updated':
|
||||
setConversations(messageManager.getConversations());
|
||||
break;
|
||||
case 'connection_changed':
|
||||
// 连接状态变化时可能需要刷新
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// 初始化时确保 MessageManager 已初始化
|
||||
messageManager.initialize().catch(error => {
|
||||
console.error('[useConversations] 初始化失败:', error);
|
||||
});
|
||||
|
||||
// 冷启动兜底:延迟做一次强制刷新,避免首次因时序问题拿到空列表
|
||||
const coldStartSyncTimer = setTimeout(() => {
|
||||
messageManager.fetchConversations(true).catch(error => {
|
||||
console.error('[useConversations] 冷启动强制刷新失败:', error);
|
||||
});
|
||||
}, 1200);
|
||||
|
||||
return () => {
|
||||
clearTimeout(coldStartSyncTimer);
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
await messageManager.fetchConversations(true);
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
conversations,
|
||||
isLoading,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== useMessages - 获取指定会话的消息 ====================
|
||||
|
||||
interface UseMessagesReturn {
|
||||
messages: MessageResponse[];
|
||||
isLoading: boolean;
|
||||
hasMore: boolean;
|
||||
loadMore: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定会话的消息
|
||||
* 用于ChatScreen等需要显示消息的组件
|
||||
*
|
||||
* @param conversationId 会话ID
|
||||
*/
|
||||
export function useMessages(conversationId: string | null): UseMessagesReturn {
|
||||
const [messages, setMessages] = useState<MessageResponse[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!conversationId) {
|
||||
setMessages([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedConversationId = String(conversationId);
|
||||
console.log('[useMessages][DEBUG] useEffect 开始执行:', { conversationId: normalizedConversationId, timestamp: Date.now() });
|
||||
|
||||
setIsLoading(true);
|
||||
setHasMore(true);
|
||||
let isUnmounted = false;
|
||||
|
||||
const unsubscribe = messageManager.subscribe((event: MessageEvent) => {
|
||||
if (
|
||||
event.type === 'messages_updated' &&
|
||||
String(event.payload.conversationId) === normalizedConversationId
|
||||
) {
|
||||
setMessages(event.payload.messages);
|
||||
if (!isUnmounted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 先读取内存快照,确保页面切换时无闪烁
|
||||
const cachedMessages = messageManager.getMessages(normalizedConversationId);
|
||||
if (cachedMessages.length > 0) {
|
||||
setMessages(cachedMessages);
|
||||
}
|
||||
|
||||
// 架构入口:激活会话并完成初始化/同步
|
||||
messageManager.activateConversation(normalizedConversationId).catch(error => {
|
||||
console.error('[useMessages] 激活会话失败:', error);
|
||||
if (!isUnmounted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}).finally(() => {
|
||||
if (!isUnmounted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isUnmounted = true;
|
||||
unsubscribe();
|
||||
if (String(messageManager.getActiveConversation()) === normalizedConversationId) {
|
||||
messageManager.setActiveConversation(null);
|
||||
}
|
||||
};
|
||||
}, [conversationId]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!conversationId || isLoading || !hasMore) return;
|
||||
|
||||
const currentMessages = messageManager.getMessages(conversationId);
|
||||
if (currentMessages.length === 0) return;
|
||||
|
||||
// 获取最早的消息seq
|
||||
const minSeq = Math.min(...currentMessages.map(m => m.seq));
|
||||
|
||||
setIsLoading(true);
|
||||
const loadedMessages = await messageManager.loadMoreMessages(conversationId, minSeq, 20);
|
||||
setIsLoading(false);
|
||||
|
||||
// 如果没有加载到新消息,说明没有更多了
|
||||
if (loadedMessages.length === 0) {
|
||||
setHasMore(false);
|
||||
}
|
||||
}, [conversationId, isLoading, hasMore]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!conversationId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
await messageManager.fetchMessages(conversationId);
|
||||
setMessages(messageManager.getMessages(conversationId));
|
||||
setIsLoading(false);
|
||||
setHasMore(true);
|
||||
}, [conversationId]);
|
||||
|
||||
return {
|
||||
messages,
|
||||
isLoading,
|
||||
hasMore,
|
||||
loadMore,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== useSendMessage - 发送消息 ====================
|
||||
|
||||
interface UseSendMessageReturn {
|
||||
sendMessage: (segments: MessageSegment[], options?: { replyToId?: string }) => Promise<MessageResponse | null>;
|
||||
isSending: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息
|
||||
* 返回发送函数和发送状态
|
||||
*/
|
||||
export function useSendMessage(conversationId: string | null): UseSendMessageReturn {
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (segments: MessageSegment[], options?: { replyToId?: string }): Promise<MessageResponse | null> => {
|
||||
if (!conversationId) return null;
|
||||
|
||||
setIsSending(true);
|
||||
try {
|
||||
const message = await messageManager.sendMessage(conversationId, segments, options);
|
||||
return message;
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
},
|
||||
[conversationId]
|
||||
);
|
||||
|
||||
return {
|
||||
sendMessage,
|
||||
isSending,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== useMarkAsRead - 标记已读 ====================
|
||||
|
||||
interface UseMarkAsReadReturn {
|
||||
markAsRead: (seq: number) => Promise<void>;
|
||||
markAllAsRead: () => Promise<void>;
|
||||
isMarking: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记已读
|
||||
* 用于ChatScreen标记当前会话已读
|
||||
*/
|
||||
export function useMarkAsRead(conversationId: string | null): UseMarkAsReadReturn {
|
||||
const [isMarking, setIsMarking] = useState(false);
|
||||
|
||||
const markAsRead = useCallback(
|
||||
async (seq: number) => {
|
||||
if (!conversationId) return;
|
||||
|
||||
setIsMarking(true);
|
||||
try {
|
||||
await messageManager.markAsRead(conversationId, seq);
|
||||
} finally {
|
||||
setIsMarking(false);
|
||||
}
|
||||
},
|
||||
[conversationId]
|
||||
);
|
||||
|
||||
const markAllAsRead = useCallback(async () => {
|
||||
setIsMarking(true);
|
||||
try {
|
||||
await messageManager.markAllAsRead();
|
||||
} finally {
|
||||
setIsMarking(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
isMarking,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== useUnreadCount - 获取未读数 ====================
|
||||
|
||||
interface UseUnreadCountReturn {
|
||||
totalUnreadCount: number;
|
||||
systemUnreadCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取未读消息数
|
||||
* 用于TabBar角标等
|
||||
*/
|
||||
export function useUnreadCount(): UseUnreadCountReturn {
|
||||
const [counts, setCounts] = useState(() => messageManager.getUnreadCount());
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = messageManager.subscribe((event: MessageEvent) => {
|
||||
if (event.type === 'unread_count_updated') {
|
||||
setCounts(messageManager.getUnreadCount());
|
||||
}
|
||||
});
|
||||
|
||||
// 初始化时获取最新未读数
|
||||
messageManager.fetchUnreadCount();
|
||||
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
totalUnreadCount: counts.total,
|
||||
systemUnreadCount: counts.system,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== useTotalUnreadCount - 获取总未读数(包含系统消息) ====================
|
||||
|
||||
/**
|
||||
* 获取总未读消息数(会话未读 + 系统消息未读)
|
||||
* 专用于TabBar徽章显示
|
||||
*/
|
||||
export function useTotalUnreadCount(): number {
|
||||
const [totalCount, setTotalCount] = useState(() => {
|
||||
const counts = messageManager.getUnreadCount();
|
||||
return counts.total + counts.system;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = messageManager.subscribe((event: MessageEvent) => {
|
||||
if (event.type === 'unread_count_updated') {
|
||||
const counts = messageManager.getUnreadCount();
|
||||
setTotalCount(counts.total + counts.system);
|
||||
}
|
||||
});
|
||||
|
||||
// 初始化时获取最新未读数
|
||||
messageManager.fetchUnreadCount();
|
||||
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
return totalCount;
|
||||
}
|
||||
|
||||
// ==================== useConversation - 获取单个会话信息 ====================
|
||||
|
||||
interface UseConversationReturn {
|
||||
conversation: ConversationResponse | null;
|
||||
isLoading: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个会话信息
|
||||
*/
|
||||
export function useConversation(conversationId: string | null): UseConversationReturn {
|
||||
const [conversation, setConversation] = useState<ConversationResponse | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!conversationId) {
|
||||
setConversation(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取当前会话信息
|
||||
setConversation(messageManager.getConversation(conversationId));
|
||||
|
||||
// 订阅更新
|
||||
const unsubscribe = messageManager.subscribe((event: MessageEvent) => {
|
||||
if (event.type === 'conversations_updated') {
|
||||
const updated = messageManager.getConversation(conversationId);
|
||||
setConversation(updated);
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [conversationId]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!conversationId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
await messageManager.fetchConversationDetail(conversationId);
|
||||
setConversation(messageManager.getConversation(conversationId));
|
||||
setIsLoading(false);
|
||||
}, [conversationId]);
|
||||
|
||||
return {
|
||||
conversation,
|
||||
isLoading,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== useMessageManager - 通用订阅 ====================
|
||||
|
||||
interface UseMessageManagerReturn {
|
||||
isConnected: boolean;
|
||||
isInitialized: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用MessageManager订阅
|
||||
* 用于获取连接状态等全局信息
|
||||
*/
|
||||
export function useMessageManager(): UseMessageManagerReturn {
|
||||
const [isConnected, setIsConnected] = useState(() => messageManager.isConnected());
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = messageManager.subscribe((event: MessageEvent) => {
|
||||
if (event.type === 'connection_changed') {
|
||||
setIsConnected(event.payload.connected);
|
||||
}
|
||||
});
|
||||
|
||||
// 初始化
|
||||
messageManager.initialize().then(() => {
|
||||
setIsInitialized(true);
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
isInitialized,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== useCreateConversation - 创建会话 ====================
|
||||
|
||||
interface UseCreateConversationReturn {
|
||||
createConversation: (userId: string) => Promise<ConversationResponse | null>;
|
||||
isCreating: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建私聊会话
|
||||
*/
|
||||
export function useCreateConversation(): UseCreateConversationReturn {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
const createConversation = useCallback(async (userId: string): Promise<ConversationResponse | null> => {
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const conversation = await messageManager.createConversation(userId);
|
||||
return conversation;
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
createConversation,
|
||||
isCreating,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== useUpdateConversation - 更新会话 ====================
|
||||
|
||||
interface UseUpdateConversationReturn {
|
||||
updateConversation: (conversationId: string, updates: Partial<ConversationResponse>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地更新会话信息
|
||||
*/
|
||||
export function useUpdateConversation(): UseUpdateConversationReturn {
|
||||
const updateConversation = useCallback((conversationId: string, updates: Partial<ConversationResponse>) => {
|
||||
messageManager.updateConversation(conversationId, updates);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
updateConversation,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== useSystemUnreadCount - 系统消息未读数操作 ====================
|
||||
|
||||
interface UseSystemUnreadCountReturn {
|
||||
setSystemUnreadCount: (count: number) => void;
|
||||
incrementSystemUnreadCount: () => void;
|
||||
decrementSystemUnreadCount: (count?: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统消息未读数操作
|
||||
*/
|
||||
export function useSystemUnreadCount(): UseSystemUnreadCountReturn {
|
||||
const setSystemUnreadCount = useCallback((count: number) => {
|
||||
messageManager.setSystemUnreadCount(count);
|
||||
}, []);
|
||||
|
||||
const incrementSystemUnreadCount = useCallback(() => {
|
||||
messageManager.incrementSystemUnreadCount();
|
||||
}, []);
|
||||
|
||||
const decrementSystemUnreadCount = useCallback((count?: number) => {
|
||||
messageManager.decrementSystemUnreadCount(count);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
setSystemUnreadCount,
|
||||
incrementSystemUnreadCount,
|
||||
decrementSystemUnreadCount,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== useMessageListRefresh - MessageListScreen焦点刷新 ====================
|
||||
|
||||
interface UseMessageListRefreshReturn {
|
||||
refresh: () => Promise<void>;
|
||||
isRefreshing: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* MessageListScreen焦点刷新Hook
|
||||
* 从 ChatScreen 返回时,MessageManager 内存状态已经是最新的(markAsRead 乐观更新),
|
||||
* 无需再请求服务器(避免时序竞争覆盖已读状态)。
|
||||
* 仅在用户主动下拉刷新时才触发服务器请求。
|
||||
* @deprecated isFocused 参数保留仅为向后兼容,不再触发焦点刷新
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export function useMessageListRefresh(_isFocused?: boolean): UseMessageListRefreshReturn {
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const lastRefreshTimeRef = useRef<number>(0);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
// 防止重复刷新(最小间隔500ms)
|
||||
const now = Date.now();
|
||||
if (now - lastRefreshTimeRef.current < 500) {
|
||||
return;
|
||||
}
|
||||
lastRefreshTimeRef.current = now;
|
||||
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await messageManager.fetchConversations(true);
|
||||
} catch (error) {
|
||||
console.error('[useMessageListRefresh] 刷新失败:', error);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 获得焦点时不主动拉服务器——内存状态已是最新,避免时序竞争
|
||||
// refresh 仅供下拉刷新手动调用
|
||||
|
||||
return {
|
||||
refresh,
|
||||
isRefreshing,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 便捷hooks组合 ====================
|
||||
|
||||
/**
|
||||
* ChatScreen专用Hook组合
|
||||
* 整合所有ChatScreen需要的功能
|
||||
*/
|
||||
interface UseChatReturn {
|
||||
// 消息相关
|
||||
messages: MessageResponse[];
|
||||
isLoadingMessages: boolean;
|
||||
hasMoreMessages: boolean;
|
||||
loadMoreMessages: () => Promise<void>;
|
||||
refreshMessages: () => Promise<void>;
|
||||
|
||||
// 发送消息
|
||||
sendMessage: (segments: MessageSegment[], options?: { replyToId?: string }) => Promise<MessageResponse | null>;
|
||||
isSending: boolean;
|
||||
|
||||
// 已读相关
|
||||
markAsRead: (seq: number) => Promise<void>;
|
||||
|
||||
// 会话信息
|
||||
conversation: ConversationResponse | null;
|
||||
}
|
||||
|
||||
export function useChat(conversationId: string | null): UseChatReturn {
|
||||
const { messages, isLoading: isLoadingMessages, hasMore: hasMoreMessages, loadMore, refresh } = useMessages(conversationId);
|
||||
const { sendMessage, isSending } = useSendMessage(conversationId);
|
||||
const { markAsRead } = useMarkAsRead(conversationId);
|
||||
const { conversation } = useConversation(conversationId);
|
||||
|
||||
return {
|
||||
messages,
|
||||
isLoadingMessages,
|
||||
hasMoreMessages,
|
||||
loadMoreMessages: loadMore,
|
||||
refreshMessages: refresh,
|
||||
sendMessage,
|
||||
isSending,
|
||||
markAsRead,
|
||||
conversation,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== useGroupTyping - 群聊输入状态 ====================
|
||||
|
||||
interface UseGroupTypingReturn {
|
||||
typingUsers: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取群聊输入状态
|
||||
* @param groupId 群组ID
|
||||
*/
|
||||
export function useGroupTyping(groupId: string | null): UseGroupTypingReturn {
|
||||
const [typingUsers, setTypingUsers] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!groupId) {
|
||||
setTypingUsers([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 初始值
|
||||
setTypingUsers(messageManager.getTypingUsers(groupId));
|
||||
|
||||
// 订阅输入状态变化
|
||||
const unsubscribe = messageManager.subscribe((event: MessageEvent) => {
|
||||
if (event.type === 'typing_status' && event.payload.groupId === groupId) {
|
||||
setTypingUsers(event.payload.typingUsers);
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [groupId]);
|
||||
|
||||
return { typingUsers };
|
||||
}
|
||||
|
||||
// ==================== useGroupMuted - 群聊禁言状态 ====================
|
||||
|
||||
interface UseGroupMutedReturn {
|
||||
isMuted: boolean;
|
||||
setMuted: (muted: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取群聊禁言状态
|
||||
* @param groupId 群组ID
|
||||
* @param currentUserId 当前用户ID(用于判断禁言通知是否针对当前用户)
|
||||
*/
|
||||
export function useGroupMuted(groupId: string | null, currentUserId?: string): UseGroupMutedReturn {
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!groupId) {
|
||||
setIsMuted(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 初始值
|
||||
setIsMuted(messageManager.isMuted(groupId));
|
||||
|
||||
// 订阅群通知变化
|
||||
const unsubscribe = messageManager.subscribe((event: MessageEvent) => {
|
||||
if (event.type === 'group_notice' && event.payload.groupId === groupId) {
|
||||
const { noticeType, data } = event.payload;
|
||||
|
||||
// 如果提供了当前用户ID,只响应当前用户的禁言/解禁通知
|
||||
if ((noticeType === 'muted' || noticeType === 'unmuted')) {
|
||||
if (!currentUserId || data?.user_id === currentUserId) {
|
||||
setIsMuted(noticeType === 'muted');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [groupId, currentUserId]);
|
||||
|
||||
const setMuted = useCallback((muted: boolean) => {
|
||||
if (groupId) {
|
||||
messageManager.setMutedStatus(groupId, muted);
|
||||
setIsMuted(muted);
|
||||
}
|
||||
}, [groupId]);
|
||||
|
||||
return { isMuted, setMuted };
|
||||
}
|
||||
|
||||
/**
|
||||
* MessageListScreen专用Hook组合
|
||||
*/
|
||||
interface UseMessageListReturn {
|
||||
conversations: ConversationResponse[];
|
||||
isLoading: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
totalUnreadCount: number;
|
||||
systemUnreadCount: number;
|
||||
markAllAsRead: () => Promise<void>;
|
||||
isMarking: boolean;
|
||||
}
|
||||
|
||||
export function useMessageList(): UseMessageListReturn {
|
||||
const { conversations, isLoading, refresh } = useConversations();
|
||||
const { totalUnreadCount, systemUnreadCount } = useUnreadCount();
|
||||
const { markAllAsRead, isMarking } = useMarkAsRead(null);
|
||||
|
||||
return {
|
||||
conversations,
|
||||
isLoading,
|
||||
refresh,
|
||||
totalUnreadCount,
|
||||
systemUnreadCount,
|
||||
markAllAsRead,
|
||||
isMarking,
|
||||
};
|
||||
}
|
||||
174
src/stores/postManager.ts
Normal file
174
src/stores/postManager.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { Post } from '../types';
|
||||
import { postService } from '../services/postService';
|
||||
import { CacheBus, CacheEvent } from './cacheBus';
|
||||
|
||||
interface PostListPayload {
|
||||
key: string;
|
||||
posts: Post[];
|
||||
}
|
||||
|
||||
interface PostDetailPayload {
|
||||
postId: string;
|
||||
post: Post | null;
|
||||
}
|
||||
|
||||
interface PostManagerSnapshot {
|
||||
listKeys: string[];
|
||||
detailKeys: string[];
|
||||
}
|
||||
|
||||
type PostManagerEvent =
|
||||
| CacheEvent<PostListPayload>
|
||||
| CacheEvent<PostDetailPayload>
|
||||
| CacheEvent<PostManagerSnapshot>
|
||||
| CacheEvent<{ error: unknown; context: string }>;
|
||||
|
||||
const LIST_TTL = 30 * 1000;
|
||||
const DETAIL_TTL = 60 * 1000;
|
||||
|
||||
interface CacheEntry<T> {
|
||||
data: T;
|
||||
timestamp: number;
|
||||
ttl: number;
|
||||
}
|
||||
|
||||
class PostManager extends CacheBus<PostManagerSnapshot, PostManagerEvent> {
|
||||
private listCache = new Map<string, CacheEntry<Post[]>>();
|
||||
private detailCache = new Map<string, CacheEntry<Post | null>>();
|
||||
private pendingRequests = new Map<string, Promise<any>>();
|
||||
|
||||
protected getSnapshot(): PostManagerSnapshot {
|
||||
return {
|
||||
listKeys: [...this.listCache.keys()],
|
||||
detailKeys: [...this.detailCache.keys()],
|
||||
};
|
||||
}
|
||||
|
||||
private isExpired(entry: CacheEntry<any>): boolean {
|
||||
return Date.now() - entry.timestamp > entry.ttl;
|
||||
}
|
||||
|
||||
private async dedupe<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
||||
const pending = this.pendingRequests.get(key) as Promise<T> | undefined;
|
||||
if (pending) return pending;
|
||||
const request = fetcher().finally(() => {
|
||||
this.pendingRequests.delete(key);
|
||||
});
|
||||
this.pendingRequests.set(key, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
private listKey(type: string, page: number, pageSize: number): string {
|
||||
return `${type}:${page}:${pageSize}`;
|
||||
}
|
||||
|
||||
async getPosts(
|
||||
type = 'recommend',
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
forceRefresh = false
|
||||
): Promise<Post[]> {
|
||||
const key = this.listKey(type, page, pageSize);
|
||||
const cached = this.listCache.get(key);
|
||||
if (!forceRefresh && cached && !this.isExpired(cached)) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
if (!forceRefresh && cached && this.isExpired(cached)) {
|
||||
this.refreshPostsInBackground(key, type, page, pageSize);
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
return this.dedupe(`posts:list:${key}`, async () => {
|
||||
const response = await postService.getPosts(page, pageSize, type);
|
||||
const posts = response.list || [];
|
||||
this.listCache.set(key, { data: posts, timestamp: Date.now(), ttl: LIST_TTL });
|
||||
this.notify({
|
||||
type: 'list_updated',
|
||||
payload: { key, posts },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return posts;
|
||||
});
|
||||
}
|
||||
|
||||
private refreshPostsInBackground(key: string, type: string, page: number, pageSize: number): void {
|
||||
this.dedupe(`posts:list:bg:${key}`, async () => {
|
||||
const response = await postService.getPosts(page, pageSize, type);
|
||||
const posts = response.list || [];
|
||||
this.listCache.set(key, { data: posts, timestamp: Date.now(), ttl: LIST_TTL });
|
||||
this.notify({
|
||||
type: 'list_updated',
|
||||
payload: { key, posts },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return posts;
|
||||
}).catch((error) => {
|
||||
this.notify({
|
||||
type: 'error',
|
||||
payload: { error, context: 'refreshPostsInBackground' },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async getPostDetail(postId: string, forceRefresh = false): Promise<Post | null> {
|
||||
const cached = this.detailCache.get(postId);
|
||||
if (!forceRefresh && cached && !this.isExpired(cached)) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
if (!forceRefresh && cached && this.isExpired(cached)) {
|
||||
this.refreshPostDetailInBackground(postId);
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
return this.dedupe(`posts:detail:${postId}`, async () => {
|
||||
const post = await postService.getPost(postId);
|
||||
this.detailCache.set(postId, { data: post, timestamp: Date.now(), ttl: DETAIL_TTL });
|
||||
this.notify({
|
||||
type: 'detail_updated',
|
||||
payload: { postId, post },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return post;
|
||||
});
|
||||
}
|
||||
|
||||
private refreshPostDetailInBackground(postId: string): void {
|
||||
this.dedupe(`posts:detail:bg:${postId}`, async () => {
|
||||
const post = await postService.getPost(postId);
|
||||
this.detailCache.set(postId, { data: post, timestamp: Date.now(), ttl: DETAIL_TTL });
|
||||
this.notify({
|
||||
type: 'detail_updated',
|
||||
payload: { postId, post },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return post;
|
||||
}).catch((error) => {
|
||||
this.notify({
|
||||
type: 'error',
|
||||
payload: { error, context: 'refreshPostDetailInBackground' },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
invalidate(postId?: string): void {
|
||||
if (postId) {
|
||||
this.detailCache.delete(postId);
|
||||
return;
|
||||
}
|
||||
this.listCache.clear();
|
||||
this.detailCache.clear();
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.listCache.clear();
|
||||
this.detailCache.clear();
|
||||
this.pendingRequests.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export const postManager = new PostManager();
|
||||
|
||||
188
src/stores/userManager.ts
Normal file
188
src/stores/userManager.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { UserDTO } from '../types/dto';
|
||||
import { authService } from '../services/authService';
|
||||
import {
|
||||
getCurrentUserCache,
|
||||
getUserCache,
|
||||
saveCurrentUserCache,
|
||||
saveUserCache,
|
||||
} from '../services/database';
|
||||
import { CacheBus, CacheEvent } from './cacheBus';
|
||||
|
||||
interface UserManagerSnapshot {
|
||||
hasCurrentUser: boolean;
|
||||
cachedUserIds: string[];
|
||||
}
|
||||
|
||||
type UserManagerEvent =
|
||||
| CacheEvent<{ user: UserDTO | null }>
|
||||
| CacheEvent<{ userId: string; user: UserDTO | null }>
|
||||
| CacheEvent<UserManagerSnapshot>
|
||||
| CacheEvent<{ error: unknown; context: string }>;
|
||||
|
||||
const USER_TTL = 5 * 60 * 1000;
|
||||
|
||||
interface CacheEntry<T> {
|
||||
data: T;
|
||||
timestamp: number;
|
||||
ttl: number;
|
||||
}
|
||||
|
||||
class UserManager extends CacheBus<UserManagerSnapshot, UserManagerEvent> {
|
||||
private currentUserCache: CacheEntry<UserDTO | null> | null = null;
|
||||
private userCache = new Map<string, CacheEntry<UserDTO | null>>();
|
||||
private pendingRequests = new Map<string, Promise<any>>();
|
||||
|
||||
protected getSnapshot(): UserManagerSnapshot {
|
||||
return {
|
||||
hasCurrentUser: !!this.currentUserCache?.data,
|
||||
cachedUserIds: [...this.userCache.keys()],
|
||||
};
|
||||
}
|
||||
|
||||
private isExpired(entry: CacheEntry<any>): boolean {
|
||||
return Date.now() - entry.timestamp > entry.ttl;
|
||||
}
|
||||
|
||||
private async dedupe<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
||||
const pending = this.pendingRequests.get(key) as Promise<T> | undefined;
|
||||
if (pending) return pending;
|
||||
const request = fetcher().finally(() => {
|
||||
this.pendingRequests.delete(key);
|
||||
});
|
||||
this.pendingRequests.set(key, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
async getCurrentUser(forceRefresh = false): Promise<UserDTO | null> {
|
||||
if (!forceRefresh && this.currentUserCache && !this.isExpired(this.currentUserCache)) {
|
||||
return this.currentUserCache.data;
|
||||
}
|
||||
|
||||
if (!forceRefresh && this.currentUserCache && this.isExpired(this.currentUserCache)) {
|
||||
this.refreshCurrentUserInBackground();
|
||||
return this.currentUserCache.data;
|
||||
}
|
||||
|
||||
if (!forceRefresh) {
|
||||
const cachedFromDb = await getCurrentUserCache();
|
||||
if (cachedFromDb) {
|
||||
this.currentUserCache = { data: cachedFromDb, timestamp: Date.now(), ttl: USER_TTL };
|
||||
this.notify({
|
||||
type: 'detail_updated',
|
||||
payload: { user: cachedFromDb },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
this.refreshCurrentUserInBackground();
|
||||
return cachedFromDb;
|
||||
}
|
||||
}
|
||||
|
||||
return this.dedupe('users:current', async () => {
|
||||
const user = await authService.fetchCurrentUserFromAPI();
|
||||
this.currentUserCache = { data: user, timestamp: Date.now(), ttl: USER_TTL };
|
||||
if (user) {
|
||||
saveCurrentUserCache(user).catch(() => {});
|
||||
saveUserCache(user).catch(() => {});
|
||||
}
|
||||
this.notify({
|
||||
type: 'detail_updated',
|
||||
payload: { user },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return user;
|
||||
});
|
||||
}
|
||||
|
||||
private refreshCurrentUserInBackground(): void {
|
||||
this.dedupe('users:current:bg', async () => {
|
||||
const user = await authService.fetchCurrentUserFromAPI();
|
||||
this.currentUserCache = { data: user, timestamp: Date.now(), ttl: USER_TTL };
|
||||
if (user) {
|
||||
saveCurrentUserCache(user).catch(() => {});
|
||||
saveUserCache(user).catch(() => {});
|
||||
}
|
||||
this.notify({
|
||||
type: 'detail_updated',
|
||||
payload: { user },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return user;
|
||||
}).catch((error) => {
|
||||
this.notify({
|
||||
type: 'error',
|
||||
payload: { error, context: 'refreshCurrentUserInBackground' },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async getUserById(userId: string, forceRefresh = false): Promise<UserDTO | null> {
|
||||
const cached = this.userCache.get(userId);
|
||||
if (!forceRefresh && cached && !this.isExpired(cached)) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
if (!forceRefresh && cached && this.isExpired(cached)) {
|
||||
this.refreshUserByIdInBackground(userId);
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
if (!forceRefresh) {
|
||||
const cachedFromDb = await getUserCache(userId);
|
||||
if (cachedFromDb) {
|
||||
this.userCache.set(userId, { data: cachedFromDb, timestamp: Date.now(), ttl: USER_TTL });
|
||||
this.notify({
|
||||
type: 'detail_updated',
|
||||
payload: { userId, user: cachedFromDb },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
this.refreshUserByIdInBackground(userId);
|
||||
return cachedFromDb;
|
||||
}
|
||||
}
|
||||
|
||||
return this.dedupe(`users:detail:${userId}`, async () => {
|
||||
const user = await authService.fetchUserByIdFromAPI(userId);
|
||||
this.userCache.set(userId, { data: user, timestamp: Date.now(), ttl: USER_TTL });
|
||||
if (user) {
|
||||
saveUserCache(user).catch(() => {});
|
||||
}
|
||||
this.notify({
|
||||
type: 'detail_updated',
|
||||
payload: { userId, user },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return user;
|
||||
});
|
||||
}
|
||||
|
||||
private refreshUserByIdInBackground(userId: string): void {
|
||||
this.dedupe(`users:detail:bg:${userId}`, async () => {
|
||||
const user = await authService.fetchUserByIdFromAPI(userId);
|
||||
this.userCache.set(userId, { data: user, timestamp: Date.now(), ttl: USER_TTL });
|
||||
if (user) {
|
||||
saveUserCache(user).catch(() => {});
|
||||
}
|
||||
this.notify({
|
||||
type: 'detail_updated',
|
||||
payload: { userId, user },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return user;
|
||||
}).catch((error) => {
|
||||
this.notify({
|
||||
type: 'error',
|
||||
payload: { error, context: 'refreshUserByIdInBackground' },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
invalidateUsers(): void {
|
||||
this.currentUserCache = null;
|
||||
this.userCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export const userManager = new UserManager();
|
||||
|
||||
603
src/stores/userStore.ts
Normal file
603
src/stores/userStore.ts
Normal file
@@ -0,0 +1,603 @@
|
||||
/**
|
||||
* 用户状态管理
|
||||
* 使用Zustand进行状态管理
|
||||
* 对接真实后端API
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { User, Post, Notification, NotificationBadge } from '../types';
|
||||
import { PaginatedData } from '../services/api';
|
||||
import {
|
||||
authService,
|
||||
postService,
|
||||
commentService,
|
||||
notificationService,
|
||||
} from '../services';
|
||||
import { userManager } from './userManager';
|
||||
import { messageManager } from './messageManager';
|
||||
|
||||
interface UserState {
|
||||
// 状态
|
||||
users: User[];
|
||||
userCache: Record<string, User>; // 用户缓存
|
||||
posts: Post[];
|
||||
notifications: Notification[];
|
||||
notificationBadge: NotificationBadge;
|
||||
messageUnreadCount: number;
|
||||
|
||||
// 加载状态
|
||||
isLoadingPosts: boolean;
|
||||
isLoadingNotifications: boolean;
|
||||
|
||||
// 搜索历史
|
||||
searchHistory: string[];
|
||||
|
||||
// 操作
|
||||
fetchUser: (userId: string) => Promise<User | undefined>;
|
||||
fetchUserPosts: (userId: string, page?: number) => Promise<Post[]>;
|
||||
fetchPosts: (type: 'recommend' | 'follow' | 'hot' | 'latest', page?: number) => Promise<PaginatedData<Post>>;
|
||||
fetchNotifications: (type?: string, page?: number) => Promise<Notification[]>;
|
||||
markNotificationAsRead: (notificationId: string) => Promise<void>;
|
||||
markAllNotificationsAsRead: () => Promise<void>;
|
||||
fetchNotificationBadge: () => Promise<void>;
|
||||
setMessageUnreadCount: (count: number) => void;
|
||||
fetchMessageUnreadCount: () => Promise<void>;
|
||||
addSearchHistory: (keyword: string) => void;
|
||||
clearSearchHistory: () => void;
|
||||
|
||||
// 互动操作
|
||||
likePost: (postId: string) => Promise<void>;
|
||||
unlikePost: (postId: string) => Promise<void>;
|
||||
favoritePost: (postId: string) => Promise<void>;
|
||||
unfavoritePost: (postId: string) => Promise<void>;
|
||||
likeComment: (commentId: string) => Promise<void>;
|
||||
unlikeComment: (commentId: string) => Promise<void>;
|
||||
followUser: (userId: string) => Promise<void>;
|
||||
unfollowUser: (userId: string) => Promise<void>;
|
||||
|
||||
// 刷新数据
|
||||
refreshAll: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useUserStore = create<UserState>((set, get) => ({
|
||||
// 初始状态
|
||||
users: [],
|
||||
userCache: {},
|
||||
posts: [],
|
||||
notifications: [],
|
||||
notificationBadge: {
|
||||
total: 0,
|
||||
likes: 0,
|
||||
comments: 0,
|
||||
follows: 0,
|
||||
system: 0,
|
||||
},
|
||||
messageUnreadCount: 0,
|
||||
searchHistory: [],
|
||||
isLoadingPosts: false,
|
||||
isLoadingNotifications: false,
|
||||
|
||||
// 获取用户信息
|
||||
fetchUser: async (userId: string) => {
|
||||
// 先检查缓存
|
||||
const cached = get().userCache[userId];
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const user = await userManager.getUserById(userId);
|
||||
if (user) {
|
||||
set(state => ({
|
||||
userCache: { ...state.userCache, [userId]: user }
|
||||
}));
|
||||
return user;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取用户信息失败:', error);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
// 获取用户帖子
|
||||
fetchUserPosts: async (userId: string, page = 1) => {
|
||||
try {
|
||||
const response = await postService.getUserPosts(userId, page);
|
||||
const newPosts = response.list;
|
||||
|
||||
set(state => ({
|
||||
posts: page === 1 ? newPosts : [...state.posts, ...newPosts]
|
||||
}));
|
||||
|
||||
return newPosts;
|
||||
} catch (error) {
|
||||
console.error('获取用户帖子失败:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
// 获取帖子列表(首页)
|
||||
fetchPosts: async (type: 'recommend' | 'follow' | 'hot' | 'latest', page = 1) => {
|
||||
set({ isLoadingPosts: true });
|
||||
|
||||
try {
|
||||
let response;
|
||||
|
||||
switch (type) {
|
||||
case 'recommend':
|
||||
response = await postService.getRecommendedPosts(page);
|
||||
break;
|
||||
case 'follow':
|
||||
response = await postService.getFollowingPosts(page);
|
||||
break;
|
||||
case 'hot':
|
||||
response = await postService.getHotPosts(page);
|
||||
break;
|
||||
case 'latest':
|
||||
default:
|
||||
response = await postService.getLatestPosts(page);
|
||||
break;
|
||||
}
|
||||
|
||||
const newPosts = response.list;
|
||||
|
||||
set(state => ({
|
||||
posts: page === 1 ? newPosts : [...state.posts, ...newPosts],
|
||||
isLoadingPosts: false
|
||||
}));
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('获取帖子列表失败:', error);
|
||||
set({ isLoadingPosts: false });
|
||||
return {
|
||||
list: [],
|
||||
total: 0,
|
||||
page,
|
||||
page_size: 20,
|
||||
total_pages: 0,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
// 获取通知列表
|
||||
fetchNotifications: async (type?: string, page = 1) => {
|
||||
set({ isLoadingNotifications: true });
|
||||
|
||||
try {
|
||||
const response = await notificationService.getNotifications(
|
||||
page,
|
||||
20,
|
||||
type as any
|
||||
);
|
||||
|
||||
const newNotifications = response.list;
|
||||
|
||||
set(state => ({
|
||||
notifications: page === 1 ? newNotifications : [...state.notifications, ...newNotifications],
|
||||
isLoadingNotifications: false
|
||||
}));
|
||||
|
||||
return newNotifications;
|
||||
} catch (error) {
|
||||
console.error('获取通知列表失败:', error);
|
||||
set({ isLoadingNotifications: false });
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
// 标记通知为已读
|
||||
markNotificationAsRead: async (notificationId: string) => {
|
||||
try {
|
||||
await notificationService.markAsRead(notificationId);
|
||||
|
||||
set(state => {
|
||||
const notifications = state.notifications.map(n =>
|
||||
n.id === notificationId ? { ...n, isRead: true } : n
|
||||
);
|
||||
|
||||
return {
|
||||
notifications,
|
||||
notificationBadge: {
|
||||
total: notifications.filter(n => !n.isRead).length,
|
||||
likes: notifications.filter(n => n.type === 'like_post' && !n.isRead).length,
|
||||
comments: notifications.filter(n => n.type === 'comment' && !n.isRead).length,
|
||||
follows: notifications.filter(n => n.type === 'follow' && !n.isRead).length,
|
||||
system: notifications.filter(n => n.type === 'system' && !n.isRead).length,
|
||||
}
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('标记通知为已读失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 标记所有通知为已读
|
||||
markAllNotificationsAsRead: async () => {
|
||||
try {
|
||||
await notificationService.markAllAsRead();
|
||||
|
||||
set(state => ({
|
||||
notifications: state.notifications.map(n => ({ ...n, isRead: true })),
|
||||
notificationBadge: {
|
||||
total: 0,
|
||||
likes: 0,
|
||||
comments: 0,
|
||||
follows: 0,
|
||||
system: 0,
|
||||
}
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('标记所有通知为已读失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 获取通知角标
|
||||
fetchNotificationBadge: async () => {
|
||||
try {
|
||||
const badge = await notificationService.getNotificationBadge();
|
||||
set({ notificationBadge: badge });
|
||||
} catch (error) {
|
||||
console.error('获取通知角标失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 设置消息未读数
|
||||
// 注意:未读数现在由 MessageManager 统一管理
|
||||
// 此方法保留用于 TabBar 角标显示,从 MessageManager 同步
|
||||
setMessageUnreadCount: (count: number) => {
|
||||
set({ messageUnreadCount: count });
|
||||
},
|
||||
|
||||
// 从后端拉取最新消息未读数
|
||||
// @deprecated 请使用 messageManager.fetchUnreadCount() 代替
|
||||
// 此方法保留用于向后兼容
|
||||
fetchMessageUnreadCount: async () => {
|
||||
try {
|
||||
await messageManager.fetchUnreadCount();
|
||||
const unread = messageManager.getUnreadCount();
|
||||
set({ messageUnreadCount: unread.total + unread.system });
|
||||
} catch (error) {
|
||||
console.error('获取消息未读数失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 添加搜索历史
|
||||
addSearchHistory: (keyword: string) => {
|
||||
set(state => {
|
||||
const history = [keyword, ...state.searchHistory.filter(k => k !== keyword)].slice(0, 20);
|
||||
return { searchHistory: history };
|
||||
});
|
||||
},
|
||||
|
||||
// 清除搜索历史
|
||||
clearSearchHistory: () => {
|
||||
set({ searchHistory: [] });
|
||||
},
|
||||
|
||||
// 点赞帖子 - 乐观更新
|
||||
likePost: async (postId: string) => {
|
||||
console.log('[userStore] likePost called:', postId);
|
||||
|
||||
// 先乐观更新本地状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? { ...p, is_liked: true, likes_count: p.likes_count + 1 } : p
|
||||
)
|
||||
}));
|
||||
|
||||
// 调用API
|
||||
try {
|
||||
const updatedPost = await postService.likePost(postId);
|
||||
if (updatedPost) {
|
||||
console.log('[userStore] likePost success, updated post:', updatedPost);
|
||||
// 使用后端返回的更新后数据更新状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? updatedPost : p
|
||||
)
|
||||
}));
|
||||
} else {
|
||||
// API 返回失败,回滚状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? { ...p, is_liked: false, likes_count: Math.max(0, p.likes_count - 1) } : p
|
||||
)
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('点赞帖子失败:', error);
|
||||
// 失败回滚状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? { ...p, is_liked: false, likes_count: Math.max(0, p.likes_count - 1) } : p
|
||||
)
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
// 取消点赞 - 乐观更新
|
||||
unlikePost: async (postId: string) => {
|
||||
console.log('[userStore] unlikePost called:', postId);
|
||||
|
||||
// 先乐观更新本地状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? { ...p, is_liked: false, likes_count: Math.max(0, p.likes_count - 1) } : p
|
||||
)
|
||||
}));
|
||||
|
||||
// 调用API
|
||||
try {
|
||||
const updatedPost = await postService.unlikePost(postId);
|
||||
if (updatedPost) {
|
||||
console.log('[userStore] unlikePost success, updated post:', updatedPost);
|
||||
// 使用后端返回的更新后数据更新状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? updatedPost : p
|
||||
)
|
||||
}));
|
||||
} else {
|
||||
// API 返回失败,回滚状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? { ...p, is_liked: true, likes_count: p.likes_count + 1 } : p
|
||||
)
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('取消点赞帖子失败:', error);
|
||||
// 失败回滚状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? { ...p, is_liked: true, likes_count: p.likes_count + 1 } : p
|
||||
)
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
// 收藏帖子 - 乐观更新
|
||||
favoritePost: async (postId: string) => {
|
||||
console.log('[userStore] favoritePost called:', postId);
|
||||
|
||||
// 先乐观更新本地状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? { ...p, is_favorited: true, favorites_count: p.favorites_count + 1 } : p
|
||||
)
|
||||
}));
|
||||
|
||||
// 调用API
|
||||
try {
|
||||
const updatedPost = await postService.favoritePost(postId);
|
||||
if (updatedPost) {
|
||||
console.log('[userStore] favoritePost success, updated post:', updatedPost);
|
||||
// 使用后端返回的更新后数据更新状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? updatedPost : p
|
||||
)
|
||||
}));
|
||||
} else {
|
||||
// API 返回失败,回滚状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? { ...p, is_favorited: false, favorites_count: Math.max(0, p.favorites_count - 1) } : p
|
||||
)
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('收藏帖子失败:', error);
|
||||
// 失败回滚状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? { ...p, is_favorited: false, favorites_count: Math.max(0, p.favorites_count - 1) } : p
|
||||
)
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
// 取消收藏 - 乐观更新
|
||||
unfavoritePost: async (postId: string) => {
|
||||
console.log('[userStore] unfavoritePost called:', postId);
|
||||
|
||||
// 先乐观更新本地状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? { ...p, is_favorited: false, favorites_count: Math.max(0, p.favorites_count - 1) } : p
|
||||
)
|
||||
}));
|
||||
|
||||
// 调用API
|
||||
try {
|
||||
const updatedPost = await postService.unfavoritePost(postId);
|
||||
if (updatedPost) {
|
||||
console.log('[userStore] unfavoritePost success, updated post:', updatedPost);
|
||||
// 使用后端返回的更新后数据更新状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? updatedPost : p
|
||||
)
|
||||
}));
|
||||
} else {
|
||||
// API 返回失败,回滚状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? { ...p, is_favorited: true, favorites_count: p.favorites_count + 1 } : p
|
||||
)
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('取消收藏帖子失败:', error);
|
||||
// 失败回滚状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p =>
|
||||
p.id === postId ? { ...p, is_favorited: true, favorites_count: p.favorites_count + 1 } : p
|
||||
)
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
// 点赞评论
|
||||
likeComment: async (commentId: string) => {
|
||||
console.log('[userStore] likeComment called:', commentId);
|
||||
// 先更新本地状态(更新posts中的帖子的评论点赞状态)
|
||||
set(state => ({
|
||||
posts: state.posts.map(p => ({
|
||||
...p,
|
||||
top_comment: p.top_comment && p.top_comment.id === commentId
|
||||
? { ...p.top_comment, is_liked: true, likes_count: p.top_comment.likes_count + 1 }
|
||||
: p.top_comment
|
||||
}))
|
||||
}));
|
||||
|
||||
// 调用API
|
||||
try {
|
||||
await commentService.likeComment(commentId);
|
||||
console.log('[userStore] likeComment success:', commentId);
|
||||
} catch (error) {
|
||||
console.error('点赞评论失败:', error);
|
||||
// 失败回滚状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p => ({
|
||||
...p,
|
||||
top_comment: p.top_comment && p.top_comment.id === commentId
|
||||
? { ...p.top_comment, is_liked: false, likes_count: Math.max(0, p.top_comment.likes_count - 1) }
|
||||
: p.top_comment
|
||||
}))
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
// 取消点赞评论
|
||||
unlikeComment: async (commentId: string) => {
|
||||
console.log('[userStore] unlikeComment called:', commentId);
|
||||
// 先更新本地状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p => ({
|
||||
...p,
|
||||
top_comment: p.top_comment && p.top_comment.id === commentId
|
||||
? { ...p.top_comment, is_liked: false, likes_count: Math.max(0, p.top_comment.likes_count - 1) }
|
||||
: p.top_comment
|
||||
}))
|
||||
}));
|
||||
|
||||
// 调用API
|
||||
try {
|
||||
await commentService.unlikeComment(commentId);
|
||||
console.log('[userStore] unlikeComment success:', commentId);
|
||||
} catch (error) {
|
||||
console.error('取消点赞评论失败:', error);
|
||||
// 失败回滚状态
|
||||
set(state => ({
|
||||
posts: state.posts.map(p => ({
|
||||
...p,
|
||||
top_comment: p.top_comment && p.top_comment.id === commentId
|
||||
? { ...p.top_comment, is_liked: true, likes_count: p.top_comment.likes_count + 1 }
|
||||
: p.top_comment
|
||||
}))
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
// 关注用户
|
||||
followUser: async (userId: string) => {
|
||||
// 先更新本地状态
|
||||
set(state => ({
|
||||
users: state.users.map(u =>
|
||||
u.id === userId
|
||||
? { ...u, isFollowing: true, followersCount: u.followers_count + 1 }
|
||||
: u
|
||||
),
|
||||
userCache: Object.fromEntries(
|
||||
Object.entries(state.userCache).map(([id, user]) => [
|
||||
id,
|
||||
id === userId
|
||||
? { ...user, isFollowing: true, followersCount: user.followers_count + 1 }
|
||||
: user
|
||||
])
|
||||
)
|
||||
}));
|
||||
|
||||
// 调用API
|
||||
try {
|
||||
await authService.followUser(userId);
|
||||
} catch (error) {
|
||||
console.error('关注用户失败:', error);
|
||||
// 失败回滚状态
|
||||
set(state => ({
|
||||
users: state.users.map(u =>
|
||||
u.id === userId
|
||||
? { ...u, isFollowing: false, followersCount: Math.max(0, u.followers_count - 1) }
|
||||
: u
|
||||
),
|
||||
userCache: Object.fromEntries(
|
||||
Object.entries(state.userCache).map(([id, user]) => [
|
||||
id,
|
||||
id === userId
|
||||
? { ...user, isFollowing: false, followersCount: Math.max(0, user.followers_count - 1) }
|
||||
: user
|
||||
])
|
||||
)
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
// 取消关注
|
||||
unfollowUser: async (userId: string) => {
|
||||
// 先更新本地状态
|
||||
set(state => ({
|
||||
users: state.users.map(u =>
|
||||
u.id === userId
|
||||
? { ...u, isFollowing: false, followersCount: Math.max(0, u.followers_count - 1) }
|
||||
: u
|
||||
),
|
||||
userCache: Object.fromEntries(
|
||||
Object.entries(state.userCache).map(([id, user]) => [
|
||||
id,
|
||||
id === userId
|
||||
? { ...user, isFollowing: false, followersCount: Math.max(0, user.followers_count - 1) }
|
||||
: user
|
||||
])
|
||||
)
|
||||
}));
|
||||
|
||||
// 调用API
|
||||
try {
|
||||
await authService.unfollowUser(userId);
|
||||
} catch (error) {
|
||||
console.error('取消关注用户失败:', error);
|
||||
// 失败回滚状态
|
||||
set(state => ({
|
||||
users: state.users.map(u =>
|
||||
u.id === userId
|
||||
? { ...u, isFollowing: true, followersCount: u.followers_count + 1 }
|
||||
: u
|
||||
),
|
||||
userCache: Object.fromEntries(
|
||||
Object.entries(state.userCache).map(([id, user]) => [
|
||||
id,
|
||||
id === userId
|
||||
? { ...user, isFollowing: true, followersCount: user.followers_count + 1 }
|
||||
: user
|
||||
])
|
||||
)
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
// 刷新所有数据
|
||||
refreshAll: async () => {
|
||||
await Promise.all([
|
||||
get().fetchPosts('recommend', 1),
|
||||
get().fetchNotificationBadge(),
|
||||
]);
|
||||
},
|
||||
}));
|
||||
|
||||
// 导出selector hooks以优化性能
|
||||
export const usePosts = () => useUserStore((state) => state.posts);
|
||||
export const useNotifications = () => useUserStore((state) => state.notifications);
|
||||
export const useNotificationBadge = () => useUserStore((state) => state.notificationBadge);
|
||||
export const useMessageUnreadCount = () => useUserStore((state) => state.messageUnreadCount);
|
||||
export const useSearchHistory = () => useUserStore((state) => state.searchHistory);
|
||||
export const useIsLoadingPosts = () => useUserStore((state) => state.isLoadingPosts);
|
||||
Reference in New Issue
Block a user