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:
co-authored by
Claude Sonnet 5
parent
76156a1c75
commit
37d08e8989
@@ -2,6 +2,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Navigate, createBrowserRouter, RouterProvider } from 'react-router-dom';
|
||||
|
||||
import { AppShell } from '@/app/AppShell';
|
||||
import { ConfirmDialogRoot } from '@/app/ConfirmDialogRoot';
|
||||
import { GlobalToastRoot } from '@/app/GlobalToastRoot';
|
||||
import { RequireAuth } from '@/app/RequireAuth';
|
||||
import { EnterpriseRegisterPage } from '@/features/auth/EnterpriseRegisterPage';
|
||||
import { LoginPage } from '@/features/auth/LoginPage';
|
||||
@@ -97,6 +99,11 @@ export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
{/* Mounted above the router (not inside AppShell) -- window.confirm()/window.alert()
|
||||
replacements need to work on unauthenticated pages too (e.g. TenantEntryPage's "switch
|
||||
tenant" confirmation), not just the logged-in app tree. */}
|
||||
<ConfirmDialogRoot />
|
||||
<GlobalToastRoot />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useEscapeKey } from '@/lib/hooks';
|
||||
import { useConfirmDialogStore } from '@/stores/confirmDialog';
|
||||
|
||||
/** Mounted once at the app root (App.tsx) -- renders whatever `requestConfirm()` (confirmDialog.ts)
|
||||
* most recently asked for. Visual language matches this app's other small action dialogs (e.g. the
|
||||
* forward picker's outlined-gray "取消" + solid colored confirm button, `02-messages.dc.html`'s own
|
||||
* spec for that pairing) rather than inventing a new button treatment just for this. */
|
||||
export function ConfirmDialogRoot() {
|
||||
const pending = useConfirmDialogStore((s) => s.pending);
|
||||
const respond = useConfirmDialogStore((s) => s.respond);
|
||||
// Always called (hooks can't be conditional) -- a no-op onEscape while nothing is pending is
|
||||
// harmless, matching how every other modal in this app registers unconditionally too.
|
||||
useEscapeKey(() => respond(false));
|
||||
|
||||
if (!pending) return null;
|
||||
const { title, message, confirmLabel = '确定', cancelLabel = '取消', danger } = pending;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-[#22262d]/25" onClick={() => respond(false)}>
|
||||
<div className="w-[340px] rounded-xl bg-white p-5 shadow-modal" onClick={(e) => e.stopPropagation()}>
|
||||
{title ? <strong className="mb-2 block text-base font-semibold text-text-heading">{title}</strong> : null}
|
||||
<p className="m-0 text-sm leading-relaxed text-text-body">{message}</p>
|
||||
<div className="mt-5 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => respond(false)}
|
||||
className="grid h-9 place-items-center rounded-lg border border-[#d5d9df] px-4 text-sm text-text-secondary"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => respond(true)}
|
||||
className={`grid h-9 place-items-center rounded-lg px-4 text-sm font-medium text-white ${danger ? 'bg-danger-text' : 'bg-btn'}`}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { useGlobalToastStore } from '@/stores/globalToast';
|
||||
|
||||
/** Mounted once at the app root (App.tsx) -- renders whatever `notify()` (globalToast.ts) most
|
||||
* recently set. `key={token}` forces Toast to remount (not just re-render) so its own auto-dismiss
|
||||
* timer restarts even when the exact same message is fired twice in a row, which a plain `message`
|
||||
* string prop wouldn't detect as a change. */
|
||||
export function GlobalToastRoot() {
|
||||
const message = useGlobalToastStore((s) => s.message);
|
||||
const variant = useGlobalToastStore((s) => s.variant);
|
||||
const token = useGlobalToastStore((s) => s.token);
|
||||
if (!message) return null;
|
||||
return <Toast key={token} message={message} variant={variant} onDone={() => useGlobalToastStore.setState({ message: '' })} />;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { Icon } from '@/app/Icon';
|
||||
import { ImApiError } from '@/lib/api/client';
|
||||
import { resolveTenant } from '@/lib/api/tenant';
|
||||
import { resetTinode } from '@/lib/tinode/connection';
|
||||
import { requestConfirm } from '@/stores/confirmDialog';
|
||||
import { parseWebsocketEndpoint, useSessionStore } from '@/stores/session';
|
||||
|
||||
export function TenantEntryPage() {
|
||||
@@ -25,7 +26,7 @@ export function TenantEntryPage() {
|
||||
// Reachable by navigating back here while already logged in (no route guard keeps an
|
||||
// authenticated user out of /tenant) -- resetTinode() below would otherwise silently tear down
|
||||
// that live session with no warning at all.
|
||||
if (myUserId && !window.confirm('切换企业将退出当前登录,确定继续吗?')) return;
|
||||
if (myUserId && !(await requestConfirm({ message: '切换企业将退出当前登录,确定继续吗?' }))) return;
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
|
||||
@@ -12,13 +12,10 @@ import { searchUsers, sendFriendRequest, type UserSearchResult } from '@/lib/api
|
||||
import { submitGroupJoinRequest } from '@/lib/api/groups';
|
||||
import type { QRResolveGroup, QRResolveUser } from '@/lib/api/qrcodes';
|
||||
import { useEscapeKey } from '@/lib/hooks';
|
||||
import { notImplemented } from '@/stores/globalToast';
|
||||
|
||||
const MAX_MESSAGE_LENGTH = 50;
|
||||
|
||||
function notImplemented() {
|
||||
window.alert('该功能暂未开放,如需联系客服。');
|
||||
}
|
||||
|
||||
interface AddFriendModalProps {
|
||||
onClose: () => void;
|
||||
onSent?: () => void;
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useAuthedBlobUrl, useDebouncedValue } from '@/lib/hooks';
|
||||
import { fileTypeStyle, formatFileSize } from '@/lib/messages/attachments';
|
||||
import { formatEventTimestamp } from '@/lib/messages/time';
|
||||
import { useChatList } from '@/lib/tinode/hooks';
|
||||
import { requestConfirm } from '@/stores/confirmDialog';
|
||||
|
||||
const CATEGORIES: { key: FavoriteType | 'all'; label: string; icon: string }[] = [
|
||||
{ key: 'all', label: '全部', icon: 'all_inbox' },
|
||||
@@ -320,9 +321,9 @@ export function FavoritesLayout() {
|
||||
setSelectMode(false);
|
||||
setSelected(new Set());
|
||||
};
|
||||
const bulkDelete = () => {
|
||||
const bulkDelete = async () => {
|
||||
if (selected.size === 0) return;
|
||||
if (!window.confirm(`删除已选择的 ${selected.size} 项收藏?`)) return;
|
||||
if (!(await requestConfirm({ message: `删除已选择的 ${selected.size} 项收藏?`, danger: true }))) return;
|
||||
deleteMutation.mutate([...selected]);
|
||||
};
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from '@/lib/api/groups';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { useEscapeKey } from '@/lib/hooks';
|
||||
import { requestConfirm } from '@/stores/confirmDialog';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
interface GroupAdminModalProps {
|
||||
@@ -478,8 +479,10 @@ function TransferOwnerSubView({ topicId, groupVersion, onClose }: { topicId: str
|
||||
topicId={topicId}
|
||||
cacheKey="eligible-owner"
|
||||
opts={{ eligibleRole: 'owner' }}
|
||||
onPick={(m) => {
|
||||
if (window.confirm(`确定将群主转让给 ${m.display_name || m.user_id} 吗?此操作不可撤销。`)) transferMutation.mutate(m.user_id);
|
||||
onPick={async (m) => {
|
||||
if (await requestConfirm({ message: `确定将群主转让给 ${m.display_name || m.user_id} 吗?此操作不可撤销。`, danger: true })) {
|
||||
transferMutation.mutate(m.user_id);
|
||||
}
|
||||
}}
|
||||
emptyLabel="没有可转让的成员"
|
||||
disabled={transferMutation.isPending}
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
import type { MediaType } from '@/lib/api/messages';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { useTinode } from '@/lib/tinode/hooks';
|
||||
import { requestConfirm } from '@/stores/confirmDialog';
|
||||
import { notify } from '@/stores/globalToast';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
interface GroupDetailColumnProps {
|
||||
@@ -155,21 +157,21 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
|
||||
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 reportAbuse = () => {
|
||||
window.alert('该功能暂未开放,如需投诉举报请联系客服。');
|
||||
notify('该功能暂未开放,如需投诉举报请联系客服。');
|
||||
};
|
||||
|
||||
const startLeaveOrDisband = () => {
|
||||
const startLeaveOrDisband = async () => {
|
||||
if (isOwner) {
|
||||
if (!window.confirm('解散群聊后,所有成员将无法再查看和发送消息,此操作不可恢复。确定解散吗?')) return;
|
||||
if (!(await requestConfirm({ message: '解散群聊后,所有成员将无法再查看和发送消息,此操作不可恢复。确定解散吗?', danger: true }))) return;
|
||||
disbandMutation.mutate();
|
||||
} else {
|
||||
if (!window.confirm('确定退出本群聊吗?')) return;
|
||||
if (!(await requestConfirm({ message: '确定退出本群聊吗?', danger: true }))) return;
|
||||
leaveMutation.mutate();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import { fileAuthHeaders, fileDownloadUrl, uploadFile } from '@/lib/api/upload';
|
||||
import { useClickOutside, useDebouncedValue, useEscapeKey } from '@/lib/hooks';
|
||||
import { fileTypeStyle, formatFileSize } from '@/lib/messages/attachments';
|
||||
import { formatEventTimestamp } from '@/lib/messages/time';
|
||||
import { requestConfirm } from '@/stores/confirmDialog';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
function isPdf(f: GroupFile): boolean {
|
||||
@@ -211,9 +212,11 @@ export function GroupFilesModal({ topicId, isAdmin, onClose }: GroupFilesModalPr
|
||||
onClose={() => setOpenMenuId(null)}
|
||||
onDownload={() => downloadFile(f)}
|
||||
onOpen={() => openFileInNewTab(f)}
|
||||
onDelete={() => {
|
||||
onDelete={async () => {
|
||||
setOpenMenuId(null);
|
||||
if (window.confirm(`确定删除文件"${f.file_name}"吗?`)) deleteMutation.mutate(f.group_file_id);
|
||||
if (await requestConfirm({ message: `确定删除文件"${f.file_name}"吗?`, danger: true })) {
|
||||
deleteMutation.mutate(f.group_file_id);
|
||||
}
|
||||
}}
|
||||
deleting={deleteMutation.isPending}
|
||||
/>
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { requestConfirm } from '@/stores/confirmDialog';
|
||||
|
||||
/**
|
||||
* README §8.7 mentions a "清空缓存「126 MB」" row -- confirmed no server endpoint touches cache at
|
||||
@@ -16,7 +17,7 @@ export function GeneralSettingsPage() {
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const clearCache = async () => {
|
||||
if (!window.confirm('确定清除本地缓存吗?不会影响你的账号数据。')) return;
|
||||
if (!(await requestConfirm({ message: '确定清除本地缓存吗?不会影响你的账号数据。' }))) return;
|
||||
setStatus('clearing');
|
||||
try {
|
||||
if (typeof caches === 'undefined') throw new Error('此浏览器不支持缓存清除');
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { getTinode, resetTinode } from '@/lib/tinode/connection';
|
||||
import { requestConfirm } from '@/stores/confirmDialog';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
/**
|
||||
@@ -66,8 +67,8 @@ export function SecuritySettingsPage() {
|
||||
|
||||
const deleteAccount = async () => {
|
||||
if (deletingAccount) return;
|
||||
if (!window.confirm('注销账号将永久删除你的账号和全部数据,此操作不可恢复。确定继续吗?')) return;
|
||||
if (!window.confirm('请再次确认:真的要注销账号吗?此操作立即生效,无法撤销。')) return;
|
||||
if (!(await requestConfirm({ message: '注销账号将永久删除你的账号和全部数据,此操作不可恢复。确定继续吗?', danger: true }))) return;
|
||||
if (!(await requestConfirm({ message: '请再次确认:真的要注销账号吗?此操作立即生效,无法撤销。', danger: true }))) return;
|
||||
setDeletingAccount(true);
|
||||
try {
|
||||
await getTinode().delCurrentUser(true);
|
||||
|
||||
@@ -7,6 +7,8 @@ import { Avatar } from '@/app/Avatar';
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { getMe } from '@/lib/api/profile';
|
||||
import { resetTinode } from '@/lib/tinode/connection';
|
||||
import { requestConfirm } from '@/stores/confirmDialog';
|
||||
import { notImplemented } from '@/stores/globalToast';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
const DIRECTORY = [
|
||||
@@ -39,10 +41,6 @@ const MOBILE_SHORTCUTS = [
|
||||
* endpoints 404, matching the old app's own real implementation (tinode-web.jsx:1995-2038), which
|
||||
* never calls a server logout endpoint at all, just clears local token/session and disconnects.
|
||||
*/
|
||||
function notImplemented() {
|
||||
window.alert('该功能暂未开放,如需联系客服。');
|
||||
}
|
||||
|
||||
export function SettingsLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
@@ -55,8 +53,8 @@ export function SettingsLayout() {
|
||||
// currently shows. Irrelevant at `md`+ (desktop always shows the one real sidebar).
|
||||
const [mobileSubView, setMobileSubView] = useState(false);
|
||||
|
||||
const logout = () => {
|
||||
if (!window.confirm('确定退出登录吗?')) return;
|
||||
const logout = async () => {
|
||||
if (!(await requestConfirm({ message: '确定退出登录吗?' }))) return;
|
||||
setLoggingOut(true);
|
||||
resetTinode();
|
||||
sessionStorage.removeItem('im-session-token');
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
/**
|
||||
* Replaces `window.confirm()` app-wide (README's own design language never intended a native
|
||||
* browser dialog to show up inside this UI -- it was just the fastest way to gate a destructive
|
||||
* action while the real dialog didn't exist yet). A global store + a single `<ConfirmDialogRoot>`
|
||||
* mounted once at the app root (see App.tsx), not a local `useState` per call site: this needs to be
|
||||
* callable from any event handler, including ones with no natural "own a piece of JSX for this"
|
||||
* home (e.g. GroupFilesModal's per-row delete button), the same reasoning `stores/calls.ts`/
|
||||
* `stores/messageJump.ts` already established for other "any component, any time" actions.
|
||||
*
|
||||
* `requestConfirm()` returns a Promise the same shape `window.confirm()`'s return value has (resolves
|
||||
* true/false, never rejects) so every existing `if (!window.confirm(...)) return;` call site becomes
|
||||
* `if (!(await requestConfirm({ message: ... }))) return;` with the enclosing function marked async --
|
||||
* a mechanical, low-risk edit, not a rewrite of the surrounding logic.
|
||||
*/
|
||||
export interface ConfirmOptions {
|
||||
title?: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
/** Red confirm button for irreversible/destructive actions (delete, disband, deregister) --
|
||||
* omitted (default blue) for merely consequential ones (log out, switch tenant). */
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
interface PendingConfirm extends ConfirmOptions {
|
||||
resolve: (value: boolean) => void;
|
||||
}
|
||||
|
||||
interface ConfirmDialogState {
|
||||
pending: PendingConfirm | null;
|
||||
respond: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export const useConfirmDialogStore = create<ConfirmDialogState>((set, get) => ({
|
||||
pending: null,
|
||||
respond: (value) => {
|
||||
get().pending?.resolve(value);
|
||||
set({ pending: null });
|
||||
},
|
||||
}));
|
||||
|
||||
export function requestConfirm(options: ConfirmOptions): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
useConfirmDialogStore.setState({ pending: { ...options, resolve } });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
/**
|
||||
* Replaces `window.alert()` app-wide, same rationale as `stores/confirmDialog.ts`'s replacement of
|
||||
* `window.confirm()`: a global store + a single `<GlobalToastRoot>` mounted once at the app root
|
||||
* renders the app's own existing `Toast` component (already used everywhere for a mutation's error
|
||||
* state) instead of the browser's native dialog. `notify()` is deliberately fire-and-forget (no
|
||||
* Promise, no return value) -- an alert-style message never blocked on a user response the way
|
||||
* confirm did, so there's nothing to await.
|
||||
*/
|
||||
interface GlobalToastState {
|
||||
message: string;
|
||||
variant: 'success' | 'error';
|
||||
token: number;
|
||||
}
|
||||
|
||||
export const useGlobalToastStore = create<GlobalToastState>(() => ({
|
||||
message: '',
|
||||
variant: 'error',
|
||||
token: 0,
|
||||
}));
|
||||
|
||||
export function notify(message: string, variant: 'success' | 'error' = 'error') {
|
||||
// `token` (not just `message`) is what Toast's own auto-dismiss effect keys off of via the caller
|
||||
// -- see GlobalToastRoot -- so firing the SAME message twice in a row (e.g. two clicks on a
|
||||
// still-disabled "该功能暂未开放" button before the first toast dismisses) restarts the timer
|
||||
// instead of the second call being a no-op change that React skips re-rendering for.
|
||||
useGlobalToastStore.setState((s) => ({ message, variant, token: s.token + 1 }));
|
||||
}
|
||||
|
||||
/** Every existing `window.alert('该功能暂未开放,如需联系客服。')` call site (settings, composer
|
||||
* toolbar, add-friend, group/contact detail panels) used the identical hand-typed Chinese string --
|
||||
* one shared helper instead of that string living independently in five files that could each drift
|
||||
* from the others if the wording ever needs to change. */
|
||||
export function notImplemented() {
|
||||
notify('该功能暂未开放,如需联系客服。');
|
||||
}
|
||||
Reference in New Issue
Block a user