Refactor(UI) : Refactor Frontend: Unified Toasts with Sonner, Introduced Layout System, and Integrated React Router (#872)

This commit is contained in:
Ember
2025-11-11 12:19:17 +08:00
committed by GitHub
parent ddc4cdeb60
commit 3bf69b758b
31 changed files with 4359 additions and 3175 deletions

30
web/src/lib/clipboard.ts Normal file
View File

@@ -0,0 +1,30 @@
import { notify } from './notify'
/**
* 复制文本到剪贴板,并显示轻量提示。
*/
export async function copyWithToast(text: string, successMsg = '已复制') {
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
} else {
// 兼容降级:创建临时文本域执行复制
const el = document.createElement('textarea')
el.value = text
el.style.position = 'fixed'
el.style.left = '-9999px'
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
}
notify.success(successMsg)
return true
} catch (err) {
console.error('Clipboard copy failed:', err)
notify.error('复制失败')
return false
}
}
export default { copyWithToast }

View File

@@ -8,6 +8,8 @@
* - Automatic redirect to login page
*/
import { toast } from 'sonner'
export class HttpClient {
// Singleton flag to prevent duplicate 401 handling
private static isHandling401 = false
@@ -23,52 +25,7 @@ export class HttpClient {
* Show login required notification to user
*/
private showLoginRequiredNotification(): void {
// Create notification element
const notification = document.createElement('div')
notification.style.cssText = `
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: linear-gradient(135deg, #F0B90B 0%, #FCD535 100%);
color: #0B0E11;
padding: 16px 24px;
border-radius: 8px;
font-size: 16px;
font-weight: bold;
z-index: 10000;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
animation: slideDown 0.3s ease-out;
`
notification.textContent = '⚠️ 登录已过期,请先登录'
// Add slide down animation
const style = document.createElement('style')
style.textContent = `
@keyframes slideDown {
from {
opacity: 0;
transform: translateX(-50%) translateY(-20px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
`
document.head.appendChild(style)
// Add to page
document.body.appendChild(notification)
// Auto remove after animation
setTimeout(() => {
notification.style.animation = 'slideDown 0.3s ease-out reverse'
setTimeout(() => {
document.body.removeChild(notification)
document.head.removeChild(style)
}, 300)
}, 1800)
toast.warning('登录已过期,请先登录', { duration: 1800 })
}
/**

87
web/src/lib/notify.tsx Normal file
View File

@@ -0,0 +1,87 @@
import { toast } from 'sonner'
import type { ReactNode } from 'react'
export interface ConfirmOptions {
title?: string
message?: string
okText?: string
cancelText?: string
}
// 全局 confirm 函数的引用,将在 ConfirmDialogProvider 中设置
let globalConfirm:
| ((options: ConfirmOptions & { message: string }) => Promise<boolean>)
| null = null
export function setGlobalConfirm(
confirmFn: (options: ConfirmOptions & { message: string }) => Promise<boolean>
) {
globalConfirm = confirmFn
}
// 确认对话框函数,使用 shadcn AlertDialog
export function confirmToast(
message: string,
options: ConfirmOptions = {}
): Promise<boolean> {
if (!globalConfirm) {
console.error('ConfirmDialogProvider not initialized')
return Promise.resolve(false)
}
return globalConfirm({
message,
...options,
})
}
// 统一通知封装,避免组件直接依赖 sonner
type Message = string | ReactNode
function message(msg: Message, options?: Parameters<typeof toast>[1]) {
return toast(msg as any, options)
}
function success(msg: Message, options?: Parameters<typeof toast.success>[1]) {
return toast.success(msg as any, options)
}
function error(msg: Message, options?: Parameters<typeof toast.error>[1]) {
return toast.error(msg as any, options)
}
function info(msg: Message, options?: Parameters<typeof toast.info>[1]) {
return toast.info?.(msg as any, options) ?? toast(msg as any, options)
}
function warning(msg: Message, options?: Parameters<typeof toast.warning>[1]) {
return toast.warning?.(msg as any, options) ?? toast(msg as any, options)
}
function custom(
renderer: Parameters<typeof toast.custom>[0],
options?: Parameters<typeof toast.custom>[1]
) {
return toast.custom(renderer, options)
}
function dismiss(id?: string | number) {
return toast.dismiss(id as any)
}
function promise<T>(p: Promise<T> | (() => Promise<T>), msgs: any) {
return toast.promise<T>(p as any, msgs as any)
}
export const notify = {
message,
success,
error,
info,
warning,
custom,
dismiss,
promise,
}
export default { confirmToast, notify }

28
web/src/lib/text.ts Normal file
View File

@@ -0,0 +1,28 @@
/**
* 文本工具
*
* stripLeadingIcons: 去掉翻译文案或标题前面用于装饰的 Emoji/符号,
* 以便在组件里自行放置图标时不重复显示。
*/
/**
* 去掉开头的装饰性 Emoji/符号以及随后的分隔符(空格/冒号/点号等)。
*/
export function stripLeadingIcons(input: string | undefined | null): string {
if (!input) return ''
let s = String(input)
// 1) 去除常见的 Emoji/符号块(箭头、杂项符号、几何图形、表情等)
// 覆盖常见范围,兼容性好于使用 Unicode 属性类。
s = s.replace(
/^[\s\u2190-\u21FF\u2300-\u23FF\u2460-\u24FF\u25A0-\u25FF\u2600-\u27BF\u2B00-\u2BFF\u1F000-\u1FAFF]+/u,
''
)
// 2) 去掉开头可能残留的分隔符(空格、连字符、冒号、居中点等)
s = s.replace(/^[\s\-:•·]+/, '')
return s.trim()
}
export default { stripLeadingIcons }