feat(web): 用符合系统风格的组件替换全部 window.alert/confirm

全项目搜出15处 window.confirm、9处 window.alert(其中3处是各文件
各自复制的 notImplemented() 辅助函数,文案还都一样)用在退出登录、
删除消息/收藏/好友、清空聊天记录、解散群聊、转让群主、危险链接
二次确认等场景——全是浏览器原生弹窗,样式和整个应用的设计语言完全
脱节。

新增两个全局UI基础设施(Zustand store + 挂载在App.tsx根节点的
渲染组件,参照stores/calls.ts/messageJump.ts已有的"任何组件都能调用"
惯例,不是每个弹窗各自管理一份本地状态):

- stores/confirmDialog.ts + app/ConfirmDialogRoot.tsx:替换
  window.confirm。requestConfirm()返回Promise<boolean>,和
  window.confirm的返回值语义一致,所以原来"if (!window.confirm(...))
  return"这一行改成"if (!(await requestConfirm({message:...})))
  return",外层函数标成async,不用大改周围逻辑。危险/不可恢复操作
  (删除、解散群聊、注销账号等)传danger:true,确认按钮变红色,
  弹窗视觉沿用转发弹窗已有的"描边取消+实心确认"按钮搭配。
- stores/globalToast.ts + app/GlobalToastRoot.tsx:替换window.alert,
  复用应用里本来就有、每个页面各自本地状态管理的Toast组件。顺手把
  3个文件里各自重复定义、文案完全一样的notImplemented()合并成
  一个共享导出。

实机验证了非危险确认(清空缓存,蓝色确认按钮)、危险确认(删除
消息,红色确认按钮)、取消按钮、Escape键取消、全局提示(点"投诉
举报")——均正常工作,且原有页面各自的Toast错误提示不受影响。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-11 00:08:15 +08:00
co-authored by Claude Sonnet 5
parent 76156a1c75
commit 37d08e8989
18 changed files with 202 additions and 43 deletions
@@ -30,6 +30,8 @@ import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks';
import { forwardMessage, sendPrebuiltContent, sendReply, sendText } from '@/lib/tinode/send';
import { closeConversation, ensureTopicAttached, openConversation } from '@/lib/tinode/topics';
import { useCallStore } from '@/stores/calls';
import { requestConfirm } from '@/stores/confirmDialog';
import { notImplemented } from '@/stores/globalToast';
import { useMessageJumpStore } from '@/stores/messageJump';
import { useSessionStore } from '@/stores/session';
@@ -50,10 +52,6 @@ interface ContextMenuState {
msg: StoredMessage;
}
function notImplemented() {
window.alert('该功能暂未开放,如需联系客服。');
}
export function ConversationView() {
const { topicId } = useParams();
const { onToggleDetail, detailVisible, onOpenHistory } = useOutletContext<OutletCtx>();
@@ -443,9 +441,9 @@ export function ConversationView() {
setSelectedSeqs(new Set());
};
const bulkDelete = () => {
const bulkDelete = async () => {
if (selectedSeqs.size === 0) return;
if (!window.confirm(`删除已选择的 ${selectedSeqs.size} 条消息?`)) return;
if (!(await requestConfirm({ message: `删除已选择的 ${selectedSeqs.size} 条消息?`, danger: true }))) return;
topic.delMessagesList([...selectedSeqs], false);
exitSelectMode();
};
+7 -5
View File
@@ -21,6 +21,8 @@ import type { MediaType } from '@/lib/api/messages';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useTinode } from '@/lib/tinode/hooks';
import { useCallStore } from '@/stores/calls';
import { requestConfirm } from '@/stores/confirmDialog';
import { notify } from '@/stores/globalToast';
interface DetailColumnProps {
topicId: string;
@@ -126,18 +128,18 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
onError: (err) => showError(err, '删除好友失败'),
});
const clearHistory = () => {
if (!window.confirm('确定清空与该联系人的聊天记录吗?此操作仅清除本地记录,不影响对方。')) return;
const clearHistory = async () => {
if (!(await requestConfirm({ message: '确定清空与该联系人的聊天记录吗?此操作仅清除本地记录,不影响对方。', danger: true }))) return;
tinode.getTopic(topicId).delMessagesAll(false);
};
const startDeleteFriend = () => {
if (!window.confirm(`确定删除好友 ${peerName} 吗?`)) return;
const startDeleteFriend = async () => {
if (!(await requestConfirm({ message: `确定删除好友 ${peerName} 吗?`, danger: true }))) return;
deleteFriendMutation.mutate();
};
const reportAbuse = () => {
window.alert('该功能暂未开放,如需投诉举报请联系客服。');
notify('该功能暂未开放,如需投诉举报请联系客服。');
};
return (
@@ -10,6 +10,8 @@ import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useAuthedBlobUrl, useDebouncedValue, useEscapeKey } from '@/lib/hooks';
import { renderDrafty } from '@/lib/messages/drafty';
import { formatEventTimestamp } from '@/lib/messages/time';
import { requestConfirm } from '@/stores/confirmDialog';
import { notify } from '@/stores/globalToast';
import { useMessageJumpStore } from '@/stores/messageJump';
import { useSessionStore } from '@/stores/session';
@@ -90,14 +92,14 @@ function LinkResult({ item }: { item: SearchHit & { url?: string; title?: string
try {
result = await safetyCheck.mutateAsync(item.url!);
} catch {
window.alert('链接安全检查失败,请稍后重试。');
notify('链接安全检查失败,请稍后重试。');
return;
}
if (result.decision === 'block') {
window.alert('该链接存在安全风险,已被拦截。');
notify('该链接存在安全风险,已被拦截。');
return;
}
if (result.decision === 'warn' && !window.confirm('该链接可能存在风险,是否仍要打开?')) return;
if (result.decision === 'warn' && !(await requestConfirm({ message: '该链接可能存在风险,是否仍要打开?' }))) return;
window.open(item.url, '_blank', 'noopener,noreferrer');
};
+5 -3
View File
@@ -3,6 +3,8 @@ import type { ReactNode } from 'react';
import { useMutation } from '@tanstack/react-query';
import { checkUrlSafety } from '@/lib/api/security';
import { requestConfirm } from '@/stores/confirmDialog';
import { notify } from '@/stores/globalToast';
/**
* docs/IM-前后端对接文档.md:1822,1898 -- links found in chat content must pass a server-side risk
@@ -22,14 +24,14 @@ export function SafeLink({ href, children }: { href: string | undefined; childre
try {
result = await safetyCheck.mutateAsync(href);
} catch {
window.alert('链接安全检查失败,请稍后重试。');
notify('链接安全检查失败,请稍后重试。');
return;
}
if (result.decision === 'block') {
window.alert('该链接存在安全风险,已被拦截。');
notify('该链接存在安全风险,已被拦截。');
return;
}
if (result.decision === 'warn' && !window.confirm('该链接可能存在风险,是否仍要打开?')) return;
if (result.decision === 'warn' && !(await requestConfirm({ message: '该链接可能存在风险,是否仍要打开?' }))) return;
window.open(href, '_blank', 'noopener,noreferrer');
};