2968484d2d
- Money.tsx (2260行→54行): 拆分为 money/ 子目录4个组件, React.lazy二级分割 - AIAssistant.tsx (2038行→63行): 拆分为 ai-assistant/ 子目录6个组件, React.lazy二级分割 - xlsx改为动态导入, OvertimeTab从345KB降至12.7KB - api-services.ts: 请求参数 any→Record<string,unknown> - 移除前端3处console.log残留 - 后端console替换为pino logger - 前后端未使用import/变量清理 - Zod schema验证: termination/platform/special-status/work-process - 新增 leave.routes.ts, acceptance-test.routes.ts - UI组件: PageGuide, QueryError, Stepper
134 lines
5.4 KiB
TypeScript
134 lines
5.4 KiB
TypeScript
/**
|
|
* Stepper 步骤条组件 — 用于多步骤工作流引导
|
|
* 支持横向/纵向布局、可点击步骤导航、完成/当前/待办状态
|
|
*/
|
|
import clsx from 'clsx'
|
|
import { Check } from 'lucide-react'
|
|
|
|
export interface Step {
|
|
key: string
|
|
title: string
|
|
description?: string
|
|
status: 'complete' | 'current' | 'pending' | 'error'
|
|
}
|
|
|
|
interface StepperProps {
|
|
steps: Step[]
|
|
orientation?: 'horizontal' | 'vertical'
|
|
onStepClick?: (key: string) => void
|
|
className?: string
|
|
}
|
|
|
|
/**
|
|
* Stepper 步骤条 — 展示工作流进度和步骤导航
|
|
*/
|
|
export function Stepper({ steps, orientation = 'horizontal', onStepClick, className }: StepperProps) {
|
|
|
|
if (orientation === 'vertical') {
|
|
return (
|
|
<div className={clsx('flex flex-col', className)}>
|
|
{steps.map((step, i) => {
|
|
const isLast = i === steps.length - 1
|
|
const isClickable = onStepClick && (step.status === 'complete' || step.status === 'current')
|
|
return (
|
|
<div key={step.key} className="flex gap-3">
|
|
{/* 左侧指示器 + 连接线 */}
|
|
<div className="flex flex-col items-center">
|
|
<button
|
|
onClick={isClickable ? () => onStepClick?.(step.key) : undefined}
|
|
disabled={!isClickable}
|
|
className={clsx(
|
|
'flex items-center justify-center w-8 h-8 rounded-full text-xs font-medium transition-colors shrink-0',
|
|
step.status === 'complete' && 'bg-success text-white',
|
|
step.status === 'current' && 'bg-brand-600 text-white ring-4 ring-brand-600/20',
|
|
step.status === 'pending' && 'bg-surface-muted text-ink-400',
|
|
step.status === 'error' && 'bg-danger text-white',
|
|
isClickable && 'cursor-pointer hover:opacity-80',
|
|
)}
|
|
>
|
|
{step.status === 'complete' ? <Check className="w-4 h-4" /> : i + 1}
|
|
</button>
|
|
{!isLast && (
|
|
<div className={clsx('w-0.5 flex-1 min-h-[24px] my-1', step.status === 'complete' ? 'bg-success' : 'bg-border-subtle')} />
|
|
)}
|
|
</div>
|
|
{/* 右侧内容 */}
|
|
<div className={clsx('pb-4', isLast && 'pb-0')}>
|
|
<div
|
|
onClick={isClickable ? () => onStepClick?.(step.key) : undefined}
|
|
className={clsx(
|
|
'text-sm font-medium',
|
|
step.status === 'current' && 'text-ink-900',
|
|
step.status === 'complete' && 'text-ink-700',
|
|
step.status === 'pending' && 'text-ink-400',
|
|
step.status === 'error' && 'text-danger',
|
|
isClickable && 'cursor-pointer hover:text-ink-900',
|
|
)}
|
|
>
|
|
{step.title}
|
|
</div>
|
|
{step.description && (
|
|
<div className="text-xs text-ink-500 mt-0.5">{step.description}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// 横向布局
|
|
return (
|
|
<div className={clsx('flex items-center', className)}>
|
|
{steps.map((step, i) => {
|
|
const isLast = i === steps.length - 1
|
|
const isClickable = onStepClick && (step.status === 'complete' || step.status === 'current')
|
|
return (
|
|
<div key={step.key} className="flex items-center flex-1 last:flex-none">
|
|
{/* 圆点 + 标题 */}
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<button
|
|
onClick={isClickable ? () => onStepClick?.(step.key) : undefined}
|
|
disabled={!isClickable}
|
|
className={clsx(
|
|
'flex items-center justify-center w-7 h-7 rounded-full text-xs font-medium transition-colors shrink-0',
|
|
step.status === 'complete' && 'bg-success text-white',
|
|
step.status === 'current' && 'bg-brand-600 text-white ring-4 ring-brand-600/20',
|
|
step.status === 'pending' && 'bg-surface-muted text-ink-400',
|
|
step.status === 'error' && 'bg-danger text-white',
|
|
isClickable && 'cursor-pointer hover:opacity-80',
|
|
)}
|
|
>
|
|
{step.status === 'complete' ? <Check className="w-3.5 h-3.5" /> : i + 1}
|
|
</button>
|
|
<div className="hidden sm:block">
|
|
<div
|
|
onClick={isClickable ? () => onStepClick?.(step.key) : undefined}
|
|
className={clsx(
|
|
'text-sm font-medium whitespace-nowrap',
|
|
step.status === 'current' && 'text-ink-900',
|
|
step.status === 'complete' && 'text-ink-700',
|
|
step.status === 'pending' && 'text-ink-400',
|
|
step.status === 'error' && 'text-danger',
|
|
isClickable && 'cursor-pointer hover:text-ink-900',
|
|
)}
|
|
>
|
|
{step.title}
|
|
</div>
|
|
{step.description && (
|
|
<div className="text-xs text-ink-500 whitespace-nowrap">{step.description}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{/* 连接线 */}
|
|
{!isLast && (
|
|
<div className={clsx('h-0.5 flex-1 mx-2', step.status === 'complete' ? 'bg-success' : 'bg-border-subtle')} />
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|