From 7e9ab1273c352a1f7f1ac5f9e9797c58a1ef0e04 Mon Sep 17 00:00:00 2001 From: cinderzhan Date: Fri, 24 Apr 2026 17:14:56 +0800 Subject: [PATCH] feat(frontend): replace native browser dialogs with Clawith-styled modal/toast Users were seeing browser-default confirm() and alert() popups (session delete, model connectivity test, etc.) that broke visual consistency with the rest of the app. Introduces a unified dialog/toast system so every notification uses the Clawith UI. - DialogProvider + useDialog(): centered modal with Promise-based confirm() and alert() API, info/success/warning/error types, collapsible details for long error payloads (e.g. LLM connectivity test raw errors). - ToastProvider + useToast(): top-right auto-dismissing notifications with the same type/details support for non-critical errors. - Migrated all 46 native alert/confirm call sites across 9 files following the split: destructive or must-acknowledge -> dialog; non-critical -> toast. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/Dialog/DialogProvider.tsx | 189 ++++++++++++++++++ .../src/components/Toast/ToastProvider.tsx | 182 +++++++++++++++++ frontend/src/main.tsx | 8 +- frontend/src/pages/AdminCompanies.tsx | 7 +- frontend/src/pages/AgentCreate.tsx | 4 +- frontend/src/pages/AgentDetail.tsx | 72 ++++--- frontend/src/pages/Chat.tsx | 6 +- frontend/src/pages/EnterpriseSettings.tsx | 69 ++++--- frontend/src/pages/Layout.tsx | 4 +- frontend/src/pages/UserManagement.tsx | 7 +- frontend/src/pages/VerifyEmail.tsx | 6 +- 11 files changed, 492 insertions(+), 62 deletions(-) create mode 100644 frontend/src/components/Dialog/DialogProvider.tsx create mode 100644 frontend/src/components/Toast/ToastProvider.tsx diff --git a/frontend/src/components/Dialog/DialogProvider.tsx b/frontend/src/components/Dialog/DialogProvider.tsx new file mode 100644 index 000000000..356fb3d18 --- /dev/null +++ b/frontend/src/components/Dialog/DialogProvider.tsx @@ -0,0 +1,189 @@ +import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react'; + +type DialogType = 'info' | 'success' | 'warning' | 'error'; + +interface AlertOptions { + title?: string; + type?: DialogType; + details?: string; + confirmLabel?: string; +} + +interface ConfirmOptions { + title?: string; + danger?: boolean; + confirmLabel?: string; + cancelLabel?: string; +} + +interface DialogContextValue { + alert: (message: string, options?: AlertOptions) => Promise; + confirm: (message: string, options?: ConfirmOptions) => Promise; +} + +const DialogContext = createContext(null); + +type ModalState = + | { kind: 'alert'; message: string; options: AlertOptions; resolve: () => void } + | { kind: 'confirm'; message: string; options: ConfirmOptions; resolve: (ok: boolean) => void } + | null; + +const TYPE_META: Record = { + info: { color: 'var(--info)', icon: 'ℹ' }, + success: { color: 'var(--success)', icon: '✓' }, + warning: { color: 'var(--warning)', icon: '⚠' }, + error: { color: 'var(--error)', icon: '✕' }, +}; + +export function DialogProvider({ children }: { children: ReactNode }) { + const [state, setState] = useState(null); + + const alert = useCallback( + (message: string, options: AlertOptions = {}) => + new Promise((resolve) => setState({ kind: 'alert', message, options, resolve })), + [], + ); + + const confirm = useCallback( + (message: string, options: ConfirmOptions = {}) => + new Promise((resolve) => setState({ kind: 'confirm', message, options, resolve })), + [], + ); + + const close = useCallback((result?: boolean) => { + setState((s) => { + if (!s) return null; + if (s.kind === 'alert') s.resolve(); + else s.resolve(!!result); + return null; + }); + }, []); + + return ( + + {children} + {state && } + + ); +} + +function DialogModal({ state, onClose }: { state: NonNullable; onClose: (result?: boolean) => void }) { + const btnRef = useRef(null); + const [showDetails, setShowDetails] = useState(false); + + useEffect(() => { + const timer = setTimeout(() => btnRef.current?.focus(), 50); + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(false); + if (e.key === 'Enter' && state.kind === 'alert') onClose(); + }; + window.addEventListener('keydown', onKey); + return () => { clearTimeout(timer); window.removeEventListener('keydown', onKey); }; + }, [state, onClose]); + + const isConfirm = state.kind === 'confirm'; + const type: DialogType = isConfirm + ? (state.options.danger ? 'error' : 'info') + : (state.options.type ?? 'info'); + const meta = TYPE_META[type]; + const title = state.options.title + ?? (isConfirm ? '请确认' : type === 'error' ? '出错了' : type === 'success' ? '成功' : type === 'warning' ? '提示' : '提示'); + const details = !isConfirm ? state.options.details : undefined; + + return ( +
{ if (e.target === e.currentTarget) onClose(false); }} + > +
+
+ {meta.icon} +

{title}

+
+
+ {state.message} +
+ {details && ( +
+ + {showDetails && ( +
{details}
+ )} +
+ )} +
+ {isConfirm && ( + + )} + +
+
+
+ ); +} + +export function useDialog() { + const ctx = useContext(DialogContext); + if (!ctx) throw new Error('useDialog must be used within DialogProvider'); + return ctx; +} diff --git a/frontend/src/components/Toast/ToastProvider.tsx b/frontend/src/components/Toast/ToastProvider.tsx new file mode 100644 index 000000000..a56c95beb --- /dev/null +++ b/frontend/src/components/Toast/ToastProvider.tsx @@ -0,0 +1,182 @@ +import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react'; + +type ToastType = 'info' | 'success' | 'warning' | 'error'; + +interface ToastOptions { + duration?: number; + details?: string; +} + +interface ToastItem { + id: number; + type: ToastType; + message: string; + details?: string; + duration: number; +} + +interface ToastContextValue { + show: (type: ToastType, message: string, options?: ToastOptions) => void; + info: (message: string, options?: ToastOptions) => void; + success: (message: string, options?: ToastOptions) => void; + warning: (message: string, options?: ToastOptions) => void; + error: (message: string, options?: ToastOptions) => void; +} + +const ToastContext = createContext(null); + +const TYPE_META: Record = { + info: { color: 'var(--info)', icon: 'ℹ' }, + success: { color: 'var(--success)', icon: '✓' }, + warning: { color: 'var(--warning)', icon: '⚠' }, + error: { color: 'var(--error)', icon: '✕' }, +}; + +let idSeq = 0; + +export function ToastProvider({ children }: { children: ReactNode }) { + const [items, setItems] = useState([]); + + const remove = useCallback((id: number) => { + setItems((list) => list.filter((t) => t.id !== id)); + }, []); + + const show = useCallback((type: ToastType, message: string, options: ToastOptions = {}) => { + const id = ++idSeq; + const duration = options.duration ?? (type === 'error' ? 6000 : 3500); + setItems((list) => [...list, { id, type, message, details: options.details, duration }]); + }, []); + + const value: ToastContextValue = { + show, + info: (m, o) => show('info', m, o), + success: (m, o) => show('success', m, o), + warning: (m, o) => show('warning', m, o), + error: (m, o) => show('error', m, o), + }; + + return ( + + {children} +
+ {items.map((t) => ( + remove(t.id)} /> + ))} +
+
+ ); +} + +function ToastCard({ item, onClose }: { item: ToastItem; onClose: () => void }) { + const [showDetails, setShowDetails] = useState(false); + const [leaving, setLeaving] = useState(false); + const timerRef = useRef(null); + const meta = TYPE_META[item.type]; + + useEffect(() => { + timerRef.current = window.setTimeout(() => { + setLeaving(true); + window.setTimeout(onClose, 180); + }, item.duration); + return () => { if (timerRef.current) window.clearTimeout(timerRef.current); }; + }, [item.duration, onClose]); + + const pause = () => { if (timerRef.current) window.clearTimeout(timerRef.current); }; + + return ( +
+ {meta.icon} +
+
{item.message}
+ {item.details && ( + <> + + {showDetails && ( +
{item.details}
+ )} + + )} +
+ +
+ ); +} + +export function useToast() { + const ctx = useContext(ToastContext); + if (!ctx) throw new Error('useToast must be used within ToastProvider'); + return ctx; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index af8bf1883..78e194fe7 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -6,6 +6,8 @@ import './i18n'; import './index.css'; import App from './App'; import ErrorBoundary from './components/ErrorBoundary'; +import { DialogProvider } from './components/Dialog/DialogProvider'; +import { ToastProvider } from './components/Toast/ToastProvider'; import { loadSavedAccentColor } from './utils/theme'; // Apply saved theme color before first paint @@ -22,7 +24,11 @@ ReactDOM.createRoot(document.getElementById('root')!).render( - + + + + + diff --git a/frontend/src/pages/AdminCompanies.tsx b/frontend/src/pages/AdminCompanies.tsx index 884b0f62d..d5f04f271 100644 --- a/frontend/src/pages/AdminCompanies.tsx +++ b/frontend/src/pages/AdminCompanies.tsx @@ -6,6 +6,7 @@ import { saveAccentColor, getSavedAccentColor } from '../utils/theme'; import { IconFilter } from '@tabler/icons-react'; import PlatformDashboard from './PlatformDashboard'; import LinearCopyButton from '../components/LinearCopyButton'; +import { useDialog } from '../components/Dialog/DialogProvider'; // Helper for authenticated JSON fetch async function fetchJson(url: string, options?: RequestInit): Promise { const token = localStorage.getItem('token'); @@ -639,6 +640,7 @@ function PlatformTab() { // ─── Companies Tab ───────────────────────────────── function CompaniesTab() { const { t } = useTranslation(); + const dialog = useDialog(); const [companies, setCompanies] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); @@ -750,7 +752,10 @@ function CompaniesTab() { const handleToggle = async (id: string, currentlyActive: boolean) => { const action = currentlyActive ? 'disable' : 'enable'; - if (currentlyActive && !confirm(t('admin.confirmDisable', 'Disable this company? All users and agents will be paused.'))) return; + if (currentlyActive) { + const ok = await dialog.confirm(t('admin.confirmDisable', 'Disable this company? All users and agents will be paused.'), { title: '禁用公司', danger: true, confirmLabel: '禁用' }); + if (!ok) return; + } try { await adminApi.toggleCompany(id); loadCompanies(); diff --git a/frontend/src/pages/AgentCreate.tsx b/frontend/src/pages/AgentCreate.tsx index 8816eb9a4..4c9b7115f 100644 --- a/frontend/src/pages/AgentCreate.tsx +++ b/frontend/src/pages/AgentCreate.tsx @@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { agentApi, channelApi, enterpriseApi, skillApi } from '../services/api'; import ChannelConfig from '../components/ChannelConfig'; import LinearCopyButton from '../components/LinearCopyButton'; +import { useToast } from '../components/Toast/ToastProvider'; const STEPS = ['basicInfo', 'personality', 'skills', 'permissions', 'channel'] as const; const OPENCLAW_STEPS = ['basicInfo', 'permissions'] as const; @@ -60,6 +61,7 @@ function parseSoulTemplate(soulTemplate: string, sectionNames: string[] = []): R export default function AgentCreate() { const { t } = useTranslation(); + const toast = useToast(); const navigate = useNavigate(); const queryClient = useQueryClient(); const [step, setStep] = useState(0); @@ -609,7 +611,7 @@ For humans, the message is delivered via their available channel (e.g. Feishu).` template_id: '', })); } catch { - alert('Invalid JSON file'); + toast.error('JSON 文件格式无效'); } }; reader.readAsText(file); diff --git a/frontend/src/pages/AgentDetail.tsx b/frontend/src/pages/AgentDetail.tsx index 2e05c98b4..1ad1ff8b3 100644 --- a/frontend/src/pages/AgentDetail.tsx +++ b/frontend/src/pages/AgentDetail.tsx @@ -4,6 +4,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import ConfirmModal from '../components/ConfirmModal'; +import { useDialog } from '../components/Dialog/DialogProvider'; +import { useToast } from '../components/Toast/ToastProvider'; import type { FileBrowserApi } from '../components/FileBrowser'; import FileBrowser from '../components/FileBrowser'; import ChannelConfig from '../components/ChannelConfig'; @@ -48,6 +50,8 @@ const getCategoryLabels = (t: any): Record => ({ function ToolsManager({ agentId, canManage = false }: { agentId: string; canManage?: boolean }) { const { t } = useTranslation(); + const dialog = useDialog(); + const toast = useToast(); const [tools, setTools] = useState([]); const [loading, setLoading] = useState(true); const [configTool, setConfigTool] = useState(null); @@ -215,7 +219,7 @@ function ToolsManager({ agentId, canManage = false }: { agentId: string; canMana setConfigTool(null); } loadTools(); - } catch (e) { alert('Save failed: ' + e); } + } catch (e: any) { toast.error('保存失败', { details: String(e?.message || e) }); } setConfigSaving(false); }; @@ -316,7 +320,11 @@ function ToolsManager({ agentId, canManage = false }: { agentId: string; canMana {canManage && tool.source === 'agent' && tool.agent_tool_id && ( diff --git a/frontend/src/pages/Chat.tsx b/frontend/src/pages/Chat.tsx index 601151d0d..380449ab9 100644 --- a/frontend/src/pages/Chat.tsx +++ b/frontend/src/pages/Chat.tsx @@ -9,6 +9,7 @@ import { IconPaperclip, IconSend } from '@tabler/icons-react'; import { formatFileSize } from '../utils/formatFileSize'; import { useAuthStore } from '../stores'; import { useDropZone } from '../hooks/useDropZone'; +import { useToast } from '../components/Toast/ToastProvider'; /* ── Inline SVG Icons ── */ const Icons = { @@ -262,6 +263,7 @@ function ChatToolChain({ toolCalls }: { toolCalls: ToolCall[] }) { export default function Chat() { const { t } = useTranslation(); + const toast = useToast(); const { id } = useParams<{ id: string }>(); const token = useAuthStore((s) => s.token); const [messages, setMessages] = useState([]); @@ -646,7 +648,7 @@ export default function Chat() { }); } catch (err: any) { if (err?.message !== 'Upload cancelled') { - alert(t('agent.upload.failed') + (err?.message ? `: ${err.message}` : '')); + toast.error(t('agent.upload.failed'), { details: String(err?.message || '') }); } } finally { if (previewUrl) URL.revokeObjectURL(previewUrl); @@ -750,7 +752,7 @@ export default function Chat() { }); } catch (err: any) { if (err?.message !== 'Upload cancelled') { - alert(t('agent.upload.failed') + (err?.message ? `: ${err.message}` : '')); + toast.error(t('agent.upload.failed'), { details: String(err?.message || '') }); } } finally { if (previewUrl) URL.revokeObjectURL(previewUrl); diff --git a/frontend/src/pages/EnterpriseSettings.tsx b/frontend/src/pages/EnterpriseSettings.tsx index df78ce6a9..0342ead09 100644 --- a/frontend/src/pages/EnterpriseSettings.tsx +++ b/frontend/src/pages/EnterpriseSettings.tsx @@ -10,6 +10,8 @@ import { saveAccentColor, getSavedAccentColor, resetAccentColor, PRESET_COLORS } import UserManagement from './UserManagement'; import InvitationCodes from './InvitationCodes'; import LinearCopyButton from '../components/LinearCopyButton'; +import { useDialog } from '../components/Dialog/DialogProvider'; +import { useToast } from '../components/Toast/ToastProvider'; // API helpers for enterprise endpoints async function fetchJson(url: string, options?: RequestInit): Promise { const token = localStorage.getItem('token'); @@ -131,6 +133,8 @@ function SsoChannelSection({ idpType, existingProvider, tenant, t }: { idpType: string; existingProvider: any; tenant: any; t: any; }) { const qc = useQueryClient(); + const dialog = useDialog(); + const toast = useToast(); const [liveDomain, setLiveDomain] = useState(existingProvider?.sso_domain || tenant?.sso_domain || ''); const [ssoError, setSsoError] = useState(''); const [toggling, setToggling] = useState(false); @@ -145,7 +149,7 @@ function SsoChannelSection({ idpType, existingProvider, tenant, t }: { const handleSsoToggle = async () => { if (!existingProvider) { - alert(t('enterprise.identity.saveFirst', 'Please save the configuration first to enable SSO.')); + toast.warning(t('enterprise.identity.saveFirst', 'Please save the configuration first to enable SSO.')); return; } const newVal = !ssoEnabled; @@ -743,7 +747,7 @@ function OrgTab({ tenant }: { tenant: any }) { Saved )} {existingProvider && ( - )} @@ -1659,6 +1663,7 @@ function CompanyTimezoneEditor() { // ── Broadcast Section ────────────────────────── function BroadcastSection() { const { t } = useTranslation(); + const toast = useToast(); const [title, setTitle] = useState(''); const [body, setBody] = useState(''); const [sendEmail, setSendEmail] = useState(false); @@ -1678,7 +1683,7 @@ function BroadcastSection() { }); if (!res.ok) { const err = await res.json().catch(() => ({})); - alert(err.detail || 'Failed to send broadcast'); + toast.error('广播发送失败', { details: String(err.detail || `HTTP ${res.status}`) }); setSending(false); return; } @@ -1692,7 +1697,7 @@ function BroadcastSection() { setBody(''); setSendEmail(false); } catch (e: any) { - alert(e.message || 'Failed'); + toast.error('广播发送失败', { details: String(e?.message || e) }); } setSending(false); }; @@ -1753,6 +1758,8 @@ function BroadcastSection() { export default function EnterpriseSettings() { const { t } = useTranslation(); + const dialog = useDialog(); + const toast = useToast(); const qc = useQueryClient(); const [activeTab, setActiveTab] = useState<'llm' | 'org' | 'info' | 'approvals' | 'audit' | 'tools' | 'skills' | 'quotas' | 'users' | 'invites'>('info'); @@ -1789,7 +1796,7 @@ export default function EnterpriseSettings() { try { await fetchJson('/enterprise/tenant-quotas', { method: 'PATCH', body: JSON.stringify(quotaForm) }); setQuotaSaved(true); setTimeout(() => setQuotaSaved(false), 2000); - } catch (e) { alert('Failed to save'); } + } catch (e: any) { toast.error('保存失败', { details: String(e?.message || e) }); } setQuotaSaving(false); }; const [companyIntro, setCompanyIntro] = useState(''); @@ -1980,8 +1987,8 @@ export default function EnterpriseSettings() { if (res.status === 409) { const data = await res.json(); const agents = data.detail?.agents || []; - const msg = `This model is used by ${agents.length} agent(s):\n\n${agents.join(', ')}\n\nDelete anyway? (their model config will be cleared)`; - if (confirm(msg)) { + const msg = `该模型正在被 ${agents.length} 个数字员工使用:\n\n${agents.join(', ')}\n\n仍要删除吗?(对应的模型配置会被清空)`; + if (await dialog.confirm(msg, { title: '删除模型', danger: true, confirmLabel: '强制删除' })) { // Retry with force const r2 = await fetch(`/api/enterprise/llm-models/${id}?force=true`, { method: 'DELETE', @@ -2155,11 +2162,11 @@ export default function EnterpriseSettings() { if (btn) { btn.textContent = t('enterprise.llm.testSuccess', { latency: result.latency_ms }); btn.style.color = 'var(--success)'; } setTimeout(() => { if (btn) { btn.textContent = origText; btn.style.color = ''; } }, 3000); } else { - alert(t('enterprise.llm.testFailed', { error: result.error || 'Unknown error', latency: result.latency_ms })); + await dialog.alert(t('enterprise.llm.testFailedShort', '连通性测试失败'), { type: 'error', title: t('enterprise.llm.testTitle', '连通性测试'), details: String(result.error || 'Unknown error') }); if (btn) btn.textContent = origText; } } catch (e: any) { - alert(t('enterprise.llm.testError', { message: e.message })); + await dialog.alert(t('enterprise.llm.testErrorShort', '连通性测试出错'), { type: 'error', title: t('enterprise.llm.testTitle', '连通性测试'), details: String(e?.message || e) }); if (btn) btn.textContent = origText; } }}>{t('enterprise.llm.test')} @@ -2265,11 +2272,11 @@ export default function EnterpriseSettings() { if (btn) { btn.textContent = t('enterprise.llm.testSuccess', { latency: result.latency_ms }); btn.style.color = 'var(--success)'; } setTimeout(() => { if (btn) { btn.textContent = origText; btn.style.color = ''; } }, 3000); } else { - alert(t('enterprise.llm.testFailed', { error: result.error || 'Unknown error', latency: result.latency_ms })); + await dialog.alert(t('enterprise.llm.testFailedShort', '连通性测试失败'), { type: 'error', title: t('enterprise.llm.testTitle', '连通性测试'), details: String(result.error || 'Unknown error') }); if (btn) btn.textContent = origText; } } catch (e: any) { - alert(t('enterprise.llm.testError', { message: e.message })); + await dialog.alert(t('enterprise.llm.testErrorShort', '连通性测试出错'), { type: 'error', title: t('enterprise.llm.testTitle', '连通性测试'), details: String(e?.message || e) }); if (btn) btn.textContent = origText; } }}>{t('enterprise.llm.test')} @@ -2509,7 +2516,7 @@ export default function EnterpriseSettings() { onChange={async (e) => { const wantEnable = e.target.checked; if (wantEnable) { - const confirmed = window.confirm( + const confirmed = await dialog.confirm( t('enterprise.a2aAsync.enableWarning', [ '⚠️ You are about to enable the A2A Async Communication feature (Beta).', @@ -2526,9 +2533,10 @@ export default function EnterpriseSettings() { '', 'Are you sure you want to enable this feature?' ].join('\n') - ) + ), + { title: '启用 A2A 异步通信(Beta)', confirmLabel: '启用' }, ); - if (!confirmed) return; + if (!confirmed) { e.target.checked = false; return; } } try { await fetchJson(`/tenants/${selectedTenantId}`, { @@ -2537,7 +2545,7 @@ export default function EnterpriseSettings() { }); qc.invalidateQueries({ queryKey: ['tenant', selectedTenantId] }); } catch (err: any) { - alert(err.message || 'Update failed'); + toast.error('更新失败', { details: String(err?.message || err) }); } }} style={{ opacity: 0, width: 0, height: 0 }} @@ -2581,8 +2589,11 @@ export default function EnterpriseSettings() { @@ -2920,7 +2932,9 @@ export default function EnterpriseSettings() { await loadAllTools(); setShowAddMCP(false); setMcpTestResult(null); setMcpForm({ server_url: '', server_name: '', api_key: '' }); setMcpRawInput(''); if (errors.length > 0) { - alert(`Imported ${successCount}/${tools.length} tools.\nFailed:\n${errors.join('\n')}`); + await dialog.alert(`已导入 ${successCount}/${tools.length} 个工具`, { type: 'warning', title: '部分导入失败', details: errors.join('\n') }); + } else if (successCount > 0) { + toast.success(`已导入 ${successCount} 个工具`); } }}>{t('enterprise.tools.importAll')} @@ -3027,7 +3041,8 @@ export default function EnterpriseSettings() {
@@ -3066,7 +3081,8 @@ export default function EnterpriseSettings() { )} @@ -3129,7 +3145,7 @@ export default function EnterpriseSettings() { await fetchJson('/tools/bulk', { method: 'PUT', body: JSON.stringify(payload) }); loadAllTools(); } catch (err: any) { - alert('Bulk update failed: ' + err.message); + toast.error('批量更新失败', { details: String(err?.message || err) }); } }} style={{ opacity: 0, width: 0, height: 0 }} /> @@ -3197,7 +3213,8 @@ export default function EnterpriseSettings() { {/* Delete (non-builtin only) */} {tool.type !== 'builtin' && ( diff --git a/frontend/src/pages/Layout.tsx b/frontend/src/pages/Layout.tsx index c47b3c4eb..fc7db13f4 100644 --- a/frontend/src/pages/Layout.tsx +++ b/frontend/src/pages/Layout.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useAuthStore } from '../stores'; import { agentApi, tenantApi, authApi } from '../services/api'; +import { useToast } from '../components/Toast/ToastProvider'; import { IconHome, @@ -233,6 +234,7 @@ function VersionDisplay() { export default function Layout() { const { t, i18n } = useTranslation(); + const toast = useToast(); const navigate = useNavigate(); const { user, logout, setAuth } = useAuthStore(); const queryClient = useQueryClient(); @@ -308,7 +310,7 @@ export default function Layout() { }); if (!res.ok) { const err = await res.json().catch(() => ({ detail: 'Failed to switch tenant' })); - alert(err.detail || 'Failed to switch tenant'); + toast.error('切换公司失败', { details: String(err.detail || `HTTP ${res.status}`) }); return; } const data = await res.json(); diff --git a/frontend/src/pages/UserManagement.tsx b/frontend/src/pages/UserManagement.tsx index b40074a4a..5a1bd8aa9 100644 --- a/frontend/src/pages/UserManagement.tsx +++ b/frontend/src/pages/UserManagement.tsx @@ -5,6 +5,7 @@ import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useAuthStore } from '../stores'; import LinearCopyButton from '../components/LinearCopyButton'; +import { useDialog } from '../components/Dialog/DialogProvider'; interface UserInfo { id: string; @@ -49,6 +50,7 @@ export default function UserManagement() { const { t, i18n } = useTranslation(); const isChinese = i18n.language?.startsWith('zh'); const { user: currentUser, setUser } = useAuthStore(); + const dialog = useDialog(); const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); @@ -315,12 +317,13 @@ export default function UserManagement() { className="form-input" value={user.role} disabled={changingRoleUserId === user.id} - onChange={e => { + onChange={async e => { const newRole = e.target.value; const confirmMsg = isChinese ? `确认将 ${user.display_name || user.username} 的角色更改为 ${newRole === 'org_admin' ? 'Admin' : 'Member'}?` : `Change ${user.display_name || user.username}'s role to ${newRole === 'org_admin' ? 'Admin' : 'Member'}?`; - if (confirm(confirmMsg)) handleRoleChange(user.id, newRole); + const ok = await dialog.confirm(confirmMsg, { title: isChinese ? '更改角色' : 'Change role' }); + if (ok) handleRoleChange(user.id, newRole); }} style={{ fontSize: '11px', padding: '2px 4px', width: '100%', minWidth: 0 }} > diff --git a/frontend/src/pages/VerifyEmail.tsx b/frontend/src/pages/VerifyEmail.tsx index 88f8c8d42..cca1dd157 100644 --- a/frontend/src/pages/VerifyEmail.tsx +++ b/frontend/src/pages/VerifyEmail.tsx @@ -3,9 +3,11 @@ import { Link, useSearchParams, useNavigate, useLocation } from 'react-router-do import { useTranslation } from 'react-i18next'; import { authApi } from '../services/api'; import { useAuthStore } from '../stores'; +import { useToast } from '../components/Toast/ToastProvider'; export default function VerifyEmail() { const { t, i18n } = useTranslation(); + const toast = useToast(); const [searchParams] = useSearchParams(); const navigate = useNavigate(); const location = useLocation(); @@ -79,9 +81,9 @@ export default function VerifyEmail() { setLoading(true); try { await authApi.resendVerification(email); - alert(isChinese ? '验证码已重发,请检查您的邮箱。' : 'Verification code resent. Please check your email.'); + toast.success(isChinese ? '验证码已重发,请检查您的邮箱' : 'Verification code resent. Please check your email.'); } catch (err: any) { - alert(err.message || 'Failed to resend verification'); + toast.error(isChinese ? '重发失败' : 'Failed to resend verification', { details: String(err?.message || err) }); } finally { setLoading(false); }