feat(ui): theme-adapt LogPanel/ProgressBar and remove stale deps

Replace hardcoded slate-* colors with shadcn theme tokens (bg-card,
text-foreground, text-muted-foreground) in LogPanel and
SegmentedProgressBar. Remove react-focus-lock and useDialogFocus hook
no longer needed after Modal migration to Radix Dialog.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Misaka
2026-04-01 20:56:52 +08:00
parent ca7ec4ab31
commit cb438a34db
5 changed files with 25 additions and 401 deletions

61
package-lock.json generated
View File

@@ -30,7 +30,6 @@
"mysql2": "^3.18.2",
"playwright": "^1.58.2",
"playwright-core": "^1.58.2",
"react-focus-lock": "^2.13.7",
"react-markdown": "^10.1.0",
"recharts": "^3.8.0",
"reflect-metadata": "^0.2.2",
@@ -1451,15 +1450,6 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -9578,18 +9568,6 @@
"integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==",
"license": "MIT"
},
"node_modules/focus-lock": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/focus-lock/-/focus-lock-1.3.6.tgz",
"integrity": "sha512-Ik/6OCk9RQQ0T5Xw+hKNLWrjSMtv51dD4GRmJjbD5a58TIEpI5a5iXagKVl3Z5UuyslMCA8Xwnu76jQob62Yhg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.3"
},
"engines": {
"node": ">=10"
}
},
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -11226,6 +11204,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@@ -11962,6 +11941,7 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
@@ -13524,6 +13504,7 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -14192,6 +14173,7 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
@@ -14263,18 +14245,6 @@
"node": ">=0.10.0"
}
},
"node_modules/react-clientside-effect": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/react-clientside-effect/-/react-clientside-effect-1.2.8.tgz",
"integrity": "sha512-ma2FePH0z3px2+WOu6h+YycZcEvFmmxIlAb62cF52bG86eMySciO/EQZeQMXd07kPCYB0a1dWDT5J+KE9mCDUw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.13"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/react-dom": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
@@ -14288,29 +14258,6 @@
"react": "^19.2.4"
}
},
"node_modules/react-focus-lock": {
"version": "2.13.7",
"resolved": "https://registry.npmjs.org/react-focus-lock/-/react-focus-lock-2.13.7.tgz",
"integrity": "sha512-20lpZHEQrXPb+pp1tzd4ULL6DyO5D2KnR0G69tTDdydrmNhU7pdFmbQUYVyHUgp+xN29IuFR0PVuhOmvaZL9Og==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.0.0",
"focus-lock": "^1.3.6",
"prop-types": "^15.6.2",
"react-clientside-effect": "^1.2.7",
"use-callback-ref": "^1.3.3",
"use-sidecar": "^1.1.3"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",

View File

@@ -54,7 +54,6 @@
"mysql2": "^3.18.2",
"playwright": "^1.58.2",
"playwright-core": "^1.58.2",
"react-focus-lock": "^2.13.7",
"react-markdown": "^10.1.0",
"recharts": "^3.8.0",
"reflect-metadata": "^0.2.2",

View File

@@ -10,15 +10,15 @@ interface LogPanelProps {
const getLogColor = (level: LogLevel): string => {
switch (level) {
case 'error':
return 'text-red-400'
return 'text-red-500 dark:text-red-400'
case 'warning':
return 'text-amber-400'
return 'text-amber-500 dark:text-amber-400'
case 'success':
return 'text-emerald-400'
return 'text-emerald-500 dark:text-emerald-400'
case 'system':
return 'text-blue-400'
return 'text-blue-500 dark:text-blue-400'
default:
return 'text-slate-400'
return 'text-muted-foreground'
}
}
@@ -30,14 +30,14 @@ const LogPanel: React.FC<LogPanelProps> = ({ logs, onClear }) => {
}, [logs])
return (
<div className="bg-slate-900 rounded-xl shadow-lg border border-slate-700 overflow-hidden flex flex-col min-h-[300px] flex-1">
<div className="bg-slate-800 px-4 py-2 flex items-center justify-between border-b border-slate-700 flex-shrink-0">
<div className="flex items-center gap-2 text-slate-400 text-sm">
<div className="bg-card rounded-xl shadow-lg border border-border overflow-hidden flex flex-col min-h-[300px] flex-1">
<div className="bg-muted px-4 py-2 flex items-center justify-between border-b border-border flex-shrink-0">
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<Terminal size={16} />
<span></span>
</div>
<button
className="text-xs text-slate-400 hover:text-white transition-colors"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={onClear}
>
@@ -45,12 +45,12 @@ const LogPanel: React.FC<LogPanelProps> = ({ logs, onClear }) => {
</div>
<div className="flex-1 p-4 font-mono text-sm overflow-y-auto leading-relaxed">
{logs.length === 0 ? (
<div className="text-slate-500 text-center py-8">...</div>
<div className="text-muted-foreground text-center py-8">...</div>
) : (
logs.map((log, index) => (
<div key={index} className={getLogColor(log.level)}>
<span className="text-slate-600">[{log.timestamp}]</span>{' '}
<span className="text-slate-500">[{log.level.toUpperCase()}]</span> {log.message}
<span className="text-muted-foreground/60">[{log.timestamp}]</span>{' '}
<span className="text-muted-foreground">[{log.level.toUpperCase()}]</span> {log.message}
</div>
))
)}

View File

@@ -60,13 +60,13 @@ export const SegmentedProgressBar: React.FC<SegmentedProgressBarProps> = ({
const getStatusDot = (status: string) => {
if (status === 'completed') return 'bg-emerald-600'
if (status === 'active') return 'bg-blue-600 animate-pulse'
return 'bg-slate-300'
return 'bg-muted-foreground/40'
}
const getStatusText = (status: string) => {
if (status === 'completed') return 'text-emerald-600'
if (status === 'active') return 'text-blue-600 font-semibold'
return 'text-slate-400'
return 'text-muted-foreground'
}
const getDetailText = () => {
@@ -121,7 +121,7 @@ export const SegmentedProgressBar: React.FC<SegmentedProgressBarProps> = ({
const segments = getSegments()
return (
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-5">
<div className="bg-card rounded-xl shadow-sm border border-border p-5">
{/* 阶段标签 */}
<div className="flex justify-between mb-3">
{PHASES.map((p, index) => {
@@ -139,7 +139,7 @@ export const SegmentedProgressBar: React.FC<SegmentedProgressBarProps> = ({
</div>
{/* 分段进度条 */}
<div className="relative h-3 bg-slate-100 rounded-full overflow-hidden mb-3">
<div className="relative h-3 bg-muted rounded-full overflow-hidden mb-3">
{segments.map((segment, index) => {
const prevEnd = index === 0 ? 0 : segments[index - 1].end
const segmentWidth = segment.end - prevEnd
@@ -161,7 +161,7 @@ export const SegmentedProgressBar: React.FC<SegmentedProgressBarProps> = ({
}}
/>
{index < PHASES.length - 1 && (
<div className="absolute right-0 top-0 h-full w-px bg-white/50" />
<div className="absolute right-0 top-0 h-full w-px bg-background/50" />
)}
</div>
)
@@ -170,11 +170,11 @@ export const SegmentedProgressBar: React.FC<SegmentedProgressBarProps> = ({
{/* 详细信息 */}
<div className="flex justify-between items-center">
<div className="text-sm text-slate-600">
<span className="text-slate-500"></span>
<span className="text-slate-800">{getDetailText()}</span>
<div className="text-sm text-foreground">
<span className="text-muted-foreground"></span>
<span className="text-foreground">{getDetailText()}</span>
</div>
<div className="text-xl font-bold text-slate-800">{Math.round(progress)}%</div>
<div className="text-xl font-bold text-foreground">{Math.round(progress)}%</div>
</div>
</div>
)

View File

@@ -1,322 +0,0 @@
import { useEffect, RefObject } from 'react'
/**
* Options for configuring dialog focus management
*/
export interface UseDialogFocusOptions {
/** Whether the dialog is currently open */
isOpen: boolean
/** Ref to the dialog container element */
dialogRef: RefObject<HTMLElement | null>
/** Callback to close the dialog */
onClose: () => void
/** Optional ref to the element that triggered opening the dialog */
triggerRef?: RefObject<HTMLElement | null>
/** Optional selector for the element to focus initially inside the dialog */
initialFocusSelector?: string
/** Whether to lock body scroll when dialog is open (default: true) */
lockBodyScroll?: boolean
/**
* Whether Escape key should close the dialog (default: true)
* Can be a boolean or a function that receives the keyboard event and returns a boolean
*/
shouldCloseOnEscape?: boolean | ((event: KeyboardEvent) => boolean)
}
/**
* Return type for useDialogFocus hook
*/
export interface UseDialogFocusReturn {
/** Whether focus lock should be enabled */
focusLockEnabled: boolean
/** Props to spread on FocusLock component */
focusLockProps: {
disabled: boolean
}
}
/**
* React Hook for managing focus in modal dialogs
*
* Integrates with react-focus-lock to provide:
* - Focus trapping within dialog
* - Initial focus management (first interactive element or custom selector)
* - Focus restoration to trigger element on close
* - Escape key handling
* - Body scroll locking
*
* @param options - Configuration options for focus management
* @returns Focus lock props and state
*
* @example
* ```typescript
* function MyDialog({ isOpen, onClose, isExecuting }) {
* const dialogRef = useRef<HTMLDivElement>(null)
* const triggerRef = useRef<HTMLButtonElement>(null)
* const { focusLockEnabled, focusLockProps } = useDialogFocus({
* isOpen,
* dialogRef,
* onClose,
* triggerRef,
* shouldCloseOnEscape: () => !isExecuting // Only close when not executing
* })
*
* return (
* <FocusLock {...focusLockProps}>
* <div ref={dialogRef} role="dialog" aria-modal="true">
* <h2>Dialog Title</h2>
* <button onClick={onClose}>Close</button>
* </div>
* </FocusLock>
* )
* }
* ```
*/
export function useDialogFocus(options: UseDialogFocusOptions): UseDialogFocusReturn {
const {
isOpen,
dialogRef,
onClose,
triggerRef,
initialFocusSelector,
lockBodyScroll = true,
shouldCloseOnEscape = true
} = options
// Handle Escape key press
useEffect(() => {
if (!isOpen) return
const handleKeyDown = (event: KeyboardEvent): void => {
if (event.key === 'Escape' || event.keyCode === 27) {
// Check if we should close on escape
let shouldClose = true
if (shouldCloseOnEscape !== undefined) {
if (typeof shouldCloseOnEscape === 'function') {
shouldClose = shouldCloseOnEscape(event)
} else {
shouldClose = shouldCloseOnEscape
}
}
if (shouldClose) {
event.preventDefault()
event.stopPropagation()
onClose()
}
}
}
window.addEventListener('keydown', handleKeyDown)
return (): void => {
window.removeEventListener('keydown', handleKeyDown)
}
}, [isOpen, onClose, shouldCloseOnEscape])
// Manage body scroll locking
useEffect(() => {
if (!lockBodyScroll) return
if (isOpen) {
// Store current scroll position
const scrollY = window.scrollY
document.body.style.position = 'fixed'
document.body.style.top = `-${scrollY}px`
document.body.style.left = '0'
document.body.style.right = '0'
document.body.style.overflow = 'hidden'
} else {
// Restore scroll position
const scrollY = document.body.style.top
document.body.style.position = ''
document.body.style.top = ''
document.body.style.left = ''
document.body.style.right = ''
document.body.style.overflow = ''
if (scrollY) {
window.scrollTo(0, parseInt(scrollY, 10) * -1)
}
}
return (): void => {
// Cleanup on unmount or when isOpen changes
if (isOpen) {
const scrollY = document.body.style.top
document.body.style.position = ''
document.body.style.top = ''
document.body.style.left = ''
document.body.style.right = ''
document.body.style.overflow = ''
if (scrollY) {
window.scrollTo(0, parseInt(scrollY, 10) * -1)
}
}
}
}, [isOpen, lockBodyScroll])
// Manage initial focus when dialog opens
useEffect(() => {
if (!isOpen || !dialogRef.current) return
const setupFocus = (): void => {
const dialogElement = dialogRef.current
if (!dialogElement) return
// If initialFocusSelector is provided, try to focus that element
if (initialFocusSelector) {
const focusElement = dialogElement.querySelector(initialFocusSelector) as HTMLElement
if (focusElement && typeof focusElement.focus === 'function') {
// Check if element is visible and focusable
const style = window.getComputedStyle(focusElement)
if (style.display !== 'none' && style.visibility !== 'hidden') {
requestAnimationFrame(() => {
focusElement.focus({ preventScroll: true })
})
return
}
// Fallback: element found but not visible, log warning and try default
console.warn(`Focus element found but not visible: ${initialFocusSelector}`)
} else {
// Fallback: element not found, log warning and try default
console.warn(`Focus element not found for selector: ${initialFocusSelector}`)
}
}
// Otherwise, focus the first interactive element
const focusableSelectors = [
'button:not([disabled]):not([tabindex="-1"])',
'a[href]',
'input:not([disabled]):not([tabindex="-1"])',
'select:not([disabled]):not([tabindex="-1"])',
'textarea:not([disabled]):not([tabindex="-1"])',
'[tabindex]:not([tabindex="-1"])'
]
const firstFocusable = dialogElement.querySelector(
focusableSelectors.join(', ')
) as HTMLElement
if (firstFocusable && typeof firstFocusable.focus === 'function') {
requestAnimationFrame(() => {
firstFocusable.focus({ preventScroll: true })
})
}
}
// Delay to ensure portal content is rendered
requestAnimationFrame(setupFocus)
}, [isOpen, dialogRef, initialFocusSelector])
// Restore focus to trigger element when dialog closes
useEffect(() => {
if (isOpen || !triggerRef?.current) return
const restoreFocus = (): void => {
const triggerElement = triggerRef.current
// Check if element still exists in DOM
if (!triggerElement || !document.contains(triggerElement)) {
if (import.meta.env.DEV) {
console.warn('[useDialogFocus] Trigger element not found in DOM, cannot restore focus')
}
return
}
// Check if element has a focus method
if (typeof triggerElement.focus !== 'function') {
if (import.meta.env.DEV) {
console.warn('[useDialogFocus] Trigger element does not have a focus method')
}
return
}
// Check if element is visible (not display: none)
const style = window.getComputedStyle(triggerElement)
if (style.display === 'none') {
if (import.meta.env.DEV) {
console.warn('[useDialogFocus] Trigger element is display: none, cannot restore focus')
}
return
}
if (style.visibility === 'hidden') {
if (import.meta.env.DEV) {
console.warn(
'[useDialogFocus] Trigger element is visibility: hidden, cannot restore focus'
)
}
return
}
// Check if element is disabled
if (triggerElement instanceof HTMLButtonElement && triggerElement.disabled) {
if (import.meta.env.DEV) {
console.warn('[useDialogFocus] Trigger element is disabled, cannot restore focus')
}
// Try to find nearest enabled ancestor or fallback to body
const focusableParent = findNearestFocusableElement(triggerElement)
if (focusableParent) {
focusableParent.focus({ preventScroll: true })
if (import.meta.env.DEV) {
console.info('[useDialogFocus] Restored focus to nearest focusable ancestor')
}
}
return
}
// All checks passed, restore focus
try {
triggerElement.focus({ preventScroll: true })
if (import.meta.env.DEV) {
console.info('[useDialogFocus] Successfully restored focus to trigger element')
}
} catch (error) {
if (import.meta.env.DEV) {
console.error('[useDialogFocus] Error restoring focus:', error)
}
}
}
// Helper function to find nearest focusable ancestor
const findNearestFocusableElement = (element: HTMLElement): HTMLElement | null => {
let parent = element.parentElement
const focusableSelectors = [
'button:not([disabled]):not([tabindex="-1"])',
'a[href]',
'input:not([disabled]):not([tabindex="-1"])',
'select:not([disabled]):not([tabindex="-1"])',
'textarea:not([disabled]):not([tabindex="-1"])',
'[tabindex]:not([tabindex="-1"])'
]
while (parent && parent !== document.body) {
// Check if parent itself is focusable
if (
focusableSelectors.some((selector) => parent?.matches(selector)) &&
window.getComputedStyle(parent).display !== 'none' &&
window.getComputedStyle(parent).visibility !== 'hidden'
) {
return parent
}
// Check if parent contains focusable element
const focusableChild = parent.querySelector(focusableSelectors.join(', ')) as HTMLElement
if (focusableChild) {
return focusableChild
}
parent = parent.parentElement
}
return null
}
// Use microtask queue to ensure this runs after DOM cleanup
queueMicrotask(restoreFocus)
}, [isOpen, triggerRef])
// Return focus lock configuration
return {
focusLockEnabled: isOpen,
focusLockProps: {
disabled: !isOpen
}
}
}
export default useDialogFocus