Files
mai-bot/dashboard/src/components/survey/survey-question.tsx
2026-01-13 06:24:35 +08:00

248 lines
7.8 KiB
TypeScript

/**
* 单个问题渲染组件
*/
import { useState } from 'react'
import { cn } from '@/lib/utils'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Checkbox } from '@/components/ui/checkbox'
import { Slider } from '@/components/ui/slider'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Star } from 'lucide-react'
import type { SurveyQuestion as SurveyQuestionType } from '@/types/survey'
interface SurveyQuestionProps {
question: SurveyQuestionType
value: string | string[] | number | undefined
onChange: (value: string | string[] | number) => void
error?: string
disabled?: boolean
}
export function SurveyQuestion({
question,
value,
onChange,
error,
disabled = false
}: SurveyQuestionProps) {
const [hoverRating, setHoverRating] = useState<number | null>(null)
// 如果问题设置了只读,则禁用输入
const isDisabled = disabled || question.readOnly
const renderQuestion = () => {
switch (question.type) {
case 'single':
return (
<RadioGroup
value={value as string || ''}
onValueChange={onChange}
disabled={isDisabled}
className="space-y-2"
>
{question.options?.map((option) => (
<div key={option.id} className="flex items-center space-x-2">
<RadioGroupItem value={option.value} id={`${question.id}-${option.id}`} />
<Label
htmlFor={`${question.id}-${option.id}`}
className="cursor-pointer font-normal"
>
{option.label}
</Label>
</div>
))}
</RadioGroup>
)
case 'multiple': {
const selectedValues = (value as string[]) || []
return (
<div className="space-y-2">
{question.options?.map((option) => (
<div key={option.id} className="flex items-center space-x-2">
<Checkbox
id={`${question.id}-${option.id}`}
checked={selectedValues.includes(option.value)}
disabled={isDisabled || (
question.maxSelections !== undefined &&
selectedValues.length >= question.maxSelections &&
!selectedValues.includes(option.value)
)}
onCheckedChange={(checked) => {
if (checked) {
onChange([...selectedValues, option.value])
} else {
onChange(selectedValues.filter(v => v !== option.value))
}
}}
/>
<Label
htmlFor={`${question.id}-${option.id}`}
className="cursor-pointer font-normal"
>
{option.label}
</Label>
</div>
))}
{question.maxSelections && (
<p className="text-xs text-muted-foreground">
{question.maxSelections}
</p>
)}
</div>
)
}
case 'text':
return (
<Input
value={value as string || ''}
onChange={(e) => onChange(e.target.value)}
placeholder={question.placeholder || '请输入...'}
disabled={isDisabled}
readOnly={question.readOnly}
maxLength={question.maxLength}
className={cn(question.readOnly && "bg-muted cursor-not-allowed")}
/>
)
case 'textarea':
return (
<div className="space-y-1">
<Textarea
value={value as string || ''}
onChange={(e) => onChange(e.target.value)}
placeholder={question.placeholder || '请输入...'}
disabled={isDisabled}
readOnly={question.readOnly}
maxLength={question.maxLength}
rows={4}
className={cn(question.readOnly && "bg-muted cursor-not-allowed")}
/>
{question.maxLength && (
<p className="text-xs text-muted-foreground text-right">
{(value as string || '').length} / {question.maxLength}
</p>
)}
</div>
)
case 'rating': {
const ratingValue = (value as number) || 0
const displayRating = hoverRating !== null ? hoverRating : ratingValue
return (
<div className="flex items-center gap-1">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
type="button"
disabled={isDisabled}
className={cn(
"p-1 transition-colors focus:outline-none focus:ring-2 focus:ring-ring rounded",
isDisabled && "cursor-not-allowed opacity-50"
)}
onMouseEnter={() => !isDisabled && setHoverRating(star)}
onMouseLeave={() => setHoverRating(null)}
onClick={() => !isDisabled && onChange(star)}
>
<Star
className={cn(
"h-6 w-6 transition-colors",
star <= displayRating
? "fill-yellow-400 text-yellow-400"
: "text-muted-foreground"
)}
/>
</button>
))}
{ratingValue > 0 && (
<span className="ml-2 text-sm text-muted-foreground">
{ratingValue} / 5
</span>
)}
</div>
)
}
case 'scale': {
const min = question.min ?? 1
const max = question.max ?? 10
const step = question.step ?? 1
const scaleValue = (value as number) ?? min
return (
<div className="space-y-4">
<Slider
value={[scaleValue]}
onValueChange={([val]) => onChange(val)}
min={min}
max={max}
step={step}
disabled={isDisabled}
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{question.minLabel || min}</span>
<span className="font-medium text-foreground">{scaleValue}</span>
<span>{question.maxLabel || max}</span>
</div>
</div>
)
}
case 'dropdown':
return (
<Select
value={value as string || ''}
onValueChange={onChange}
disabled={isDisabled}
>
<SelectTrigger>
<SelectValue placeholder={question.placeholder || '请选择...'} />
</SelectTrigger>
<SelectContent>
{question.options?.map((option) => (
<SelectItem key={option.id} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)
default:
return <div className="text-muted-foreground"></div>
}
}
return (
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-base font-medium">
{question.title}
{question.required && (
<span className="text-destructive ml-1">*</span>
)}
</Label>
{question.description && (
<p className="text-sm text-muted-foreground">{question.description}</p>
)}
</div>
{renderQuestion()}
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
)
}