import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react'; import { Copy, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; import { toast } from 'sonner'; // 推荐使用 shadcn 的默认 Toast export type ValidateRule = { validator: (value: string) => boolean; message: string; }; export type ToolbarAction = { key: string; label: string; icon?: React.ReactNode; position?: 'top' | 'bottom'; type?: 'primary' | 'default' | 'danger'; disabled?: boolean | ((value: string) => boolean); onClick: (value: string, helpers: { clear: () => void; setError: (msg: string) => void }) => void; }; export interface TextInputAreaProps extends Omit< React.TextareaHTMLAttributes, 'onChange' > { value?: string; defaultValue?: string; /** 值变化回调,返回最新的字符串内容 */ onChange?: (value: string) => void; minRows?: number; maxRows?: number; showCount?: boolean; showClear?: boolean; allowCopy?: boolean; rules?: ValidateRule[]; validateTrigger?: 'onBlur' | 'onChange' | 'onAction'; actions?: ToolbarAction[]; topExtra?: React.ReactNode; title?: string; externalError?: string; onClear?: () => void; } // 提炼基础的 ActionButton,全面向 shadcn 核心 Button 样式对齐 function ActionButton({ action, value, globalDisabled, onAction, }: { action: ToolbarAction; value: string; globalDisabled: boolean; onAction: (action: ToolbarAction) => void; }) { const isBtnDisabled = typeof action.disabled === 'function' ? action.disabled(value) : (action.disabled ?? false); const variantClasses = { primary: 'bg-primary text-primary-foreground shadow hover:bg-primary/90', danger: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', default: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', }; return ( ); } const TextInputArea = forwardRef((props, ref) => { const { value: controlledValue, defaultValue = '', onChange, placeholder: placeholderProp, disabled = false, readOnly = false, autoFocus = false, minRows = 4, maxRows = 12, maxLength, className, showCount = false, showClear = true, allowCopy = false, rules = [], validateTrigger = 'onAction', actions = [], topExtra, title, externalError, onClear, ...restProps } = props; const internalRef = useRef(null); const [internalValue, setInternalValue] = useState(defaultValue); const [error, setError] = useState(''); const { t } = useTranslation('common'); const placeholder = placeholderProp ?? t('textInputArea.placeholder'); const isControlled = controlledValue !== undefined; const value = isControlled ? controlledValue : internalValue; const displayError = externalError ?? error; // 双向合并 ref 指针 useImperativeHandle(ref, () => internalRef.current as HTMLTextAreaElement); // 1. 高性能的动态高度自适应计算 const adjustHeight = useCallback(() => { const textArea = internalRef.current; if (!textArea) return; // 重置高度计算 textArea.style.height = 'auto'; const computedMin = minRows * 24; // 每行粗略按 24px 计算 const computedMax = maxRows * 24; const nextHeight = Math.max(textArea.scrollHeight, computedMin); textArea.style.height = `${Math.min(nextHeight, computedMax)}px`; }, [minRows, maxRows]); // 当数值改变时自适应扩展 React.useEffect(() => { adjustHeight(); }, [value, adjustHeight]); const validate = useCallback( (val: string, trigger?: string): boolean => { if (validateTrigger !== trigger && trigger) return true; for (const rule of rules) { if (!rule.validator(val)) { setError(rule.message); return false; } } setError(''); return true; }, [rules, validateTrigger], ); const handleChange = (e: React.ChangeEvent) => { const newVal = e.target.value; if (maxLength && newVal.length > maxLength) { const msg = t('charCount', { count: maxLength }); setError(msg); toast.warning(msg); return; } if (!isControlled) setInternalValue(newVal); onChange?.(newVal); if (error) setError(''); if (validateTrigger === 'onChange') validate(newVal, 'onChange'); }; const handleBlur = () => { if (validateTrigger === 'onBlur') validate(value, 'onBlur'); }; const handleClear = useCallback(() => { if (!isControlled) setInternalValue(''); onChange?.(''); setError(''); internalRef.current?.focus(); toast.success(t('textInputArea.cleared')); onClear?.(); }, [isControlled, onChange, onClear, t]); const handleCopy = useCallback(async () => { try { await navigator.clipboard.writeText(value); toast.success(t('messages.copySuccess')); } catch { setError(t('messages.copyError')); toast.error(t('messages.copyError')); } }, [value, t]); const handleAction = useCallback( (action: ToolbarAction) => { const isDisabled = typeof action.disabled === 'function' ? action.disabled(value) : action.disabled; if (isDisabled || disabled) return; if (validateTrigger === 'onAction' && !validate(value, 'onAction')) return; action.onClick(value, { clear: handleClear, setError, }); }, [value, disabled, validate, validateTrigger, handleClear], ); const topActions = actions.filter((a) => a.position !== 'bottom'); const bottomActions = actions.filter((a) => a.position === 'bottom'); const hasTopBar = title || showCount || topActions.length > 0 || topExtra; const hasBottomBar = allowCopy || showClear || bottomActions.length > 0; return (
{hasTopBar && (
{title && {title}} {topExtra}
{topActions.map((action) => ( ))} {showCount && ( {value.length} {maxLength ? ` / ${maxLength}` : ''} )}
)}