feat(ui): migrate more components to shadcn/ui

Continue migrating components to use shadcn/ui for better consistency
and dark mode support.

## Components Migrated

### ConfirmDialog
- Replace Modal with shadcn AlertDialog component
- Use AlertDialogAction and AlertDialogCancel with proper asChild prop
- Update icon colors for dark mode support

### UpdateDialog
- Replace buttons with shadcn Button component
- Use shadcn Card for version info display
- Add shadcn ScrollArea for changelog view
- Update styling with shadcn color tokens

### PlaywrightDownloadDialog
- Replace progress bar with shadcn Progress component
- Use shadcn Card for stats display
- Update colors for dark mode support
- Use shadcn Button throughout

### CleanerSidebar
- Replace card divs with shadcn Card component
- Add shadcn ScrollArea for manager list
- Update radio button styling with shadcn tokens
- Improve dark mode support

### CleanerToolbar
- Replace all buttons with shadcn Button component
- Use shadcn Separator for visual separation
- Update toolbar styling with shadcn tokens

### Button Component Enhancement
- Add React.forwardRef for ref forwarding support
- Enables proper ref usage with parent components

## Improvements
- Full dark mode support across all migrated components
- Consistent styling using shadcn design tokens
- Better accessibility with semantic components
- Ref forwarding support for Button component

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Misaka_Company
2026-04-01 10:48:54 +08:00
parent 8ef08be649
commit c99907c0ff
6 changed files with 346 additions and 266 deletions

View File

@@ -1,6 +1,10 @@
import React, { useEffect, useState, useCallback } from 'react' import React, { useEffect, useState, useCallback } from 'react'
import { DownloadCloud, LoaderCircle, X } from 'lucide-react' import { DownloadCloud, LoaderCircle, X } from 'lucide-react'
import Modal from './ui/Modal' import Modal from './ui/Modal'
import { Button } from './ui/Button'
import { Progress } from './ui/shadcn/progress'
import { Card, CardContent } from './ui/shadcn/card'
interface DownloadProgress { interface DownloadProgress {
percent: number // 0-100 percent: number // 0-100
downloadedBytes: number downloadedBytes: number
@@ -137,23 +141,18 @@ export default function PlaywrightDownloadDialog({
{/* Progress Bar */} {/* Progress Bar */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-slate-600"></span> <span className="text-muted-foreground"></span>
<span className="font-semibold text-blue-600">{progress?.percent ?? 0}%</span> <span className="font-semibold text-primary">{progress?.percent ?? 0}%</span>
</div>
<div className="h-3 w-full overflow-hidden rounded-full bg-slate-200">
<div
className="h-full bg-gradient-to-r from-blue-500 to-blue-600 transition-all duration-300 ease-out"
style={{ width: `${progress?.percent ?? 0}%` }}
/>
</div> </div>
<Progress value={progress?.percent ?? 0} className="h-3" />
</div> </div>
{/* Current File */} {/* Current File */}
{progress?.currentFile && ( {progress?.currentFile && (
<div className="rounded-lg bg-slate-50 px-4 py-3"> <div className="rounded-lg bg-muted/50 px-4 py-3">
<div className="text-xs font-medium text-slate-500"></div> <div className="text-xs font-medium text-muted-foreground"></div>
<div <div
className="mt-1 truncate text-sm text-slate-700" className="mt-1 truncate text-sm text-foreground"
title={progress.currentFile} title={progress.currentFile}
> >
{progress.currentFile} {progress.currentFile}
@@ -163,54 +162,63 @@ export default function PlaywrightDownloadDialog({
{/* Stats Grid */} {/* Stats Grid */}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="rounded-lg border border-slate-200 px-4 py-3"> <Card>
<div className="text-xs text-slate-500"></div> <CardContent className="p-4">
<div className="mt-1 text-lg font-semibold text-slate-900"> <div className="text-xs text-muted-foreground"></div>
<div className="mt-1 text-lg font-semibold text-foreground">
{progress ? formatBytes(progress.downloadedBytes) : '0 B'} {progress ? formatBytes(progress.downloadedBytes) : '0 B'}
</div> </div>
</div> </CardContent>
<div className="rounded-lg border border-slate-200 px-4 py-3"> </Card>
<div className="text-xs text-slate-500"></div> <Card>
<div className="mt-1 text-lg font-semibold text-slate-900"> <CardContent className="p-4">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-1 text-lg font-semibold text-foreground">
{progress && progress.totalBytes > 0 {progress && progress.totalBytes > 0
? formatBytes(progress.totalBytes) ? formatBytes(progress.totalBytes)
: '计算中...'} : '计算中...'}
</div> </div>
</div> </CardContent>
<div className="rounded-lg border border-slate-200 px-4 py-3"> </Card>
<div className="text-xs text-slate-500"></div> <Card>
<div className="mt-1 text-lg font-semibold text-slate-900"> <CardContent className="p-4">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-1 text-lg font-semibold text-foreground">
{progress && progress.speed >= 0 {progress && progress.speed >= 0
? `${formatBytes(progress.speed)}/秒` ? `${formatBytes(progress.speed)}/秒`
: '计算中...'} : '计算中...'}
</div> </div>
</div> </CardContent>
<div className="rounded-lg border border-slate-200 px-4 py-3"> </Card>
<div className="text-xs text-slate-500"></div> <Card>
<div className="mt-1 text-lg font-semibold text-slate-900"> <CardContent className="p-4">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-1 text-lg font-semibold text-foreground">
{progress?.eta !== undefined && progress.eta >= 0 {progress?.eta !== undefined && progress.eta >= 0
? formatETA(progress.eta) ? formatETA(progress.eta)
: '计算中...'} : '计算中...'}
</div> </div>
</div> </CardContent>
</Card>
</div> </div>
{/* Cancel Button */} {/* Cancel Button */}
<div className="flex justify-center pt-2"> <div className="flex justify-center pt-2">
<button <Button
variant="danger"
onClick={handleCancelDownloadClick} onClick={handleCancelDownloadClick}
className="inline-flex items-center gap-2 rounded-md border border-rose-200 bg-rose-50 px-4 py-2 text-sm text-rose-700 hover:bg-rose-100" className="gap-2"
> >
<X size={16} /> <X size={16} />
</button> </Button>
</div> </div>
</div> </div>
)} )}
{/* Error Section */} {/* Error Section */}
{error && ( {error && (
<div className="rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700"> <div className="rounded-lg border border-destructive/20 bg-destructive/10 px-4 py-3 text-sm text-destructive dark:bg-destructive/20">
<div className="font-medium"></div> <div className="font-medium"></div>
<div className="mt-1">{error}</div> <div className="mt-1">{error}</div>
</div> </div>
@@ -218,7 +226,7 @@ export default function PlaywrightDownloadDialog({
{/* Success Section (shown briefly before closing) */} {/* Success Section (shown briefly before closing) */}
{!isDownloading && !error && progress?.percent === 100 && ( {!isDownloading && !error && progress?.percent === 100 && (
<div className="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700"> <div className="rounded-lg border border-green-200 bg-green-50 dark:border-green-800/30 dark:bg-green-900/20 px-4 py-3 text-sm text-green-700 dark:text-green-400">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<DownloadCloud size={18} /> <DownloadCloud size={18} />
<span className="font-medium"></span> <span className="font-medium"></span>
@@ -230,8 +238,8 @@ export default function PlaywrightDownloadDialog({
{/* Initial Loading State */} {/* Initial Loading State */}
{isDownloading && !progress && ( {isDownloading && !progress && (
<div className="flex flex-col items-center justify-center py-8"> <div className="flex flex-col items-center justify-center py-8">
<LoaderCircle size={48} className="animate-spin text-blue-500" /> <LoaderCircle size={48} className="animate-spin text-primary" />
<div className="mt-4 text-sm text-slate-600">...</div> <div className="mt-4 text-sm text-muted-foreground">...</div>
</div> </div>
)} )}
</div> </div>
@@ -246,24 +254,25 @@ export default function PlaywrightDownloadDialog({
isAlertDialog isAlertDialog
> >
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800"> <div className="rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-800/30 dark:bg-amber-900/20 px-4 py-3 text-sm text-amber-800 dark:text-amber-400">
<div className="font-medium"></div> <div className="font-medium"></div>
<div className="mt-1">退</div> <div className="mt-1">退</div>
</div> </div>
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<button <Button
variant="secondary"
onClick={handleCloseConfirm} onClick={handleCloseConfirm}
className="rounded-md border border-slate-200 px-4 py-2 text-sm text-slate-600 hover:bg-slate-50"
> >
</button> </Button>
<button <Button
variant="danger"
onClick={handleConfirmCancel} onClick={handleConfirmCancel}
className="inline-flex items-center gap-2 rounded-md bg-rose-600 px-4 py-2 text-sm font-medium text-white hover:bg-rose-700" className="gap-2"
> >
<X size={16} /> <X size={16} />
退 退
</button> </Button>
</div> </div>
</div> </div>
</Modal> </Modal>

View File

@@ -3,6 +3,9 @@ import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import { DownloadCloud, LoaderCircle, RefreshCw } from 'lucide-react' import { DownloadCloud, LoaderCircle, RefreshCw } from 'lucide-react'
import Modal from './ui/Modal' import Modal from './ui/Modal'
import { Button } from './ui/Button'
import { ScrollArea } from './ui/shadcn/scroll-area'
import { Card, CardContent, CardHeader, CardTitle } from './ui/shadcn/card'
import { useUpdateDialogState } from '../hooks/useUpdateDialogState' import { useUpdateDialogState } from '../hooks/useUpdateDialogState'
import type { UserType } from '../../../main/types/user.types' import type { UserType } from '../../../main/types/user.types'
import type { import type {
@@ -44,7 +47,7 @@ export default function UpdateDialog({
const renderReleaseList = (title: string, releases: UpdateRelease[]) => { const renderReleaseList = (title: string, releases: UpdateRelease[]) => {
if (releases.length === 0) { if (releases.length === 0) {
return ( return (
<div className="rounded-lg border border-dashed border-slate-200 px-3 py-4 text-sm text-slate-500"> <div className="rounded-lg border border-dashed border-border px-3 py-4 text-sm text-muted-foreground">
</div> </div>
) )
@@ -52,7 +55,7 @@ export default function UpdateDialog({
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="text-xs font-semibold uppercase tracking-wide text-slate-500">{title}</div> <div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{title}</div>
{releases.map((release) => { {releases.map((release) => {
const selected = isSelectedRelease(release) const selected = isSelectedRelease(release)
return ( return (
@@ -65,19 +68,19 @@ export default function UpdateDialog({
}} }}
className={`w-full rounded-lg border px-3 py-3 text-left transition ${ className={`w-full rounded-lg border px-3 py-3 text-left transition ${
selected selected
? 'border-blue-500 bg-blue-50 text-blue-900' ? 'border-primary bg-primary/10 text-primary'
: 'border-slate-200 bg-white text-slate-700 hover:border-slate-300' : 'border-border bg-background text-foreground hover:bg-accent'
}`} }`}
> >
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<span className="font-medium">V{release.version}</span> <span className="font-medium">V{release.version}</span>
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs uppercase text-slate-600"> <span className="rounded-full bg-muted px-2 py-0.5 text-xs uppercase text-muted-foreground">
{release.channel} {release.channel}
</span> </span>
</div> </div>
<div className="mt-1 text-xs text-slate-500">{release.publishedAt}</div> <div className="mt-1 text-xs text-muted-foreground">{release.publishedAt}</div>
{release.notesSummary && ( {release.notesSummary && (
<div className="mt-2 text-xs text-slate-600">{release.notesSummary}</div> <div className="mt-2 text-xs text-foreground/80">{release.notesSummary}</div>
)} )}
</button> </button>
) )
@@ -96,21 +99,23 @@ export default function UpdateDialog({
disableEscapeKey={isBusy} disableEscapeKey={isBusy}
> >
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between rounded-lg bg-slate-50 px-4 py-3 text-sm"> <div className="flex items-center justify-between rounded-lg bg-muted/50 px-4 py-3 text-sm">
<div className="text-slate-600"> <div className="text-muted-foreground">
<span className="font-semibold text-slate-900">V{status?.currentVersion}</span> <span className="font-semibold text-foreground">V{status?.currentVersion}</span>
<span className="ml-2 rounded-full bg-slate-200 px-2 py-0.5 text-xs uppercase text-slate-700"> <span className="ml-2 rounded-full bg-muted px-2 py-0.5 text-xs uppercase text-muted-foreground">
{status?.currentChannel ?? __APP_CHANNEL__} {status?.currentChannel ?? __APP_CHANNEL__}
</span> </span>
</div> </div>
<button <Button
variant="secondary"
size="sm"
onClick={() => void onRefreshCatalog()} onClick={() => void onRefreshCatalog()}
className="inline-flex items-center gap-2 rounded-md border border-slate-200 px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-100"
disabled={isBusy} disabled={isBusy}
className="gap-2"
> >
<RefreshCw size={14} /> <RefreshCw size={14} />
</button> </Button>
</div> </div>
{catalog?.mode === 'admin' ? ( {catalog?.mode === 'admin' ? (
@@ -119,104 +124,106 @@ export default function UpdateDialog({
{renderReleaseList('Stable', catalog.channels?.stable ?? [])} {renderReleaseList('Stable', catalog.channels?.stable ?? [])}
{renderReleaseList('Preview', catalog.channels?.preview ?? [])} {renderReleaseList('Preview', catalog.channels?.preview ?? [])}
</div> </div>
<div className="rounded-xl border border-slate-200 bg-white"> <Card>
<div className="border-b border-slate-200 px-4 py-3"> <CardHeader>
<div className="text-sm font-semibold text-slate-900"> <CardTitle className="text-sm">
{selectedRelease {selectedRelease
? `${selectedRelease.channel.toUpperCase()} V${selectedRelease.version}` ? `${selectedRelease.channel.toUpperCase()} V${selectedRelease.version}`
: '请选择一个版本'} : '请选择一个版本'}
</div> </CardTitle>
<div className="mt-1 text-xs text-slate-500"> <div className="mt-1 text-xs text-muted-foreground">
{selectedRelease?.notesSummary ?? '选择版本后可查看详细更新说明。'} {selectedRelease?.notesSummary ?? '选择版本后可查看详细更新说明。'}
</div> </div>
</div> </CardHeader>
<div className="max-h-[420px] overflow-auto px-4 py-4 prose prose-slate max-w-none prose-headings:mb-2 prose-p:my-2 prose-li:my-1"> <CardContent className="max-h-[420px]">
<ScrollArea className="h-[340px]">
{isLoadingChangelog ? ( {isLoadingChangelog ? (
<div className="flex items-center gap-2 text-sm text-slate-500"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<LoaderCircle size={16} className="animate-spin" /> <LoaderCircle size={16} className="animate-spin" />
... ...
</div> </div>
) : ( ) : (
<div className="prose prose-slate dark:prose-invert max-w-none prose-headings:mb-2 prose-p:my-2 prose-li:my-1">
<ReactMarkdown remarkPlugins={[remarkGfm]}> <ReactMarkdown remarkPlugins={[remarkGfm]}>
{changelog || '暂无更新说明。'} {changelog || '暂无更新说明。'}
</ReactMarkdown> </ReactMarkdown>
</div>
)} )}
</div> </ScrollArea>
</div> </CardContent>
</Card>
</div> </div>
) : ( ) : (
<div className="rounded-xl border border-slate-200 bg-white"> <Card>
<div className="border-b border-slate-200 px-4 py-3"> <CardHeader>
<div className="text-sm font-semibold text-slate-900"> <CardTitle className="text-sm">
{selectedRelease ? `发现稳定版 V${selectedRelease.version}` : '暂无可用更新'} {selectedRelease ? `发现稳定版 V${selectedRelease.version}` : '暂无可用更新'}
</div> </CardTitle>
<div className="mt-1 text-xs text-slate-500"> <div className="mt-1 text-xs text-muted-foreground">
{status?.currentChannel === 'preview' {status?.currentChannel === 'preview'
? '当前设备正在运行预览版,普通用户将更新回稳定版。' ? '当前设备正在运行预览版,普通用户将更新回稳定版。'
: '更新包已在后台准备完成,确认后将自动关闭并重启应用。'} : '更新包已在后台准备完成,确认后将自动关闭并重启应用。'}
</div> </div>
</div> </CardHeader>
<div className="max-h-[420px] overflow-auto px-4 py-4 prose prose-slate max-w-none prose-headings:mb-2 prose-p:my-2 prose-li:my-1"> <CardContent className="max-h-[420px]">
<ScrollArea className="h-[340px]">
{isLoadingChangelog ? ( {isLoadingChangelog ? (
<div className="flex items-center gap-2 text-sm text-slate-500"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<LoaderCircle size={16} className="animate-spin" /> <LoaderCircle size={16} className="animate-spin" />
... ...
</div> </div>
) : ( ) : (
<div className="prose prose-slate dark:prose-invert max-w-none prose-headings:mb-2 prose-p:my-2 prose-li:my-1">
<ReactMarkdown remarkPlugins={[remarkGfm]}> <ReactMarkdown remarkPlugins={[remarkGfm]}>
{changelog || '暂无更新说明。'} {changelog || '暂无更新说明。'}
</ReactMarkdown> </ReactMarkdown>
</div>
)} )}
</div> </ScrollArea>
</div> </CardContent>
</Card>
)} )}
{status?.error && ( {status?.error && (
<div className="rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700"> <div className="rounded-lg border border-destructive/20 bg-destructive/10 px-4 py-3 text-sm text-destructive dark:bg-destructive/20">
{status.error} {status.error}
</div> </div>
)} )}
<div className="flex items-center justify-between border-t border-slate-200 pt-4"> <div className="flex items-center justify-between border-t border-border pt-4">
<div className="text-sm text-slate-500">{status?.message ?? ' '}</div> <div className="text-sm text-muted-foreground">{status?.message ?? ' '}</div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <Button
variant="secondary"
onClick={onClose} onClick={onClose}
className="rounded-md border border-slate-200 px-4 py-2 text-sm text-slate-600 hover:bg-slate-50"
disabled={isBusy} disabled={isBusy}
> >
</button> </Button>
{userType === 'Admin' ? ( {userType === 'Admin' ? (
<button <Button
onClick={() => onClick={() =>
selectedRelease selectedRelease
? void onDownloadAndInstallAdminRelease(selectedRelease) ? void onDownloadAndInstallAdminRelease(selectedRelease)
: undefined : undefined
} }
className="inline-flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-blue-300"
disabled={!selectedRelease || isBusy} disabled={!selectedRelease || isBusy}
loading={isBusy}
className="gap-2"
> >
{isBusy ? ( {isBusy ? null : <DownloadCloud size={16} />}
<LoaderCircle size={16} className="animate-spin" />
) : (
<DownloadCloud size={16} />
)}
</button> </Button>
) : ( ) : (
<button <Button
onClick={() => void onInstallUserRelease()} onClick={() => void onInstallUserRelease()}
className="inline-flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-blue-300"
disabled={!selectedRelease || isBusy} disabled={!selectedRelease || isBusy}
loading={isBusy}
className="gap-2"
> >
{isBusy ? ( {isBusy ? null : <DownloadCloud size={16} />}
<LoaderCircle size={16} className="animate-spin" />
) : (
<DownloadCloud size={16} />
)}
</button> </Button>
)} )}
</div> </div>
</div> </div>

View File

@@ -1,5 +1,8 @@
import React from 'react' import React from 'react'
import { DatabaseZap, Layers, Users } from 'lucide-react' import { DatabaseZap, Layers, Users } from 'lucide-react'
import { Card } from '../ui/shadcn/card'
import { ScrollArea } from '../ui/shadcn/scroll-area'
import { cn } from '@renderer/lib/utils'
interface CleanerSidebarProps { interface CleanerSidebarProps {
valMode: 'full' | 'filtered' valMode: 'full' | 'filtered'
@@ -18,15 +21,20 @@ export function CleanerSidebar({
}: CleanerSidebarProps): React.JSX.Element { }: CleanerSidebarProps): React.JSX.Element {
return ( return (
<div className="w-full xl:w-[380px] flex-shrink-0 flex flex-col gap-5 xl:self-start overflow-auto"> <div className="w-full xl:w-[380px] flex-shrink-0 flex flex-col gap-5 xl:self-start overflow-auto">
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-5"> <Card className="p-5">
<h3 className="text-base font-semibold mb-4 flex items-center gap-2 text-slate-800"> <h3 className="text-base font-semibold mb-4 flex items-center gap-2">
<DatabaseZap size={18} className="text-blue-500" /> <DatabaseZap size={18} className="text-blue-500" />
</h3> </h3>
<div className="space-y-3"> <div className="space-y-3">
<label <label
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${valMode === 'full' ? 'bg-blue-50 border-blue-200' : 'hover:bg-slate-50 border-slate-200'}`} className={cn(
"flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors",
valMode === 'full'
? 'bg-primary/10 border-primary'
: 'hover:bg-muted/50 border-border'
)}
> >
<input <input
type="radio" type="radio"
@@ -36,13 +44,18 @@ export function CleanerSidebar({
onChange={() => setValMode('full')} onChange={() => setValMode('full')}
/> />
<div> <div>
<div className="text-sm font-medium text-slate-800"> - </div> <div className="text-sm font-medium"> - </div>
<div className="text-xs text-slate-500 mt-0.5"></div> <div className="text-xs text-muted-foreground mt-0.5"></div>
</div> </div>
</label> </label>
<label <label
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${valMode === 'filtered' ? 'bg-blue-50 border-blue-200' : 'hover:bg-slate-50 border-slate-200'}`} className={cn(
"flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors",
valMode === 'filtered'
? 'bg-primary/10 border-primary'
: 'hover:bg-muted/50 border-border'
)}
> >
<input <input
type="radio" type="radio"
@@ -52,10 +65,10 @@ export function CleanerSidebar({
onChange={() => setValMode('filtered')} onChange={() => setValMode('filtered')}
/> />
<div className="w-full"> <div className="w-full">
<div className="text-sm font-medium text-slate-800"> - ProductionID </div> <div className="text-sm font-medium"> - ProductionID </div>
<div className="text-xs text-slate-500 mt-0.5"></div> <div className="text-xs text-muted-foreground mt-0.5"></div>
{valMode === 'filtered' && ( {valMode === 'filtered' && (
<div className="mt-3 text-xs text-blue-700 bg-blue-100/50 rounded p-2 flex items-start gap-1.5"> <div className="mt-3 text-xs text-primary bg-primary/10 rounded p-2 flex items-start gap-1.5">
<Layers size={14} className="mt-0.5 flex-shrink-0" /> <Layers size={14} className="mt-0.5 flex-shrink-0" />
<span> <span>
使<strong></strong> 使<strong></strong>
@@ -65,39 +78,40 @@ export function CleanerSidebar({
</div> </div>
</label> </label>
</div> </div>
</div> </Card>
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-5"> <Card className="p-5">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h3 className="text-base font-semibold flex items-center gap-2 text-slate-800"> <h3 className="text-base font-semibold flex items-center gap-2">
<Users size={18} className="text-indigo-500" /> <Users size={18} className="text-indigo-500" />
() ()
</h3> </h3>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={() => setSelectedManagers(new Set(managers))} onClick={() => setSelectedManagers(new Set(managers))}
className="text-xs text-blue-600 hover:underline" className="text-xs text-primary hover:underline"
> >
</button> </button>
<button <button
onClick={() => setSelectedManagers(new Set())} onClick={() => setSelectedManagers(new Set())}
className="text-xs text-slate-500 hover:underline" className="text-xs text-muted-foreground hover:underline"
> >
</button> </button>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-2 mt-3 max-h-[120px] overflow-y-auto pr-1"> <ScrollArea className="h-[120px] pr-1">
<div className="grid grid-cols-2 gap-2">
{managers.map((manager) => ( {managers.map((manager) => (
<label <label
key={manager} key={manager}
className="flex items-center gap-2 text-sm text-slate-700 cursor-pointer hover:bg-slate-50 p-1.5 rounded" className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 p-1.5 rounded"
> >
<input <input
type="checkbox" type="checkbox"
className="rounded text-blue-600" className="rounded text-primary"
checked={selectedManagers.has(manager)} checked={selectedManagers.has(manager)}
onChange={(e) => { onChange={(e) => {
setSelectedManagers((prev) => { setSelectedManagers((prev) => {
@@ -111,9 +125,10 @@ export function CleanerSidebar({
{manager || '未分配'} {manager || '未分配'}
</label> </label>
))} ))}
{managers.length === 0 && <div className="text-sm text-slate-400"></div>} {managers.length === 0 && <div className="text-sm text-muted-foreground"></div>}
</div>
</div> </div>
</ScrollArea>
</Card>
</div> </div>
) )
} }

View File

@@ -10,6 +10,8 @@ import {
Settings2, Settings2,
Square Square
} from 'lucide-react' } from 'lucide-react'
import { Button } from '../ui/Button'
import { Separator } from '../ui/shadcn/separator'
import type { ValidationResult } from '../../hooks/cleaner/types' import type { ValidationResult } from '../../hooks/cleaner/types'
interface CleanerToolbarProps { interface CleanerToolbarProps {
@@ -48,25 +50,29 @@ export function CleanerToolbar({
}: CleanerToolbarProps): React.JSX.Element { }: CleanerToolbarProps): React.JSX.Element {
return ( return (
<> <>
<div className="p-4 border-b border-slate-200 flex-shrink-0"> <div className="p-4 border-b border-border flex-shrink-0">
<button <Button
onClick={() => void handleValidation()} onClick={() => void handleValidation()}
disabled={isValidationRunning} disabled={isValidationRunning}
className="w-full bg-slate-100 hover:bg-slate-200 text-slate-800 py-3 rounded-lg font-medium transition-colors shadow-sm flex justify-center items-center gap-2 border border-slate-200 disabled:opacity-50" className="w-full gap-2"
loading={isValidationRunning}
> >
<Search size={18} /> {isValidationRunning ? '正在获取...' : '获取并校验物料状态'} <Search size={18} /> {isValidationRunning ? '正在获取...' : '获取并校验物料状态'}
</button> </Button>
</div> </div>
<div className="bg-slate-50 border-b border-slate-200 px-4 py-3 flex justify-between items-center flex-shrink-0"> <div className="bg-muted/30 border-b border-border px-4 py-3 flex justify-between items-center flex-shrink-0">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<button <Button
size="sm"
onClick={() => setSelectedItems(new Set(filteredResults.map((r) => r.materialCode)))} onClick={() => setSelectedItems(new Set(filteredResults.map((r) => r.materialCode)))}
className="text-xs bg-white border border-slate-300 text-slate-700 px-2.5 py-1.5 rounded shadow-sm hover:bg-slate-50 flex items-center gap-1" variant="secondary"
className="gap-1"
> >
<CheckSquare size={14} className="text-blue-600" /> <CheckSquare size={14} className="text-primary" />
</button> </Button>
<button <Button
size="sm"
onClick={() => { onClick={() => {
const visibleCodes = new Set(filteredResults.map((r) => r.materialCode)) const visibleCodes = new Set(filteredResults.map((r) => r.materialCode))
setSelectedItems((prev) => { setSelectedItems((prev) => {
@@ -77,14 +83,16 @@ export function CleanerToolbar({
return next return next
}) })
}} }}
className="text-xs bg-white border border-slate-300 text-slate-700 px-2.5 py-1.5 rounded shadow-sm hover:bg-slate-50 flex items-center gap-1" variant="secondary"
className="gap-1"
> >
<Square size={14} className="text-slate-400" /> <Square size={14} className="text-muted-foreground" />
</button> </Button>
<div className="w-px h-4 bg-slate-300 mx-1"></div> <Separator orientation="vertical" className="h-4 mx-1" />
<button <Button
size="sm"
onClick={() => { onClick={() => {
const checkedCodes = filteredResults const checkedCodes = filteredResults
.filter((r) => selectedItems.has(r.materialCode)) .filter((r) => selectedItems.has(r.materialCode))
@@ -93,49 +101,58 @@ export function CleanerToolbar({
setHiddenItems((prev) => new Set([...prev, ...checkedCodes])) setHiddenItems((prev) => new Set([...prev, ...checkedCodes]))
} }
}} }}
className="text-xs bg-white border border-slate-300 text-slate-700 px-2.5 py-1.5 rounded shadow-sm hover:bg-slate-50 flex items-center gap-1" variant="secondary"
className="gap-1"
> >
<EyeOff size={14} /> <EyeOff size={14} />
</button> </Button>
<button <Button
size="sm"
onClick={() => setHiddenItems(new Set())} onClick={() => setHiddenItems(new Set())}
className="text-xs bg-white border border-slate-300 text-slate-700 px-2.5 py-1.5 rounded shadow-sm hover:bg-slate-50 flex items-center gap-1" variant="secondary"
className="gap-1"
> >
<Eye size={14} /> <Eye size={14} />
</button> </Button>
<div className="w-px h-4 bg-slate-300 mx-1"></div> <Separator orientation="vertical" className="h-4 mx-1" />
<button <Button
size="sm"
onClick={() => void handleConfirmDeletion()} onClick={() => void handleConfirmDeletion()}
disabled={isRunning || validationResults.length === 0} disabled={isRunning || validationResults.length === 0}
className="text-xs bg-indigo-50 border border-indigo-200 text-indigo-700 px-3 py-1.5 rounded shadow-sm hover:bg-indigo-100 flex items-center gap-1.5 font-medium transition-colors disabled:opacity-50" className="gap-1.5 bg-indigo-50 text-indigo-700 border-indigo-200 hover:bg-indigo-100 dark:bg-indigo-900/20 dark:text-indigo-400 dark:border-indigo-800"
> >
<HardDrive size={14} /> () <HardDrive size={14} /> ()
</button> </Button>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <Button
size="sm"
onClick={() => setIsTypeDialogOpen(true)} onClick={() => setIsTypeDialogOpen(true)}
ref={typeManagementButtonRef} ref={typeManagementButtonRef}
className="text-xs bg-white border border-slate-300 text-slate-700 px-3 py-1.5 rounded shadow-sm hover:bg-slate-50 flex items-center gap-1.5" variant="secondary"
className="gap-1.5"
> >
<Settings2 size={14} /> <Settings2 size={14} />
</button> </Button>
<button <Button
size="sm"
onClick={() => void handleExportResults()} onClick={() => void handleExportResults()}
disabled={isExporting || filteredResults.length === 0} disabled={isExporting || filteredResults.length === 0}
className="text-xs bg-blue-50 border border-blue-200 text-blue-700 px-3 py-1.5 rounded shadow-sm hover:bg-blue-100 flex items-center gap-1.5 font-medium disabled:opacity-50" loading={isExporting}
className="gap-1.5 bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-800"
> >
<FileSpreadsheet size={14} /> {isExporting ? '导出中...' : '导出结果'} <FileSpreadsheet size={14} /> {isExporting ? '导出中...' : '导出结果'}
</button> </Button>
<button <Button
size="sm"
onClick={() => setIsReportViewerOpen(true)} onClick={() => setIsReportViewerOpen(true)}
className="text-xs bg-emerald-50 border border-emerald-200 text-emerald-700 px-3 py-1.5 rounded shadow-sm hover:bg-emerald-100 flex items-center gap-1.5 font-medium transition-colors" className="gap-1.5 bg-emerald-50 text-emerald-700 border-emerald-200 hover:bg-emerald-100 dark:bg-emerald-900/20 dark:text-emerald-400 dark:border-emerald-800"
> >
<FileText size={14} /> <FileText size={14} />
</button> </Button>
</div> </div>
</div> </div>
</> </>

View File

@@ -34,7 +34,8 @@ const sizeMap: Record<ButtonSize, 'default' | 'sm' | 'lg' | 'icon'> = {
lg: 'lg' lg: 'lg'
} }
export function Button({ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({
variant = 'primary', variant = 'primary',
size = 'md', size = 'md',
loading = false, loading = false,
@@ -43,12 +44,13 @@ export function Button({
className = '', className = '',
disabled, disabled,
...props ...props
}: ButtonProps) { }, ref) => {
const newVariant = variantMap[variant] const newVariant = variantMap[variant]
const newSize = sizeMap[size] const newSize = sizeMap[size]
return ( return (
<ShadcnButton <ShadcnButton
ref={ref}
variant={newVariant} variant={newVariant}
size={newSize} size={newSize}
loading={loading} loading={loading}
@@ -60,6 +62,9 @@ export function Button({
{children} {children}
</ShadcnButton> </ShadcnButton>
) )
} }
)
Button.displayName = 'Button'
export { Button }
export default Button export default Button

View File

@@ -2,12 +2,23 @@
* ConfirmDialog Component * ConfirmDialog Component
* *
* A confirmation dialog component for displaying confirmation prompts. * A confirmation dialog component for displaying confirmation prompts.
* Extends the Modal component with consistent styling and behavior. * Now uses shadcn/ui AlertDialog component for better accessibility and styling.
*
* Migrated to use shadcn/ui AlertDialog
*/ */
import React, { useCallback, useEffect } from 'react' import React, { useCallback, useEffect } from 'react'
import { AlertTriangle, Info, AlertCircle } from 'lucide-react' import { AlertTriangle, Info, AlertCircle } from 'lucide-react'
import { Modal } from './Modal' import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from './shadcn/alert-dialog'
import { Button } from './Button' import { Button } from './Button'
export type ConfirmDialogVariant = 'danger' | 'warning' | 'info' export type ConfirmDialogVariant = 'danger' | 'warning' | 'info'
@@ -29,18 +40,18 @@ const variantStyles: Record<
> = { > = {
danger: { danger: {
icon: <AlertCircle className="w-6 h-6" />, icon: <AlertCircle className="w-6 h-6" />,
iconColor: 'text-red-500', iconColor: 'text-destructive',
buttonVariant: 'danger' buttonVariant: 'danger' as const
}, },
warning: { warning: {
icon: <AlertTriangle className="w-6 h-6" />, icon: <AlertTriangle className="w-6 h-6" />,
iconColor: 'text-yellow-500', iconColor: 'text-amber-500 dark:text-amber-400',
buttonVariant: 'primary' buttonVariant: 'primary' as const
}, },
info: { info: {
icon: <Info className="w-6 h-6" />, icon: <Info className="w-6 h-6" />,
iconColor: 'text-blue-500', iconColor: 'text-primary',
buttonVariant: 'primary' buttonVariant: 'primary' as const
} }
} }
@@ -78,40 +89,56 @@ export function ConfirmDialog({
const styles = variantStyles[variant] const styles = variantStyles[variant]
// For AlertDialog, we use open/onOpenChange
const handleOpenChange = (open: boolean) => {
if (!open) {
onCancel()
}
}
return ( return (
<Modal <AlertDialog open={isOpen} onOpenChange={handleOpenChange}>
isOpen={isOpen} <AlertDialogContent>
onClose={onCancel} <AlertDialogHeader>
title={title}
size="md"
showCloseButton={false}
isAlertDialog={true}
initialFocusSelector="[data-autofocus]"
>
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
{/* Icon */} {/* Icon */}
<div className={`flex-shrink-0 ${styles.iconColor}`}>{styles.icon}</div> <div className={`flex-shrink-0 ${styles.iconColor}`}>{styles.icon}</div>
{/* Message */} {/* Title and Description */}
<div className="flex-1"> <div className="flex-1">
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="mt-2">
{typeof message === 'string' ? ( {typeof message === 'string' ? (
<p className="text-gray-700 whitespace-pre-wrap">{message}</p> <p className="whitespace-pre-wrap text-foreground">{message}</p>
) : ( ) : (
message message
)} )}
</div> </div>
</AlertDialogDescription>
</div> </div>
</div>
</AlertDialogHeader>
{/* Buttons */} {/* Buttons */}
<div className="flex justify-end gap-3 mt-6"> <AlertDialogFooter>
<AlertDialogCancel asChild>
<Button variant="secondary" onClick={onCancel}> <Button variant="secondary" onClick={onCancel}>
{cancelText} {cancelText}
</Button> </Button>
<Button data-autofocus="true" variant={styles.buttonVariant} onClick={onConfirm}> </AlertDialogCancel>
<AlertDialogAction asChild>
<Button
data-autofocus="true"
variant={styles.buttonVariant}
onClick={onConfirm}
>
{confirmText} {confirmText}
</Button> </Button>
</div> </AlertDialogAction>
</Modal> </AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) )
} }