refactor(im): 同步并收敛三端并发与本地数据隔离
This commit is contained in:
@@ -67,6 +67,8 @@ const dialogTitle = computed(() =>
|
|||||||
const presetMode = computed(() => !!presetUser.value);
|
const presetMode = computed(() => !!presetUser.value);
|
||||||
|
|
||||||
function resetAll() {
|
function resetAll() {
|
||||||
|
loading.value = false;
|
||||||
|
submitting.value = false;
|
||||||
keyword.value = '';
|
keyword.value = '';
|
||||||
users.value = [];
|
users.value = [];
|
||||||
searched.value = false;
|
searched.value = false;
|
||||||
@@ -102,14 +104,17 @@ function buildPresetApplyContent(): string {
|
|||||||
/** 按昵称搜索用户:空关键字直接清空结果 */
|
/** 按昵称搜索用户:空关键字直接清空结果 */
|
||||||
async function handleSearch() {
|
async function handleSearch() {
|
||||||
searched.value = true;
|
searched.value = true;
|
||||||
if (!keyword.value.trim()) {
|
const query = keyword.value.trim();
|
||||||
|
if (!query) {
|
||||||
users.value = [];
|
users.value = [];
|
||||||
|
loading.value = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
users.value =
|
users.value = (await getSimpleUserListByNickname(query)) || [];
|
||||||
(await getSimpleUserListByNickname(keyword.value.trim())) || [];
|
} catch (error) {
|
||||||
|
console.warn('[IM FriendAddDialog] 搜索用户失败', error);
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -132,25 +137,27 @@ function backToSearch() {
|
|||||||
|
|
||||||
/** 提交好友申请:返回 requestId 走「等待验证」;返回 null 表示后端命中「单向好友静默重启」分支,已直接成为好友 */
|
/** 提交好友申请:返回 requestId 走「等待验证」;返回 null 表示后端命中「单向好友静默重启」分支,已直接成为好友 */
|
||||||
async function handleSubmitApply() {
|
async function handleSubmitApply() {
|
||||||
if (!targetUser.value) {
|
const target = targetUser.value;
|
||||||
|
if (!target) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 预校验:不能加自己(搜索列表已过滤,这里兜底 presetUser / 名片入口等场景)
|
// 预校验:不能加自己(搜索列表已过滤,这里兜底 presetUser / 名片入口等场景)
|
||||||
if (targetUser.value.id === currentUserId.value) {
|
if (target.id === currentUserId.value) {
|
||||||
message.warning('不能添加自己为好友');
|
message.warning('不能添加自己为好友');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const payload = {
|
||||||
|
toUserId: target.id,
|
||||||
|
applyContent: applyContent.value.trim() || undefined,
|
||||||
|
displayName: displayName.value.trim() || undefined,
|
||||||
|
addSource: addSource.value,
|
||||||
|
};
|
||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
try {
|
try {
|
||||||
const requestId = await friendStore.applyFriendRequest({
|
const requestId = await friendStore.applyFriendRequest(payload);
|
||||||
toUserId: targetUser.value.id,
|
|
||||||
applyContent: applyContent.value.trim() || undefined,
|
|
||||||
displayName: displayName.value.trim() || undefined,
|
|
||||||
addSource: addSource.value,
|
|
||||||
});
|
|
||||||
// silent 分支(已是单向好友被静默重启):主动 fetchFriendInfo 入库,不依赖 WS FRIEND_ADD 推送,避免丢推时列表看不到
|
// silent 分支(已是单向好友被静默重启):主动 fetchFriendInfo 入库,不依赖 WS FRIEND_ADD 推送,避免丢推时列表看不到
|
||||||
if (requestId === null) {
|
if (requestId === null) {
|
||||||
await friendStore.fetchFriendInfo(targetUser.value.id);
|
await friendStore.fetchFriendInfo(target.id);
|
||||||
}
|
}
|
||||||
message.success(requestId ? '申请已发送,等待对方验证' : '已添加为好友');
|
message.success(requestId ? '申请已发送,等待对方验证' : '已添加为好友');
|
||||||
visible.value = false;
|
visible.value = false;
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ function handleChat(group: GroupLite) {
|
|||||||
|
|
||||||
/** 加入群聊:先关浮层(避免与 prompt 的 mask 互相遮挡)→ 弹申请理由(可选)→ applyJoinGroup */
|
/** 加入群聊:先关浮层(避免与 prompt 的 mask 互相遮挡)→ 弹申请理由(可选)→ applyJoinGroup */
|
||||||
async function handleApply(group: GroupLite) {
|
async function handleApply(group: GroupLite) {
|
||||||
|
const groupId = group.id;
|
||||||
handleClose();
|
handleClose();
|
||||||
let applyContent: string;
|
let applyContent: string;
|
||||||
try {
|
try {
|
||||||
@@ -86,12 +87,16 @@ async function handleApply(group: GroupLite) {
|
|||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await applyJoinGroup({
|
try {
|
||||||
groupId: group.id,
|
await applyJoinGroup({
|
||||||
applyContent: applyContent || undefined,
|
groupId,
|
||||||
addSource: ImGroupAddSource.SHARE_LINK,
|
applyContent: applyContent || undefined,
|
||||||
});
|
addSource: ImGroupAddSource.SHARE_LINK,
|
||||||
message.success('加群申请已发送');
|
});
|
||||||
|
message.success('加群申请已发送');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[IM GroupInfoCard] 申请加群失败', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ const memberCountText = computed(() => {
|
|||||||
return count ? `${count} 位成员` : '';
|
return count ? `${count} 位成员` : '';
|
||||||
});
|
});
|
||||||
|
|
||||||
/** member 切群 / 首挂:拉成员;竞态用 group.id 比对丢弃陈旧响应避免上一条群成员错位 */
|
/** member 切群 / 首挂:拉取群成员 */
|
||||||
watch(
|
watch(
|
||||||
() => [props.group?.id, isMember.value] as const,
|
() => [props.group?.id, isMember.value] as const,
|
||||||
async ([id, member]) => {
|
async ([id, member]) => {
|
||||||
@@ -86,13 +86,14 @@ watch(
|
|||||||
if (!id || !member) {
|
if (!id || !member) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const list = await groupStore.fetchGroupMemberList(id, true);
|
try {
|
||||||
if (props.group?.id !== id) {
|
const list = await groupStore.fetchGroupMemberList(id, true);
|
||||||
return;
|
members.value = list.map((m) =>
|
||||||
|
convertGroupMemberLite(m, friendStore.getFriend(m.userId)),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[IM GroupInfo] 群成员加载失败', { groupId: id }, error);
|
||||||
}
|
}
|
||||||
members.value = list.map((m) =>
|
|
||||||
convertGroupMemberLite(m, friendStore.getFriend(m.userId)),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -52,12 +52,15 @@ const newOwner = computed<GroupMemberLite | undefined>(() => {
|
|||||||
|
|
||||||
/** 二次确认转让:转让后旧群主降为普通成员,无法撤销 */
|
/** 二次确认转让:转让后旧群主降为普通成员,无法撤销 */
|
||||||
async function handleOk() {
|
async function handleOk() {
|
||||||
if (!groupId.value || !newOwner.value) {
|
const targetGroupId = groupId.value;
|
||||||
|
const newOwnerUserId = newOwner.value?.userId;
|
||||||
|
const newOwnerName = newOwner.value?.showName;
|
||||||
|
if (!targetGroupId || !newOwnerUserId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await confirm(
|
await confirm(
|
||||||
`确定将群主转让给 ${newOwner.value.showName}?转让后你将变为普通成员,无法撤销。`,
|
`确定将群主转让给 ${newOwnerName}?转让后你将变为普通成员,无法撤销。`,
|
||||||
'确认转让群主',
|
'确认转让群主',
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -66,12 +69,14 @@ async function handleOk() {
|
|||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
try {
|
try {
|
||||||
await transferGroupOwner({
|
await transferGroupOwner({
|
||||||
id: groupId.value,
|
id: targetGroupId,
|
||||||
newOwnerUserId: newOwner.value.userId,
|
newOwnerUserId,
|
||||||
});
|
});
|
||||||
message.success('群主转让成功');
|
message.success('群主转让成功');
|
||||||
emit('reload');
|
emit('reload');
|
||||||
visible.value = false;
|
visible.value = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[IM GroupOwnerTransferDialog] 转让群主失败', error);
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false;
|
submitting.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { IconifyIcon as Icon } from '@vben/icons';
|
|||||||
|
|
||||||
import { message, Popover } from 'ant-design-vue';
|
import { message, Popover } from 'ant-design-vue';
|
||||||
|
|
||||||
import { getActiveCall, joinCall } from '#/api/im/rtc';
|
import { getActiveCall, joinCall, leaveCall } from '#/api/im/rtc';
|
||||||
import { getCurrentUserId } from '#/views/im/utils/auth';
|
import { getCurrentUserId } from '#/views/im/utils/auth';
|
||||||
|
|
||||||
import { useGroupCallMembers } from '../../composables/useGroupCallMembers';
|
import { useGroupCallMembers } from '../../composables/useGroupCallMembers';
|
||||||
@@ -25,6 +25,7 @@ const rtcStore = useRtcStore();
|
|||||||
const groupStore = useGroupStore();
|
const groupStore = useGroupStore();
|
||||||
|
|
||||||
const popoverVisible = ref(false);
|
const popoverVisible = ref(false);
|
||||||
|
const joining = ref(false);
|
||||||
|
|
||||||
/** 当前群的活跃通话;rtcStore 维护,参与者加入 / 离开通知增删 joinedUserIds,通话结束移除 */
|
/** 当前群的活跃通话;rtcStore 维护,参与者加入 / 离开通知增删 joinedUserIds,通话结束移除 */
|
||||||
const activeCall = computed(() => rtcStore.getGroupCall(props.groupId));
|
const activeCall = computed(() => rtcStore.getGroupCall(props.groupId));
|
||||||
@@ -51,10 +52,14 @@ watch(
|
|||||||
activeCall.value?.room,
|
activeCall.value?.room,
|
||||||
groupStore.isGroupActiveCallExpired(props.groupId),
|
groupStore.isGroupActiveCallExpired(props.groupId),
|
||||||
] as const,
|
] as const,
|
||||||
async ([groupId, room], oldValues) => {
|
async ([groupId, room], oldValues, onCleanup) => {
|
||||||
if (!groupId) {
|
if (!groupId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let obsolete = false;
|
||||||
|
onCleanup(() => {
|
||||||
|
obsolete = true;
|
||||||
|
});
|
||||||
|
|
||||||
if (!activeCall.value) {
|
if (!activeCall.value) {
|
||||||
if (!groupStore.isGroupActiveCallExpired(groupId)) {
|
if (!groupStore.isGroupActiveCallExpired(groupId)) {
|
||||||
@@ -62,6 +67,9 @@ watch(
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const data = await getActiveCall(groupId);
|
const data = await getActiveCall(groupId);
|
||||||
|
if (obsolete) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (data) {
|
if (data) {
|
||||||
rtcStore.setGroupCall(data, true);
|
rtcStore.setGroupCall(data, true);
|
||||||
} else {
|
} else {
|
||||||
@@ -94,6 +102,9 @@ watch(
|
|||||||
// 拉最新参与者写回 store;接口返回空 → 该群已无活跃通话,移除本地缓存
|
// 拉最新参与者写回 store;接口返回空 → 该群已无活跃通话,移除本地缓存
|
||||||
try {
|
try {
|
||||||
const data = await getActiveCall(groupId);
|
const data = await getActiveCall(groupId);
|
||||||
|
if (obsolete) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (data) {
|
if (data) {
|
||||||
rtcStore.setGroupCall(data, true);
|
rtcStore.setGroupCall(data, true);
|
||||||
} else {
|
} else {
|
||||||
@@ -124,10 +135,11 @@ const serverSaysJoined = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** 加入按钮禁用:仅在本端实际持有 LiveKit 连接时禁用 */
|
/** 加入按钮禁用:仅在本端实际持有 LiveKit 连接时禁用 */
|
||||||
const joinDisabled = computed(() => isInThisCall.value);
|
const joinDisabled = computed(() => isInThisCall.value || joining.value);
|
||||||
|
|
||||||
/** 加入按钮文案;本端连着 → 已在通话中;服务端还残留我但本端断了 → 重新加入;其它 → 加入 */
|
/** 加入按钮文案;本端连着 → 已在通话中;服务端还残留我但本端断了 → 重新加入;其它 → 加入 */
|
||||||
const joinLabel = computed(() => {
|
const joinLabel = computed(() => {
|
||||||
|
if (joining.value) return '加入中...';
|
||||||
if (isInThisCall.value) return '已在通话中';
|
if (isInThisCall.value) return '已在通话中';
|
||||||
if (serverSaysJoined.value) return '重新加入';
|
if (serverSaysJoined.value) return '重新加入';
|
||||||
return '加入';
|
return '加入';
|
||||||
@@ -136,16 +148,35 @@ const joinLabel = computed(() => {
|
|||||||
/** 主动加入:调 invite 命中已有 call 拿 token;rtcStore 按 status 自动进 RUNNING */
|
/** 主动加入:调 invite 命中已有 call 拿 token;rtcStore 按 status 自动进 RUNNING */
|
||||||
async function handleJoin() {
|
async function handleJoin() {
|
||||||
const call = activeCall.value;
|
const call = activeCall.value;
|
||||||
if (!call || joinDisabled.value) {
|
if (!call || joinDisabled.value || joining.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (rtcStore.isActive) {
|
if (rtcStore.isActive) {
|
||||||
message.warning('您正在通话中');
|
message.warning('您正在通话中');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 加入结果会获取物理房间凭证,必须阻断旧用户或旧群结果发布
|
||||||
popoverVisible.value = false;
|
popoverVisible.value = false;
|
||||||
const data = await joinCall(call.room);
|
joining.value = true;
|
||||||
rtcStore.startInviting(data);
|
const userId = getCurrentUserId();
|
||||||
|
try {
|
||||||
|
const data = await joinCall(call.room);
|
||||||
|
if (
|
||||||
|
getCurrentUserId() !== userId ||
|
||||||
|
activeCall.value?.room !== call.room ||
|
||||||
|
(rtcStore.isActive && rtcStore.call?.room !== data.room)
|
||||||
|
) {
|
||||||
|
if (getCurrentUserId() === userId) {
|
||||||
|
await leaveCall(data.room || call.room).catch(() => undefined);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rtcStore.startInviting(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[IM RtcGroupCallBanner] 加入群通话失败', error);
|
||||||
|
} finally {
|
||||||
|
joining.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -105,7 +105,6 @@ const remarkInputRef = ref<null | { focus: () => void; select?: () => void }>(
|
|||||||
* user.id 变化的统一处理:
|
* user.id 变化的统一处理:
|
||||||
* 1. 起手用 prop 兜底首屏(full = props.user),再 getSimpleUser 命中后合并替换
|
* 1. 起手用 prop 兜底首屏(full = props.user),再 getSimpleUser 命中后合并替换
|
||||||
* 2. 顺便复位备注编辑态,避免上一个用户的脏输入泄漏到下一个
|
* 2. 顺便复位备注编辑态,避免上一个用户的脏输入泄漏到下一个
|
||||||
* 3. 竞态用 id 比对丢弃陈旧响应
|
|
||||||
*/
|
*/
|
||||||
watch(
|
watch(
|
||||||
() => props.user?.id,
|
() => props.user?.id,
|
||||||
@@ -116,9 +115,6 @@ watch(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = (await getSimpleUser(id)) as User;
|
const data = (await getSimpleUser(id)) as User;
|
||||||
if (props.user?.id !== id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
full.value = { ...props.user, ...data };
|
full.value = { ...props.user, ...data };
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
@@ -235,10 +231,11 @@ async function handleBlock() {
|
|||||||
|
|
||||||
/** 移出黑名单:操作温和不弹 confirm;后端 FRIEND_UNBLOCK 推到时由 dispatcher 同步多端 */
|
/** 移出黑名单:操作温和不弹 confirm;后端 FRIEND_UNBLOCK 推到时由 dispatcher 同步多端 */
|
||||||
async function handleUnblock() {
|
async function handleUnblock() {
|
||||||
if (!props.user?.id) {
|
const targetId = props.user?.id;
|
||||||
|
if (!targetId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await friendStore.unblockFriend(props.user.id);
|
await friendStore.unblockFriend(targetId);
|
||||||
message.success('已移出黑名单');
|
message.success('已移出黑名单');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,7 +269,12 @@ async function handleDeleteFriend() {
|
|||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await friendStore.deleteFriend(target.id, clearConversation.value);
|
try {
|
||||||
|
await friendStore.deleteFriend(target.id, clearConversation.value);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[IM UserInfo] 删除好友失败', error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
message.success('已删除好友');
|
message.success('已删除好友');
|
||||||
emit('deleted', target);
|
emit('deleted', target);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ const processing = ref(false);
|
|||||||
|
|
||||||
/** 同意申请:互斥锁 + 状态二次校验,避免并发 / 服务端已处理后再次提交 */
|
/** 同意申请:互斥锁 + 状态二次校验,避免并发 / 服务端已处理后再次提交 */
|
||||||
async function handleAgree() {
|
async function handleAgree() {
|
||||||
|
const requestId = props.request.id;
|
||||||
if (processing.value) {
|
if (processing.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -81,8 +82,10 @@ async function handleAgree() {
|
|||||||
processing.value = true;
|
processing.value = true;
|
||||||
agreeing.value = true;
|
agreeing.value = true;
|
||||||
try {
|
try {
|
||||||
await friendStore.agreeFriendRequest(props.request.id);
|
await friendStore.agreeFriendRequest(requestId);
|
||||||
message.success('已同意好友申请');
|
message.success('已同意好友申请');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[IM FriendRequestDetail] 同意好友申请失败', error);
|
||||||
} finally {
|
} finally {
|
||||||
agreeing.value = false;
|
agreeing.value = false;
|
||||||
processing.value = false;
|
processing.value = false;
|
||||||
@@ -91,6 +94,7 @@ async function handleAgree() {
|
|||||||
|
|
||||||
/** 拒绝申请:弹 prompt 收集可选拒绝理由(点取消则中止),随后调 store 落库 + 提示 */
|
/** 拒绝申请:弹 prompt 收集可选拒绝理由(点取消则中止),随后调 store 落库 + 提示 */
|
||||||
async function handleRefuse() {
|
async function handleRefuse() {
|
||||||
|
const requestId = props.request.id;
|
||||||
if (processing.value) {
|
if (processing.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -138,8 +142,10 @@ async function handleRefuse() {
|
|||||||
processing.value = true;
|
processing.value = true;
|
||||||
refusing.value = true;
|
refusing.value = true;
|
||||||
try {
|
try {
|
||||||
await friendStore.refuseFriendRequest(props.request.id, handleContent);
|
await friendStore.refuseFriendRequest(requestId, handleContent);
|
||||||
message.success('已拒绝好友申请');
|
message.success('已拒绝好友申请');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[IM FriendRequestDetail] 拒绝好友申请失败', error);
|
||||||
} finally {
|
} finally {
|
||||||
refusing.value = false;
|
refusing.value = false;
|
||||||
processing.value = false;
|
processing.value = false;
|
||||||
|
|||||||
@@ -220,18 +220,26 @@ function handleChatGroup(group: GroupLite) {
|
|||||||
|
|
||||||
/** 删除好友:二次确认 → store 落库 → 清空当前选中 */
|
/** 删除好友:二次确认 → store 落库 → 清空当前选中 */
|
||||||
async function handleDeleteFriend(friend: FriendLite) {
|
async function handleDeleteFriend(friend: FriendLite) {
|
||||||
|
const friendId = friend.id;
|
||||||
try {
|
try {
|
||||||
await confirm(`确定删除好友「${friend.nickname}」吗?`, '删除联系人');
|
await confirm(`确定删除好友「${friend.nickname}」吗?`, '删除联系人');
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
// friendStore.deleteFriend 内部已经级联清理对应私聊会话
|
// friendStore.deleteFriend 内部已经级联清理对应私聊会话
|
||||||
await friendStore.deleteFriend(friend.id);
|
await friendStore.deleteFriend(friendId);
|
||||||
if (
|
} catch (error) {
|
||||||
selection.value?.type === 'friend' &&
|
console.warn('[IM contact] 删除好友失败', error);
|
||||||
selection.value.friend.id === friend.id
|
return;
|
||||||
) {
|
}
|
||||||
selection.value = null;
|
if (
|
||||||
}
|
selection.value?.type === 'friend' &&
|
||||||
message.success('已删除好友');
|
selection.value.friend.id === friendId
|
||||||
} catch {}
|
) {
|
||||||
|
selection.value = null;
|
||||||
|
}
|
||||||
|
message.success('已删除好友');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 备注已保存:UserInfo 内部已经走完 friendStore 落库 + 提示,本侧只负责同步 selection 持的旧 FriendLite 副本 */
|
/** 备注已保存:UserInfo 内部已经走完 friendStore 落库 + 提示,本侧只负责同步 selection 持的旧 FriendLite 副本 */
|
||||||
|
|||||||
@@ -171,24 +171,28 @@ function handleMuted() {
|
|||||||
type === ImConversationType.PRIVATE
|
type === ImConversationType.PRIVATE
|
||||||
? friendStore.setFriendSilent(targetId, next)
|
? friendStore.setFriendSilent(targetId, next)
|
||||||
: groupStore.setGroupSilent(targetId, next);
|
: groupStore.setGroupSilent(targetId, next);
|
||||||
sync.catch((error) => {
|
void sync.catch((error) => {
|
||||||
console.error('[IM] 切换免打扰失败', error);
|
console.error('[IM] 切换免打扰失败', error);
|
||||||
conversationStore.setConversationSilent(type, targetId, !next);
|
const conversation = conversationStore.getConversation(type, targetId);
|
||||||
|
if (conversation?.silent === next) {
|
||||||
|
conversationStore.setConversationSilent(type, targetId, !next);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删除会话:二次确认后软删 */
|
/** 删除会话:二次确认后软删 */
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
|
const { type, targetId, name } = props.conversation;
|
||||||
try {
|
try {
|
||||||
await confirm(
|
await confirm(`确定删除与「${name}」的会话吗?`, '删除会话');
|
||||||
`确定删除与「${props.conversation.name}」的会话吗?`,
|
} catch {
|
||||||
'删除会话',
|
return;
|
||||||
);
|
}
|
||||||
conversationStore.removeConversation(
|
try {
|
||||||
props.conversation.type,
|
await conversationStore.removeConversation(type, targetId);
|
||||||
props.conversation.targetId,
|
} catch (error) {
|
||||||
);
|
console.warn('[IM ConversationItem] 删除会话失败', error);
|
||||||
} catch {}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 右键菜单:置顶 / 免打扰 / 删除 */
|
/** 右键菜单:置顶 / 免打扰 / 删除 */
|
||||||
|
|||||||
@@ -124,6 +124,9 @@ async function onUploadPicked(e: Event) {
|
|||||||
}
|
}
|
||||||
const payload = { url, width: size.width, height: size.height };
|
const payload = { url, width: size.width, height: size.height };
|
||||||
await faceStore.addFaceUserItem(payload);
|
await faceStore.addFaceUserItem(payload);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[IM FacePicker] 上传个人表情失败', error);
|
||||||
|
message.error('上传失败');
|
||||||
} finally {
|
} finally {
|
||||||
uploading.value = false;
|
uploading.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { computed, inject } from 'vue';
|
|||||||
import { confirm } from '@vben/common-ui';
|
import { confirm } from '@vben/common-ui';
|
||||||
import { IconifyIcon as Icon } from '@vben/icons';
|
import { IconifyIcon as Icon } from '@vben/icons';
|
||||||
|
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
import { useMessageMultiSelect } from '#/views/im/home/composables/useMessageMultiSelect';
|
import { useMessageMultiSelect } from '#/views/im/home/composables/useMessageMultiSelect';
|
||||||
import { useConversationStore } from '#/views/im/home/store/conversationStore';
|
import { useConversationStore } from '#/views/im/home/store/conversationStore';
|
||||||
import { useMessageStore } from '#/views/im/home/store/messageStore';
|
import { useMessageStore } from '#/views/im/home/store/messageStore';
|
||||||
@@ -83,6 +85,7 @@ async function handleDelete() {
|
|||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const { type, targetId } = conversation;
|
||||||
const messages = getSelectedMessages();
|
const messages = getSelectedMessages();
|
||||||
if (messages.length === 0) {
|
if (messages.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -97,13 +100,21 @@ async function handleDelete() {
|
|||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const m of messages) {
|
try {
|
||||||
messageStore.removeMessage(conversation.type, conversation.targetId, {
|
await Promise.all(
|
||||||
id: m.id,
|
messages.map((item) =>
|
||||||
clientMessageId: m.clientMessageId,
|
messageStore.removeMessage(type, targetId, {
|
||||||
});
|
id: item.id,
|
||||||
|
clientMessageId: item.clientMessageId,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[IM MessageMultiSelectBar] 批量删除消息失败', error);
|
||||||
|
message.error('删除失败,请重试');
|
||||||
|
} finally {
|
||||||
|
multiSelect.exit();
|
||||||
}
|
}
|
||||||
multiSelect.exit();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 取消多选 */
|
/** 取消多选 */
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { IconifyIcon as Icon } from '@vben/icons';
|
|||||||
|
|
||||||
import { message, Popover, Tooltip } from 'ant-design-vue';
|
import { message, Popover, Tooltip } from 'ant-design-vue';
|
||||||
|
|
||||||
import { createCall } from '#/api/im/rtc';
|
import { createCall, leaveCall } from '#/api/im/rtc';
|
||||||
import { getCurrentUserId } from '#/views/im/utils/auth';
|
import { getCurrentUserId } from '#/views/im/utils/auth';
|
||||||
import {
|
import {
|
||||||
ImConversationType,
|
ImConversationType,
|
||||||
@@ -275,8 +275,12 @@ function reloadGroupData() {
|
|||||||
if (!conversation || conversation.type !== ImConversationType.GROUP) {
|
if (!conversation || conversation.type !== ImConversationType.GROUP) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
groupStore.fetchGroupInfo(conversation.targetId, true);
|
void groupStore.fetchGroupInfo(conversation.targetId, true);
|
||||||
groupStore.fetchGroupMemberList(conversation.targetId, true);
|
void groupStore
|
||||||
|
.fetchGroupMemberList(conversation.targetId, true)
|
||||||
|
.catch((error) => {
|
||||||
|
console.warn('[IM MessagePanel] 强制刷新群成员失败', error);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const historyDialogRef = ref<InstanceType<typeof MessageHistory>>(); // 历史消息抽屉 ref:「聊天历史」icon / 抽屉「查找聊天内容」入口都调 open() 触发
|
const historyDialogRef = ref<InstanceType<typeof MessageHistory>>(); // 历史消息抽屉 ref:「聊天历史」icon / 抽屉「查找聊天内容」入口都调 open() 触发
|
||||||
@@ -359,8 +363,18 @@ async function doInvite(reqVO: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
callInviting.value = true;
|
callInviting.value = true;
|
||||||
|
const userId = getCurrentUserId();
|
||||||
try {
|
try {
|
||||||
const data = await createCall(reqVO);
|
const data = await createCall(reqVO);
|
||||||
|
if (
|
||||||
|
getCurrentUserId() !== userId ||
|
||||||
|
(rtcStore.isActive && rtcStore.call?.room !== data.room)
|
||||||
|
) {
|
||||||
|
if (getCurrentUserId() === userId) {
|
||||||
|
await leaveCall(data.room).catch(() => undefined);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
// 后端已 INSERT + 立即 end(如忙线):toast 提示,不进 INVITING 阶段;chat tip 由 RTC_CALL_END 推送写入消息流
|
// 后端已 INSERT + 立即 end(如忙线):toast 提示,不进 INVITING 阶段;chat tip 由 RTC_CALL_END 推送写入消息流
|
||||||
if (data.status === ImRtcCallStatus.ENDED) {
|
if (data.status === ImRtcCallStatus.ENDED) {
|
||||||
message.warning(resolveCallEndReasonText(data.endReason));
|
message.warning(resolveCallEndReasonText(data.endReason));
|
||||||
@@ -368,6 +382,8 @@ async function doInvite(reqVO: {
|
|||||||
}
|
}
|
||||||
// 正常进入 INVITING 阶段:走 store 逻辑发起通话,后续状态更新 / 消息流更新由 RTC 模块监听推送处理
|
// 正常进入 INVITING 阶段:走 store 逻辑发起通话,后续状态更新 / 消息流更新由 RTC 模块监听推送处理
|
||||||
rtcStore.startInviting(data);
|
rtcStore.startInviting(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[IM MessagePanel] 发起通话失败', error);
|
||||||
} finally {
|
} finally {
|
||||||
callInviting.value = false;
|
callInviting.value = false;
|
||||||
}
|
}
|
||||||
@@ -497,16 +513,6 @@ async function handleLocateMention() {
|
|||||||
conversation.type,
|
conversation.type,
|
||||||
conversation.targetId,
|
conversation.targetId,
|
||||||
);
|
);
|
||||||
const isActive = () => {
|
|
||||||
const activeConversation = conversationStore.activeConversation;
|
|
||||||
return (
|
|
||||||
!!activeConversation &&
|
|
||||||
getClientConversationId(
|
|
||||||
activeConversation.type,
|
|
||||||
activeConversation.targetId,
|
|
||||||
) === clientConversationId
|
|
||||||
);
|
|
||||||
};
|
|
||||||
for (let guard = 0; guard < 50; guard++) {
|
for (let guard = 0; guard < 50; guard++) {
|
||||||
const loadedMessages = messageStore.getMessages(clientConversationId);
|
const loadedMessages = messageStore.getMessages(clientConversationId);
|
||||||
if (loadedMessages.some((item) => item.id === messageId)) {
|
if (loadedMessages.some((item) => item.id === messageId)) {
|
||||||
@@ -516,9 +522,6 @@ async function handleLocateMention() {
|
|||||||
clientConversationId,
|
clientConversationId,
|
||||||
50,
|
50,
|
||||||
);
|
);
|
||||||
if (!isActive()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
messageStore
|
messageStore
|
||||||
.getMessages(clientConversationId)
|
.getMessages(clientConversationId)
|
||||||
@@ -530,10 +533,7 @@ async function handleLocateMention() {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!isActive()) {
|
await handleLocate(messageId);
|
||||||
return;
|
|
||||||
}
|
|
||||||
await handleLocate(messageId, isActive);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -545,14 +545,11 @@ async function handleLocateMention() {
|
|||||||
* 4. 加 --highlight class 短暂高亮,提示用户"就是这条"
|
* 4. 加 --highlight class 短暂高亮,提示用户"就是这条"
|
||||||
* 5. 找不到 wrapper(原消息已分页出去)时弹 warning 提示,与微信"消息已不在窗口"观感一致
|
* 5. 找不到 wrapper(原消息已分页出去)时弹 warning 提示,与微信"消息已不在窗口"观感一致
|
||||||
*/
|
*/
|
||||||
async function handleLocate(messageId: number, isActive?: () => boolean) {
|
async function handleLocate(messageId: number) {
|
||||||
if (!messageId) {
|
if (!messageId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await nextTick();
|
await nextTick();
|
||||||
if (isActive && !isActive()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!listRef.value) {
|
if (!listRef.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ async function loadReadUsers() {
|
|||||||
// 全可见成员都已读 → 更新为 DONE,让外面 label 直接命中「全部已读」分支;
|
// 全可见成员都已读 → 更新为 DONE,让外面 label 直接命中「全部已读」分支;
|
||||||
// 否则只更新 readCount,receiptStatus 维持不变(PENDING)
|
// 否则只更新 readCount,receiptStatus 维持不变(PENDING)
|
||||||
const allRead = readCount > 0 && readCount >= visibleMembers.value.length;
|
const allRead = readCount > 0 && readCount >= visibleMembers.value.length;
|
||||||
messageStore.applyMessageReadReceipt({
|
await messageStore.applyMessageReadReceipt({
|
||||||
conversationType: ImConversationType.GROUP,
|
conversationType: ImConversationType.GROUP,
|
||||||
targetId: props.groupId,
|
targetId: props.groupId,
|
||||||
groupMessageId: props.message.id,
|
groupMessageId: props.message.id,
|
||||||
|
|||||||
159
apps/web-antd/src/views/im/utils/messageSync.ts
Normal file
159
apps/web-antd/src/views/im/utils/messageSync.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
/** 消息状态优先级;高优先级终态不可被普通消息覆盖 */
|
||||||
|
export enum MessageTerminalPriority {
|
||||||
|
NORMAL = 0, // 普通消息
|
||||||
|
CONFIRMED = 1, // 服务端已确认消息
|
||||||
|
RECALL = 2 // 撤回终态
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 会话写 lane 与全量屏障 */
|
||||||
|
interface ConversationWriteState {
|
||||||
|
barrierTail: Promise<void> // 当前全量屏障尾部
|
||||||
|
tails: Map<string, Promise<void>> // 各会话写入尾部
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RelationState {
|
||||||
|
terminated: boolean
|
||||||
|
messageId: number
|
||||||
|
localTerminationPending: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const writeState: ConversationWriteState = {
|
||||||
|
// 当前运行时的会话写状态
|
||||||
|
barrierTail: Promise.resolve(),
|
||||||
|
tails: new Map()
|
||||||
|
}
|
||||||
|
const relationStates = new Map<string, RelationState>() // 群关系消息终态
|
||||||
|
|
||||||
|
/** 同一会话串行执行消息与会话终态写入 */
|
||||||
|
export async function enqueueConversationWrite<T>(
|
||||||
|
clientConversationId: string,
|
||||||
|
operation: () => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
return enqueueConversationWrites([clientConversationId], operation)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一次写入原子占用全部会话 lane,避免嵌套获取与屏障互锁 */
|
||||||
|
export function enqueueConversationWrites<T>(
|
||||||
|
clientConversationIds: string[],
|
||||||
|
operation: () => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
// 1. 等待全量屏障和所有目标会话的前驱写入
|
||||||
|
const keys = Array.from(new Set(clientConversationIds)).sort()
|
||||||
|
const predecessors = [
|
||||||
|
writeState.barrierTail,
|
||||||
|
...keys.map((key) => writeState.tails.get(key) || Promise.resolve())
|
||||||
|
]
|
||||||
|
const current = Promise.all(predecessors.map((task) => task.catch(() => undefined))).then(
|
||||||
|
operation
|
||||||
|
)
|
||||||
|
const settled = current.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined
|
||||||
|
)
|
||||||
|
// 2. 先发布 recovery tail;完成时仅清理仍指向本任务的 lane
|
||||||
|
keys.forEach((key) => writeState.tails.set(key, settled))
|
||||||
|
return current.finally(() => {
|
||||||
|
keys.forEach((key) => {
|
||||||
|
if (writeState.tails.get(key) === settled) {
|
||||||
|
writeState.tails.delete(key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 独占全部会话写入;只用于全量快照重建,常规写仍按会话并行 */
|
||||||
|
export function enqueueConversationBarrier<T>(operation: () => Promise<T>): Promise<T> {
|
||||||
|
// 1. 同步发布 gate,阻止后续会话写越过本次全量操作
|
||||||
|
const previousBarrier = writeState.barrierTail
|
||||||
|
const existingWrites = Array.from(writeState.tails.values())
|
||||||
|
let release!: () => void
|
||||||
|
const gate = new Promise<void>((resolve) => {
|
||||||
|
release = resolve
|
||||||
|
})
|
||||||
|
writeState.barrierTail = previousBarrier.catch(() => undefined).then(() => gate)
|
||||||
|
return (async () => {
|
||||||
|
// 2. 排空封门前的屏障和会话写,再独占执行全量操作
|
||||||
|
await previousBarrier.catch(() => undefined)
|
||||||
|
await Promise.all(existingWrites.map((task) => task.catch(() => undefined)))
|
||||||
|
return await operation()
|
||||||
|
})().finally(release)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 终态优先;相同优先级使用后到达状态 */
|
||||||
|
export function reduceMessageState<T>(
|
||||||
|
current: { priority: MessageTerminalPriority; value?: T } | undefined,
|
||||||
|
incoming: { priority: MessageTerminalPriority; value?: T }
|
||||||
|
) {
|
||||||
|
return current && current.priority > incoming.priority ? current : incoming
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在会话写 lane 内记录关系终态;本地主动操作等待服务端终态消息后才允许重开 */
|
||||||
|
export function markRelationTerminated(clientConversationId: string, messageId?: number): boolean {
|
||||||
|
return applyRelationState(clientConversationId, true, messageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 显式重新加入后清除关系终态;旧通知不得重开新终态 */
|
||||||
|
export function reopenRelation(clientConversationId: string, messageId?: number): boolean {
|
||||||
|
return applyRelationState(clientConversationId, false, messageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRelationTerminated(clientConversationId: string): boolean {
|
||||||
|
return relationStates.get(clientConversationId)?.terminated === true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 排空当前 IM 运行时的消息写入,并在调用方仍允许时清理关系终态 */
|
||||||
|
export async function clearMessageSyncState(shouldClear: () => boolean): Promise<void> {
|
||||||
|
const barrier = writeState.barrierTail
|
||||||
|
const tails = Array.from(writeState.tails.entries())
|
||||||
|
await Promise.all([
|
||||||
|
barrier.catch(() => undefined),
|
||||||
|
...tails.map(([, task]) => task.catch(() => undefined))
|
||||||
|
])
|
||||||
|
if (!shouldClear()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
relationStates.clear()
|
||||||
|
if (writeState.barrierTail === barrier) {
|
||||||
|
writeState.barrierTail = Promise.resolve()
|
||||||
|
}
|
||||||
|
tails.forEach(([key, task]) => {
|
||||||
|
if (writeState.tails.get(key) === task) {
|
||||||
|
writeState.tails.delete(key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按服务端关系消息编号单调归约群关系;本地主动终止在服务端终态确认前阻止旧成员消息重开 */
|
||||||
|
function applyRelationState(
|
||||||
|
clientConversationId: string,
|
||||||
|
terminated: boolean,
|
||||||
|
messageId?: number
|
||||||
|
): boolean {
|
||||||
|
const current = relationStates.get(clientConversationId)
|
||||||
|
if (messageId === undefined) {
|
||||||
|
if (!terminated && current?.localTerminationPending) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (terminated && current?.terminated) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
relationStates.set(clientConversationId, {
|
||||||
|
terminated,
|
||||||
|
messageId: current?.messageId ?? 0,
|
||||||
|
localTerminationPending: terminated
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (!terminated && current?.localTerminationPending) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (current && messageId <= current.messageId) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
relationStates.set(clientConversationId, {
|
||||||
|
terminated,
|
||||||
|
messageId,
|
||||||
|
localTerminationPending: false
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
108
apps/web-antd/src/views/im/utils/resourceRequest.ts
Normal file
108
apps/web-antd/src/views/im/utils/resourceRequest.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/** 可合并请求的固定资源 */
|
||||||
|
export enum ResourceRequestKey {
|
||||||
|
FACE_PACKS = 'facePacks', // 系统表情包
|
||||||
|
FACE_USER_ITEMS = 'faceUserItems', // 用户表情
|
||||||
|
FRIEND_LIST = 'friendList', // 好友列表
|
||||||
|
GROUP_LIST = 'groupList', // 群列表
|
||||||
|
CHANNEL_LIST = 'channelList', // 频道列表
|
||||||
|
GROUP_REQUEST_UNHANDLED = 'groupRequestUnhandled' // 未处理加群申请
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 固定资源请求的 task 生命周期模式 */
|
||||||
|
export enum ResourceRequestMode {
|
||||||
|
CACHE_SUCCESS = 'cache-success', // 成功后持续复用,清理运行时状态时失效
|
||||||
|
SINGLE_FLIGHT = 'single-flight' // 仅合并当前在途请求
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 固定资源请求策略 */
|
||||||
|
type ResourceRequestPolicy =
|
||||||
|
| {
|
||||||
|
mode: ResourceRequestMode.CACHE_SUCCESS
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
mode: ResourceRequestMode.SINGLE_FLIGHT
|
||||||
|
refreshAfterPending?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单个固定资源当前发布的请求状态 */
|
||||||
|
interface ResourceRequestEntry {
|
||||||
|
mode: ResourceRequestMode // task 生命周期模式
|
||||||
|
task: Promise<unknown> // 当前请求 task
|
||||||
|
trailingExecute?: () => Promise<unknown> // 合并后的尾随刷新
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceRequests = new Map<ResourceRequestKey, ResourceRequestEntry>() // 每个 key 仅发布一个当前 entry
|
||||||
|
|
||||||
|
/** 运行固定资源请求 */
|
||||||
|
export function runResourceRequest<T>(
|
||||||
|
key: ResourceRequestKey,
|
||||||
|
execute: () => Promise<T>,
|
||||||
|
policy: ResourceRequestPolicy
|
||||||
|
): Promise<T> {
|
||||||
|
const existing = resourceRequests.get(key)
|
||||||
|
// 1. 复用 task;force 只覆盖为一个最新尾随执行器
|
||||||
|
if (existing) {
|
||||||
|
if (existing.mode !== policy.mode) {
|
||||||
|
return Promise.reject(new Error(`IM resource policy mismatch: ${key}`))
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
existing.mode === ResourceRequestMode.SINGLE_FLIGHT &&
|
||||||
|
policy.mode === ResourceRequestMode.SINGLE_FLIGHT &&
|
||||||
|
policy.refreshAfterPending
|
||||||
|
) {
|
||||||
|
existing.trailingExecute = execute
|
||||||
|
}
|
||||||
|
return existing.task as Promise<T>
|
||||||
|
}
|
||||||
|
const task = Promise.resolve().then(execute)
|
||||||
|
const entry: ResourceRequestEntry = { mode: policy.mode, task }
|
||||||
|
resourceRequests.set(key, entry)
|
||||||
|
void task.then(
|
||||||
|
() => finishResourceRequest(key, entry, true),
|
||||||
|
() => finishResourceRequest(key, entry, false)
|
||||||
|
)
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 完成请求并按策略释放或补刷 */
|
||||||
|
function finishResourceRequest(
|
||||||
|
key: ResourceRequestKey,
|
||||||
|
entry: ResourceRequestEntry,
|
||||||
|
succeeded: boolean
|
||||||
|
): void {
|
||||||
|
// 1. 旧 finalizer 不能修改已经替换的新 entry
|
||||||
|
if (resourceRequests.get(key) !== entry) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 2. once 成功保留;其余情况先释放当前 entry
|
||||||
|
if (entry.mode === ResourceRequestMode.CACHE_SUCCESS && succeeded) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resourceRequests.delete(key)
|
||||||
|
// 3. single-flight 的多次 force 合并为一次后台尾随刷新
|
||||||
|
if (entry.trailingExecute) {
|
||||||
|
void runResourceRequest(key, entry.trailingExecute, {
|
||||||
|
mode: ResourceRequestMode.SINGLE_FLIGHT
|
||||||
|
}).catch((error) => console.warn(`[IM] 尾随刷新 ${key} 失败`, error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 排空并清理固定资源请求状态 */
|
||||||
|
export async function clearResourceRequests(): Promise<void> {
|
||||||
|
const entries = Array.from(resourceRequests.entries())
|
||||||
|
entries.forEach(([, entry]) => {
|
||||||
|
entry.trailingExecute = undefined
|
||||||
|
})
|
||||||
|
await Promise.all(entries.map(([, entry]) => entry.task.catch(() => undefined)))
|
||||||
|
entries.forEach(([key, entry]) => {
|
||||||
|
if (resourceRequests.get(key) === entry) {
|
||||||
|
resourceRequests.delete(key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断固定资源当前是否有请求在途 */
|
||||||
|
export function isResourceRequestPending(key: ResourceRequestKey): boolean {
|
||||||
|
const entry = resourceRequests.get(key)
|
||||||
|
return entry?.mode === ResourceRequestMode.SINGLE_FLIGHT
|
||||||
|
}
|
||||||
159
apps/web-antdv-next/src/views/im/utils/messageSync.ts
Normal file
159
apps/web-antdv-next/src/views/im/utils/messageSync.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
/** 消息状态优先级;高优先级终态不可被普通消息覆盖 */
|
||||||
|
export enum MessageTerminalPriority {
|
||||||
|
NORMAL = 0, // 普通消息
|
||||||
|
CONFIRMED = 1, // 服务端已确认消息
|
||||||
|
RECALL = 2 // 撤回终态
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 会话写 lane 与全量屏障 */
|
||||||
|
interface ConversationWriteState {
|
||||||
|
barrierTail: Promise<void> // 当前全量屏障尾部
|
||||||
|
tails: Map<string, Promise<void>> // 各会话写入尾部
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RelationState {
|
||||||
|
terminated: boolean
|
||||||
|
messageId: number
|
||||||
|
localTerminationPending: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const writeState: ConversationWriteState = {
|
||||||
|
// 当前运行时的会话写状态
|
||||||
|
barrierTail: Promise.resolve(),
|
||||||
|
tails: new Map()
|
||||||
|
}
|
||||||
|
const relationStates = new Map<string, RelationState>() // 群关系消息终态
|
||||||
|
|
||||||
|
/** 同一会话串行执行消息与会话终态写入 */
|
||||||
|
export async function enqueueConversationWrite<T>(
|
||||||
|
clientConversationId: string,
|
||||||
|
operation: () => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
return enqueueConversationWrites([clientConversationId], operation)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一次写入原子占用全部会话 lane,避免嵌套获取与屏障互锁 */
|
||||||
|
export function enqueueConversationWrites<T>(
|
||||||
|
clientConversationIds: string[],
|
||||||
|
operation: () => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
// 1. 等待全量屏障和所有目标会话的前驱写入
|
||||||
|
const keys = Array.from(new Set(clientConversationIds)).sort()
|
||||||
|
const predecessors = [
|
||||||
|
writeState.barrierTail,
|
||||||
|
...keys.map((key) => writeState.tails.get(key) || Promise.resolve())
|
||||||
|
]
|
||||||
|
const current = Promise.all(predecessors.map((task) => task.catch(() => undefined))).then(
|
||||||
|
operation
|
||||||
|
)
|
||||||
|
const settled = current.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined
|
||||||
|
)
|
||||||
|
// 2. 先发布 recovery tail;完成时仅清理仍指向本任务的 lane
|
||||||
|
keys.forEach((key) => writeState.tails.set(key, settled))
|
||||||
|
return current.finally(() => {
|
||||||
|
keys.forEach((key) => {
|
||||||
|
if (writeState.tails.get(key) === settled) {
|
||||||
|
writeState.tails.delete(key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 独占全部会话写入;只用于全量快照重建,常规写仍按会话并行 */
|
||||||
|
export function enqueueConversationBarrier<T>(operation: () => Promise<T>): Promise<T> {
|
||||||
|
// 1. 同步发布 gate,阻止后续会话写越过本次全量操作
|
||||||
|
const previousBarrier = writeState.barrierTail
|
||||||
|
const existingWrites = Array.from(writeState.tails.values())
|
||||||
|
let release!: () => void
|
||||||
|
const gate = new Promise<void>((resolve) => {
|
||||||
|
release = resolve
|
||||||
|
})
|
||||||
|
writeState.barrierTail = previousBarrier.catch(() => undefined).then(() => gate)
|
||||||
|
return (async () => {
|
||||||
|
// 2. 排空封门前的屏障和会话写,再独占执行全量操作
|
||||||
|
await previousBarrier.catch(() => undefined)
|
||||||
|
await Promise.all(existingWrites.map((task) => task.catch(() => undefined)))
|
||||||
|
return await operation()
|
||||||
|
})().finally(release)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 终态优先;相同优先级使用后到达状态 */
|
||||||
|
export function reduceMessageState<T>(
|
||||||
|
current: { priority: MessageTerminalPriority; value?: T } | undefined,
|
||||||
|
incoming: { priority: MessageTerminalPriority; value?: T }
|
||||||
|
) {
|
||||||
|
return current && current.priority > incoming.priority ? current : incoming
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在会话写 lane 内记录关系终态;本地主动操作等待服务端终态消息后才允许重开 */
|
||||||
|
export function markRelationTerminated(clientConversationId: string, messageId?: number): boolean {
|
||||||
|
return applyRelationState(clientConversationId, true, messageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 显式重新加入后清除关系终态;旧通知不得重开新终态 */
|
||||||
|
export function reopenRelation(clientConversationId: string, messageId?: number): boolean {
|
||||||
|
return applyRelationState(clientConversationId, false, messageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRelationTerminated(clientConversationId: string): boolean {
|
||||||
|
return relationStates.get(clientConversationId)?.terminated === true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 排空当前 IM 运行时的消息写入,并在调用方仍允许时清理关系终态 */
|
||||||
|
export async function clearMessageSyncState(shouldClear: () => boolean): Promise<void> {
|
||||||
|
const barrier = writeState.barrierTail
|
||||||
|
const tails = Array.from(writeState.tails.entries())
|
||||||
|
await Promise.all([
|
||||||
|
barrier.catch(() => undefined),
|
||||||
|
...tails.map(([, task]) => task.catch(() => undefined))
|
||||||
|
])
|
||||||
|
if (!shouldClear()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
relationStates.clear()
|
||||||
|
if (writeState.barrierTail === barrier) {
|
||||||
|
writeState.barrierTail = Promise.resolve()
|
||||||
|
}
|
||||||
|
tails.forEach(([key, task]) => {
|
||||||
|
if (writeState.tails.get(key) === task) {
|
||||||
|
writeState.tails.delete(key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按服务端关系消息编号单调归约群关系;本地主动终止在服务端终态确认前阻止旧成员消息重开 */
|
||||||
|
function applyRelationState(
|
||||||
|
clientConversationId: string,
|
||||||
|
terminated: boolean,
|
||||||
|
messageId?: number
|
||||||
|
): boolean {
|
||||||
|
const current = relationStates.get(clientConversationId)
|
||||||
|
if (messageId === undefined) {
|
||||||
|
if (!terminated && current?.localTerminationPending) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (terminated && current?.terminated) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
relationStates.set(clientConversationId, {
|
||||||
|
terminated,
|
||||||
|
messageId: current?.messageId ?? 0,
|
||||||
|
localTerminationPending: terminated
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (!terminated && current?.localTerminationPending) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (current && messageId <= current.messageId) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
relationStates.set(clientConversationId, {
|
||||||
|
terminated,
|
||||||
|
messageId,
|
||||||
|
localTerminationPending: false
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
108
apps/web-antdv-next/src/views/im/utils/resourceRequest.ts
Normal file
108
apps/web-antdv-next/src/views/im/utils/resourceRequest.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/** 可合并请求的固定资源 */
|
||||||
|
export enum ResourceRequestKey {
|
||||||
|
FACE_PACKS = 'facePacks', // 系统表情包
|
||||||
|
FACE_USER_ITEMS = 'faceUserItems', // 用户表情
|
||||||
|
FRIEND_LIST = 'friendList', // 好友列表
|
||||||
|
GROUP_LIST = 'groupList', // 群列表
|
||||||
|
CHANNEL_LIST = 'channelList', // 频道列表
|
||||||
|
GROUP_REQUEST_UNHANDLED = 'groupRequestUnhandled' // 未处理加群申请
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 固定资源请求的 task 生命周期模式 */
|
||||||
|
export enum ResourceRequestMode {
|
||||||
|
CACHE_SUCCESS = 'cache-success', // 成功后持续复用,清理运行时状态时失效
|
||||||
|
SINGLE_FLIGHT = 'single-flight' // 仅合并当前在途请求
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 固定资源请求策略 */
|
||||||
|
type ResourceRequestPolicy =
|
||||||
|
| {
|
||||||
|
mode: ResourceRequestMode.CACHE_SUCCESS
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
mode: ResourceRequestMode.SINGLE_FLIGHT
|
||||||
|
refreshAfterPending?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单个固定资源当前发布的请求状态 */
|
||||||
|
interface ResourceRequestEntry {
|
||||||
|
mode: ResourceRequestMode // task 生命周期模式
|
||||||
|
task: Promise<unknown> // 当前请求 task
|
||||||
|
trailingExecute?: () => Promise<unknown> // 合并后的尾随刷新
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceRequests = new Map<ResourceRequestKey, ResourceRequestEntry>() // 每个 key 仅发布一个当前 entry
|
||||||
|
|
||||||
|
/** 运行固定资源请求 */
|
||||||
|
export function runResourceRequest<T>(
|
||||||
|
key: ResourceRequestKey,
|
||||||
|
execute: () => Promise<T>,
|
||||||
|
policy: ResourceRequestPolicy
|
||||||
|
): Promise<T> {
|
||||||
|
const existing = resourceRequests.get(key)
|
||||||
|
// 1. 复用 task;force 只覆盖为一个最新尾随执行器
|
||||||
|
if (existing) {
|
||||||
|
if (existing.mode !== policy.mode) {
|
||||||
|
return Promise.reject(new Error(`IM resource policy mismatch: ${key}`))
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
existing.mode === ResourceRequestMode.SINGLE_FLIGHT &&
|
||||||
|
policy.mode === ResourceRequestMode.SINGLE_FLIGHT &&
|
||||||
|
policy.refreshAfterPending
|
||||||
|
) {
|
||||||
|
existing.trailingExecute = execute
|
||||||
|
}
|
||||||
|
return existing.task as Promise<T>
|
||||||
|
}
|
||||||
|
const task = Promise.resolve().then(execute)
|
||||||
|
const entry: ResourceRequestEntry = { mode: policy.mode, task }
|
||||||
|
resourceRequests.set(key, entry)
|
||||||
|
void task.then(
|
||||||
|
() => finishResourceRequest(key, entry, true),
|
||||||
|
() => finishResourceRequest(key, entry, false)
|
||||||
|
)
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 完成请求并按策略释放或补刷 */
|
||||||
|
function finishResourceRequest(
|
||||||
|
key: ResourceRequestKey,
|
||||||
|
entry: ResourceRequestEntry,
|
||||||
|
succeeded: boolean
|
||||||
|
): void {
|
||||||
|
// 1. 旧 finalizer 不能修改已经替换的新 entry
|
||||||
|
if (resourceRequests.get(key) !== entry) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 2. once 成功保留;其余情况先释放当前 entry
|
||||||
|
if (entry.mode === ResourceRequestMode.CACHE_SUCCESS && succeeded) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resourceRequests.delete(key)
|
||||||
|
// 3. single-flight 的多次 force 合并为一次后台尾随刷新
|
||||||
|
if (entry.trailingExecute) {
|
||||||
|
void runResourceRequest(key, entry.trailingExecute, {
|
||||||
|
mode: ResourceRequestMode.SINGLE_FLIGHT
|
||||||
|
}).catch((error) => console.warn(`[IM] 尾随刷新 ${key} 失败`, error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 排空并清理固定资源请求状态 */
|
||||||
|
export async function clearResourceRequests(): Promise<void> {
|
||||||
|
const entries = Array.from(resourceRequests.entries())
|
||||||
|
entries.forEach(([, entry]) => {
|
||||||
|
entry.trailingExecute = undefined
|
||||||
|
})
|
||||||
|
await Promise.all(entries.map(([, entry]) => entry.task.catch(() => undefined)))
|
||||||
|
entries.forEach(([key, entry]) => {
|
||||||
|
if (resourceRequests.get(key) === entry) {
|
||||||
|
resourceRequests.delete(key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断固定资源当前是否有请求在途 */
|
||||||
|
export function isResourceRequestPending(key: ResourceRequestKey): boolean {
|
||||||
|
const entry = resourceRequests.get(key)
|
||||||
|
return entry?.mode === ResourceRequestMode.SINGLE_FLIGHT
|
||||||
|
}
|
||||||
159
apps/web-ele/src/views/im/utils/messageSync.ts
Normal file
159
apps/web-ele/src/views/im/utils/messageSync.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
/** 消息状态优先级;高优先级终态不可被普通消息覆盖 */
|
||||||
|
export enum MessageTerminalPriority {
|
||||||
|
NORMAL = 0, // 普通消息
|
||||||
|
CONFIRMED = 1, // 服务端已确认消息
|
||||||
|
RECALL = 2 // 撤回终态
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 会话写 lane 与全量屏障 */
|
||||||
|
interface ConversationWriteState {
|
||||||
|
barrierTail: Promise<void> // 当前全量屏障尾部
|
||||||
|
tails: Map<string, Promise<void>> // 各会话写入尾部
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RelationState {
|
||||||
|
terminated: boolean
|
||||||
|
messageId: number
|
||||||
|
localTerminationPending: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const writeState: ConversationWriteState = {
|
||||||
|
// 当前运行时的会话写状态
|
||||||
|
barrierTail: Promise.resolve(),
|
||||||
|
tails: new Map()
|
||||||
|
}
|
||||||
|
const relationStates = new Map<string, RelationState>() // 群关系消息终态
|
||||||
|
|
||||||
|
/** 同一会话串行执行消息与会话终态写入 */
|
||||||
|
export async function enqueueConversationWrite<T>(
|
||||||
|
clientConversationId: string,
|
||||||
|
operation: () => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
return enqueueConversationWrites([clientConversationId], operation)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一次写入原子占用全部会话 lane,避免嵌套获取与屏障互锁 */
|
||||||
|
export function enqueueConversationWrites<T>(
|
||||||
|
clientConversationIds: string[],
|
||||||
|
operation: () => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
// 1. 等待全量屏障和所有目标会话的前驱写入
|
||||||
|
const keys = Array.from(new Set(clientConversationIds)).sort()
|
||||||
|
const predecessors = [
|
||||||
|
writeState.barrierTail,
|
||||||
|
...keys.map((key) => writeState.tails.get(key) || Promise.resolve())
|
||||||
|
]
|
||||||
|
const current = Promise.all(predecessors.map((task) => task.catch(() => undefined))).then(
|
||||||
|
operation
|
||||||
|
)
|
||||||
|
const settled = current.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined
|
||||||
|
)
|
||||||
|
// 2. 先发布 recovery tail;完成时仅清理仍指向本任务的 lane
|
||||||
|
keys.forEach((key) => writeState.tails.set(key, settled))
|
||||||
|
return current.finally(() => {
|
||||||
|
keys.forEach((key) => {
|
||||||
|
if (writeState.tails.get(key) === settled) {
|
||||||
|
writeState.tails.delete(key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 独占全部会话写入;只用于全量快照重建,常规写仍按会话并行 */
|
||||||
|
export function enqueueConversationBarrier<T>(operation: () => Promise<T>): Promise<T> {
|
||||||
|
// 1. 同步发布 gate,阻止后续会话写越过本次全量操作
|
||||||
|
const previousBarrier = writeState.barrierTail
|
||||||
|
const existingWrites = Array.from(writeState.tails.values())
|
||||||
|
let release!: () => void
|
||||||
|
const gate = new Promise<void>((resolve) => {
|
||||||
|
release = resolve
|
||||||
|
})
|
||||||
|
writeState.barrierTail = previousBarrier.catch(() => undefined).then(() => gate)
|
||||||
|
return (async () => {
|
||||||
|
// 2. 排空封门前的屏障和会话写,再独占执行全量操作
|
||||||
|
await previousBarrier.catch(() => undefined)
|
||||||
|
await Promise.all(existingWrites.map((task) => task.catch(() => undefined)))
|
||||||
|
return await operation()
|
||||||
|
})().finally(release)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 终态优先;相同优先级使用后到达状态 */
|
||||||
|
export function reduceMessageState<T>(
|
||||||
|
current: { priority: MessageTerminalPriority; value?: T } | undefined,
|
||||||
|
incoming: { priority: MessageTerminalPriority; value?: T }
|
||||||
|
) {
|
||||||
|
return current && current.priority > incoming.priority ? current : incoming
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在会话写 lane 内记录关系终态;本地主动操作等待服务端终态消息后才允许重开 */
|
||||||
|
export function markRelationTerminated(clientConversationId: string, messageId?: number): boolean {
|
||||||
|
return applyRelationState(clientConversationId, true, messageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 显式重新加入后清除关系终态;旧通知不得重开新终态 */
|
||||||
|
export function reopenRelation(clientConversationId: string, messageId?: number): boolean {
|
||||||
|
return applyRelationState(clientConversationId, false, messageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRelationTerminated(clientConversationId: string): boolean {
|
||||||
|
return relationStates.get(clientConversationId)?.terminated === true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 排空当前 IM 运行时的消息写入,并在调用方仍允许时清理关系终态 */
|
||||||
|
export async function clearMessageSyncState(shouldClear: () => boolean): Promise<void> {
|
||||||
|
const barrier = writeState.barrierTail
|
||||||
|
const tails = Array.from(writeState.tails.entries())
|
||||||
|
await Promise.all([
|
||||||
|
barrier.catch(() => undefined),
|
||||||
|
...tails.map(([, task]) => task.catch(() => undefined))
|
||||||
|
])
|
||||||
|
if (!shouldClear()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
relationStates.clear()
|
||||||
|
if (writeState.barrierTail === barrier) {
|
||||||
|
writeState.barrierTail = Promise.resolve()
|
||||||
|
}
|
||||||
|
tails.forEach(([key, task]) => {
|
||||||
|
if (writeState.tails.get(key) === task) {
|
||||||
|
writeState.tails.delete(key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按服务端关系消息编号单调归约群关系;本地主动终止在服务端终态确认前阻止旧成员消息重开 */
|
||||||
|
function applyRelationState(
|
||||||
|
clientConversationId: string,
|
||||||
|
terminated: boolean,
|
||||||
|
messageId?: number
|
||||||
|
): boolean {
|
||||||
|
const current = relationStates.get(clientConversationId)
|
||||||
|
if (messageId === undefined) {
|
||||||
|
if (!terminated && current?.localTerminationPending) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (terminated && current?.terminated) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
relationStates.set(clientConversationId, {
|
||||||
|
terminated,
|
||||||
|
messageId: current?.messageId ?? 0,
|
||||||
|
localTerminationPending: terminated
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (!terminated && current?.localTerminationPending) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (current && messageId <= current.messageId) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
relationStates.set(clientConversationId, {
|
||||||
|
terminated,
|
||||||
|
messageId,
|
||||||
|
localTerminationPending: false
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
108
apps/web-ele/src/views/im/utils/resourceRequest.ts
Normal file
108
apps/web-ele/src/views/im/utils/resourceRequest.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/** 可合并请求的固定资源 */
|
||||||
|
export enum ResourceRequestKey {
|
||||||
|
FACE_PACKS = 'facePacks', // 系统表情包
|
||||||
|
FACE_USER_ITEMS = 'faceUserItems', // 用户表情
|
||||||
|
FRIEND_LIST = 'friendList', // 好友列表
|
||||||
|
GROUP_LIST = 'groupList', // 群列表
|
||||||
|
CHANNEL_LIST = 'channelList', // 频道列表
|
||||||
|
GROUP_REQUEST_UNHANDLED = 'groupRequestUnhandled' // 未处理加群申请
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 固定资源请求的 task 生命周期模式 */
|
||||||
|
export enum ResourceRequestMode {
|
||||||
|
CACHE_SUCCESS = 'cache-success', // 成功后持续复用,清理运行时状态时失效
|
||||||
|
SINGLE_FLIGHT = 'single-flight' // 仅合并当前在途请求
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 固定资源请求策略 */
|
||||||
|
type ResourceRequestPolicy =
|
||||||
|
| {
|
||||||
|
mode: ResourceRequestMode.CACHE_SUCCESS
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
mode: ResourceRequestMode.SINGLE_FLIGHT
|
||||||
|
refreshAfterPending?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单个固定资源当前发布的请求状态 */
|
||||||
|
interface ResourceRequestEntry {
|
||||||
|
mode: ResourceRequestMode // task 生命周期模式
|
||||||
|
task: Promise<unknown> // 当前请求 task
|
||||||
|
trailingExecute?: () => Promise<unknown> // 合并后的尾随刷新
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceRequests = new Map<ResourceRequestKey, ResourceRequestEntry>() // 每个 key 仅发布一个当前 entry
|
||||||
|
|
||||||
|
/** 运行固定资源请求 */
|
||||||
|
export function runResourceRequest<T>(
|
||||||
|
key: ResourceRequestKey,
|
||||||
|
execute: () => Promise<T>,
|
||||||
|
policy: ResourceRequestPolicy
|
||||||
|
): Promise<T> {
|
||||||
|
const existing = resourceRequests.get(key)
|
||||||
|
// 1. 复用 task;force 只覆盖为一个最新尾随执行器
|
||||||
|
if (existing) {
|
||||||
|
if (existing.mode !== policy.mode) {
|
||||||
|
return Promise.reject(new Error(`IM resource policy mismatch: ${key}`))
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
existing.mode === ResourceRequestMode.SINGLE_FLIGHT &&
|
||||||
|
policy.mode === ResourceRequestMode.SINGLE_FLIGHT &&
|
||||||
|
policy.refreshAfterPending
|
||||||
|
) {
|
||||||
|
existing.trailingExecute = execute
|
||||||
|
}
|
||||||
|
return existing.task as Promise<T>
|
||||||
|
}
|
||||||
|
const task = Promise.resolve().then(execute)
|
||||||
|
const entry: ResourceRequestEntry = { mode: policy.mode, task }
|
||||||
|
resourceRequests.set(key, entry)
|
||||||
|
void task.then(
|
||||||
|
() => finishResourceRequest(key, entry, true),
|
||||||
|
() => finishResourceRequest(key, entry, false)
|
||||||
|
)
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 完成请求并按策略释放或补刷 */
|
||||||
|
function finishResourceRequest(
|
||||||
|
key: ResourceRequestKey,
|
||||||
|
entry: ResourceRequestEntry,
|
||||||
|
succeeded: boolean
|
||||||
|
): void {
|
||||||
|
// 1. 旧 finalizer 不能修改已经替换的新 entry
|
||||||
|
if (resourceRequests.get(key) !== entry) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 2. once 成功保留;其余情况先释放当前 entry
|
||||||
|
if (entry.mode === ResourceRequestMode.CACHE_SUCCESS && succeeded) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resourceRequests.delete(key)
|
||||||
|
// 3. single-flight 的多次 force 合并为一次后台尾随刷新
|
||||||
|
if (entry.trailingExecute) {
|
||||||
|
void runResourceRequest(key, entry.trailingExecute, {
|
||||||
|
mode: ResourceRequestMode.SINGLE_FLIGHT
|
||||||
|
}).catch((error) => console.warn(`[IM] 尾随刷新 ${key} 失败`, error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 排空并清理固定资源请求状态 */
|
||||||
|
export async function clearResourceRequests(): Promise<void> {
|
||||||
|
const entries = Array.from(resourceRequests.entries())
|
||||||
|
entries.forEach(([, entry]) => {
|
||||||
|
entry.trailingExecute = undefined
|
||||||
|
})
|
||||||
|
await Promise.all(entries.map(([, entry]) => entry.task.catch(() => undefined)))
|
||||||
|
entries.forEach(([key, entry]) => {
|
||||||
|
if (resourceRequests.get(key) === entry) {
|
||||||
|
resourceRequests.delete(key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断固定资源当前是否有请求在途 */
|
||||||
|
export function isResourceRequestPending(key: ResourceRequestKey): boolean {
|
||||||
|
const entry = resourceRequests.get(key)
|
||||||
|
return entry?.mode === ResourceRequestMode.SINGLE_FLIGHT
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user