上传完整的WebUI前端仓库
This commit is contained in:
8
dashboard/src/components/survey/index.ts
Normal file
8
dashboard/src/components/survey/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* 问卷组件导出
|
||||
*/
|
||||
|
||||
export { SurveyRenderer } from './survey-renderer'
|
||||
export { SurveyQuestion } from './survey-question'
|
||||
export { SurveyResults } from './survey-results'
|
||||
export type { SurveyRendererProps } from './survey-renderer'
|
||||
247
dashboard/src/components/survey/survey-question.tsx
Normal file
247
dashboard/src/components/survey/survey-question.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* 单个问题渲染组件
|
||||
*/
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
407
dashboard/src/components/survey/survey-renderer.tsx
Normal file
407
dashboard/src/components/survey/survey-renderer.tsx
Normal file
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* 问卷渲染器组件
|
||||
* 读取 JSON 配置并展示问卷界面
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Loader2, CheckCircle2, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { SurveyQuestion } from './survey-question'
|
||||
import { submitSurvey, checkUserSubmission } from '@/lib/survey-api'
|
||||
import type { SurveyConfig, QuestionAnswer } from '@/types/survey'
|
||||
|
||||
export interface SurveyRendererProps {
|
||||
/** 问卷配置 */
|
||||
config: SurveyConfig
|
||||
/** 初始答案(用于预填充,如自动填写版本号) */
|
||||
initialAnswers?: QuestionAnswer[]
|
||||
/** 提交成功回调 */
|
||||
onSubmitSuccess?: (submissionId: string) => void
|
||||
/** 提交失败回调 */
|
||||
onSubmitError?: (error: string) => void
|
||||
/** 是否显示进度条 */
|
||||
showProgress?: boolean
|
||||
/** 是否分页显示(每页一题) */
|
||||
paginateQuestions?: boolean
|
||||
/** 自定义类名 */
|
||||
className?: string
|
||||
}
|
||||
|
||||
type AnswerMap = Record<string, string | string[] | number | undefined>
|
||||
|
||||
export function SurveyRenderer({
|
||||
config,
|
||||
initialAnswers,
|
||||
onSubmitSuccess,
|
||||
onSubmitError,
|
||||
showProgress = true,
|
||||
paginateQuestions = false,
|
||||
className
|
||||
}: SurveyRendererProps) {
|
||||
// 将 initialAnswers 转换为 AnswerMap
|
||||
const getInitialAnswerMap = useCallback((): AnswerMap => {
|
||||
if (!initialAnswers || initialAnswers.length === 0) return {}
|
||||
return initialAnswers.reduce((acc, answer) => {
|
||||
acc[answer.questionId] = answer.value
|
||||
return acc
|
||||
}, {} as AnswerMap)
|
||||
}, [initialAnswers])
|
||||
|
||||
const [answers, setAnswers] = useState<AnswerMap>(() => getInitialAnswerMap())
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [currentPage, setCurrentPage] = useState(0)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isSubmitted, setIsSubmitted] = useState(false)
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [submissionId, setSubmissionId] = useState<string | null>(null)
|
||||
const [hasAlreadySubmitted, setHasAlreadySubmitted] = useState(false)
|
||||
const [isCheckingSubmission, setIsCheckingSubmission] = useState(true)
|
||||
|
||||
// 当 initialAnswers 变化时更新答案(合并而非替换)
|
||||
useEffect(() => {
|
||||
if (initialAnswers && initialAnswers.length > 0) {
|
||||
setAnswers(prev => ({
|
||||
...prev,
|
||||
...getInitialAnswerMap()
|
||||
}))
|
||||
}
|
||||
}, [initialAnswers, getInitialAnswerMap])
|
||||
|
||||
// 检查是否已提交过
|
||||
useEffect(() => {
|
||||
const checkSubmission = async () => {
|
||||
if (!config.settings?.allowMultiple) {
|
||||
const result = await checkUserSubmission(config.id)
|
||||
if (result.success && result.hasSubmitted) {
|
||||
setHasAlreadySubmitted(true)
|
||||
}
|
||||
}
|
||||
setIsCheckingSubmission(false)
|
||||
}
|
||||
checkSubmission()
|
||||
}, [config.id, config.settings?.allowMultiple])
|
||||
|
||||
// 检查问卷是否在有效期内
|
||||
const isWithinTimeRange = useCallback(() => {
|
||||
const now = new Date()
|
||||
if (config.settings?.startTime && new Date(config.settings.startTime) > now) {
|
||||
return false
|
||||
}
|
||||
if (config.settings?.endTime && new Date(config.settings.endTime) < now) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}, [config.settings?.startTime, config.settings?.endTime])
|
||||
|
||||
// 计算进度
|
||||
const answeredCount = config.questions.filter(q => {
|
||||
const answer = answers[q.id]
|
||||
if (answer === undefined || answer === null) return false
|
||||
if (Array.isArray(answer)) return answer.length > 0
|
||||
if (typeof answer === 'string') return answer.trim() !== ''
|
||||
return true
|
||||
}).length
|
||||
|
||||
const progress = (answeredCount / config.questions.length) * 100
|
||||
|
||||
// 更新答案
|
||||
const handleAnswerChange = useCallback((questionId: string, value: string | string[] | number) => {
|
||||
setAnswers(prev => ({ ...prev, [questionId]: value }))
|
||||
// 清除该问题的错误
|
||||
setErrors(prev => {
|
||||
const newErrors = { ...prev }
|
||||
delete newErrors[questionId]
|
||||
return newErrors
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 验证答案
|
||||
const validateAnswers = useCallback(() => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
|
||||
for (const question of config.questions) {
|
||||
if (question.required) {
|
||||
const answer = answers[question.id]
|
||||
|
||||
if (answer === undefined || answer === null) {
|
||||
newErrors[question.id] = '此题为必填项'
|
||||
continue
|
||||
}
|
||||
|
||||
if (Array.isArray(answer) && answer.length === 0) {
|
||||
newErrors[question.id] = '请至少选择一项'
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof answer === 'string' && answer.trim() === '') {
|
||||
newErrors[question.id] = '此题为必填项'
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 文本长度验证
|
||||
if (question.minLength && typeof answers[question.id] === 'string') {
|
||||
const text = answers[question.id] as string
|
||||
if (text.length < question.minLength) {
|
||||
newErrors[question.id] = `至少需要 ${question.minLength} 个字符`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}, [config.questions, answers])
|
||||
|
||||
// 提交问卷
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!validateAnswers()) {
|
||||
// 如果是分页模式,跳转到第一个有错误的问题
|
||||
if (paginateQuestions) {
|
||||
const firstErrorIndex = config.questions.findIndex(q => errors[q.id])
|
||||
if (firstErrorIndex >= 0) {
|
||||
setCurrentPage(firstErrorIndex)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
setSubmitError(null)
|
||||
|
||||
try {
|
||||
// 构建答案列表
|
||||
const answerList: QuestionAnswer[] = config.questions
|
||||
.filter(q => answers[q.id] !== undefined)
|
||||
.map(q => ({
|
||||
questionId: q.id,
|
||||
value: answers[q.id]!
|
||||
}))
|
||||
|
||||
const result = await submitSurvey(
|
||||
config.id,
|
||||
config.version,
|
||||
answerList,
|
||||
{ allowMultiple: config.settings?.allowMultiple }
|
||||
)
|
||||
|
||||
if (result.success && result.submissionId) {
|
||||
setIsSubmitted(true)
|
||||
setSubmissionId(result.submissionId)
|
||||
onSubmitSuccess?.(result.submissionId)
|
||||
} else {
|
||||
const error = result.error || '提交失败'
|
||||
setSubmitError(error)
|
||||
onSubmitError?.(error)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : '提交失败'
|
||||
setSubmitError(errorMsg)
|
||||
onSubmitError?.(errorMsg)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}, [validateAnswers, paginateQuestions, config, answers, errors, onSubmitSuccess, onSubmitError])
|
||||
|
||||
// 分页导航
|
||||
const goToPage = useCallback((page: number) => {
|
||||
if (page >= 0 && page < config.questions.length) {
|
||||
setCurrentPage(page)
|
||||
}
|
||||
}, [config.questions.length])
|
||||
|
||||
// 检查中
|
||||
if (isCheckingSubmission) {
|
||||
return (
|
||||
<Card className={cn("w-full max-w-2xl mx-auto", className)}>
|
||||
<CardContent className="py-12 flex items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 已提交过
|
||||
if (hasAlreadySubmitted && !config.settings?.allowMultiple) {
|
||||
return (
|
||||
<Card className={cn("w-full max-w-2xl mx-auto", className)}>
|
||||
<CardHeader>
|
||||
<CardTitle>{config.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="py-8">
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
你已经提交过这份问卷了,感谢参与!
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 不在有效期内
|
||||
if (!isWithinTimeRange()) {
|
||||
return (
|
||||
<Card className={cn("w-full max-w-2xl mx-auto", className)}>
|
||||
<CardHeader>
|
||||
<CardTitle>{config.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="py-8">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
问卷不在有效期内
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 提交成功
|
||||
if (isSubmitted) {
|
||||
return (
|
||||
<Card className={cn("w-full max-w-2xl mx-auto", className)}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-green-600">
|
||||
<CheckCircle2 className="h-6 w-6" />
|
||||
提交成功
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="py-8">
|
||||
<p className="text-center text-muted-foreground">
|
||||
{config.settings?.thankYouMessage || '感谢你的参与!'}
|
||||
</p>
|
||||
{submissionId && (
|
||||
<p className="text-center text-xs text-muted-foreground mt-4">
|
||||
提交编号:{submissionId}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 问卷展示
|
||||
const questionsToShow = paginateQuestions
|
||||
? [config.questions[currentPage]]
|
||||
: config.questions
|
||||
|
||||
return (
|
||||
<div className={cn("h-full flex flex-col", className)}>
|
||||
{/* 问卷头部 */}
|
||||
<div className="rounded-lg border bg-card p-4 sm:p-6 mb-4 shrink-0">
|
||||
<h2 className="text-xl font-semibold">{config.title}</h2>
|
||||
{config.description && (
|
||||
<p className="text-muted-foreground mt-1 text-sm">{config.description}</p>
|
||||
)}
|
||||
{showProgress && (
|
||||
<div className="space-y-1 pt-3">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>进度</span>
|
||||
<span>{answeredCount} / {config.questions.length}</span>
|
||||
</div>
|
||||
<Progress value={progress} className="h-2" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 问卷内容 - 可滚动区域 */}
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="space-y-4 pr-4">
|
||||
{questionsToShow.map((question, index) => (
|
||||
<div
|
||||
key={question.id}
|
||||
className={cn(
|
||||
"p-4 rounded-lg border bg-card",
|
||||
errors[question.id] ? "border-destructive bg-destructive/5" : "border-border"
|
||||
)}
|
||||
>
|
||||
{paginateQuestions && (
|
||||
<div className="text-xs text-muted-foreground mb-2">
|
||||
问题 {currentPage + 1} / {config.questions.length}
|
||||
</div>
|
||||
)}
|
||||
{!paginateQuestions && (
|
||||
<div className="text-xs text-muted-foreground mb-2">
|
||||
{index + 1}.
|
||||
</div>
|
||||
)}
|
||||
<SurveyQuestion
|
||||
question={question}
|
||||
value={answers[question.id]}
|
||||
onChange={(value) => handleAnswerChange(question.id, value)}
|
||||
error={errors[question.id]}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{submitError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{submitError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 提交按钮区域 */}
|
||||
<div className="flex justify-between items-center py-4">
|
||||
{paginateQuestions ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => goToPage(currentPage - 1)}
|
||||
disabled={currentPage === 0 || isSubmitting}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
上一题
|
||||
</Button>
|
||||
|
||||
{currentPage === config.questions.length - 1 ? (
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
提交问卷
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => goToPage(currentPage + 1)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
下一题
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{Object.keys(errors).length > 0 && (
|
||||
<span className="text-destructive">
|
||||
还有 {Object.keys(errors).length} 个必填项未完成
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
size="lg"
|
||||
>
|
||||
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
提交问卷
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
292
dashboard/src/components/survey/survey-results.tsx
Normal file
292
dashboard/src/components/survey/survey-results.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* 问卷结果查看组件
|
||||
* 展示问卷统计数据和用户提交记录
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Loader2, Users, FileText, Clock, Star, BarChart3 } from 'lucide-react'
|
||||
import { getSurveyStats, getUserSubmissions } from '@/lib/survey-api'
|
||||
import type { SurveyConfig, SurveyStats, StoredSubmission } from '@/types/survey'
|
||||
|
||||
interface SurveyResultsProps {
|
||||
/** 问卷配置 */
|
||||
config: SurveyConfig
|
||||
/** 是否显示用户提交记录 */
|
||||
showUserSubmissions?: boolean
|
||||
/** 自定义类名 */
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SurveyResults({
|
||||
config,
|
||||
showUserSubmissions = true,
|
||||
className
|
||||
}: SurveyResultsProps) {
|
||||
const [stats, setStats] = useState<SurveyStats | null>(null)
|
||||
const [userSubmissions, setUserSubmissions] = useState<StoredSubmission[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// 获取统计数据
|
||||
const statsResult = await getSurveyStats(config.id)
|
||||
if (statsResult.success && statsResult.stats) {
|
||||
setStats(statsResult.stats)
|
||||
}
|
||||
|
||||
// 获取用户提交记录
|
||||
if (showUserSubmissions) {
|
||||
const submissionsResult = await getUserSubmissions(config.id)
|
||||
if (submissionsResult.success && submissionsResult.submissions) {
|
||||
setUserSubmissions(submissionsResult.submissions)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载数据失败')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchData()
|
||||
}, [config.id, showUserSubmissions])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className={cn("w-full max-w-3xl mx-auto", className)}>
|
||||
<CardContent className="py-12 flex items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className={cn("w-full max-w-3xl mx-auto", className)}>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
{error}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={cn("w-full max-w-3xl mx-auto", className)}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
{config.title} - 统计结果
|
||||
</CardTitle>
|
||||
{config.description && (
|
||||
<CardDescription>{config.description}</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{/* 概览统计 */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<div className="p-4 rounded-lg bg-muted/50 text-center">
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground mb-1">
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="text-sm">总提交数</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{stats?.totalSubmissions || 0}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-muted/50 text-center">
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground mb-1">
|
||||
<Users className="h-4 w-4" />
|
||||
<span className="text-sm">独立用户</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{stats?.uniqueUsers || 0}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-muted/50 text-center">
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground mb-1">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="text-sm">最后提交</span>
|
||||
</div>
|
||||
<div className="text-sm font-medium">
|
||||
{stats?.lastSubmissionAt
|
||||
? new Date(stats.lastSubmissionAt).toLocaleDateString()
|
||||
: '-'
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="stats" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="stats">问题统计</TabsTrigger>
|
||||
{showUserSubmissions && (
|
||||
<TabsTrigger value="submissions">我的提交</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="stats" className="mt-4">
|
||||
<ScrollArea className="max-h-[60vh]">
|
||||
<div className="space-y-6 pr-4">
|
||||
{config.questions.map((question, index) => {
|
||||
const qStats = stats?.questionStats[question.id]
|
||||
|
||||
return (
|
||||
<div key={question.id} className="p-4 rounded-lg border">
|
||||
<div className="text-xs text-muted-foreground mb-1">
|
||||
问题 {index + 1}
|
||||
</div>
|
||||
<div className="font-medium mb-3">{question.title}</div>
|
||||
|
||||
{qStats ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
回答人数:{qStats.answered}
|
||||
</div>
|
||||
|
||||
{/* 选择题统计 */}
|
||||
{qStats.optionCounts && question.options && (
|
||||
<div className="space-y-2">
|
||||
{question.options.map(option => {
|
||||
const count = qStats.optionCounts?.[option.value] || 0
|
||||
const percentage = qStats.answered > 0
|
||||
? (count / qStats.answered) * 100
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div key={option.id} className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>{option.label}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{count} ({percentage.toFixed(1)}%)
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={percentage} className="h-2" />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 评分/量表统计 */}
|
||||
{qStats.average !== undefined && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="h-4 w-4 text-yellow-400" />
|
||||
<span className="text-sm">
|
||||
平均分:{qStats.average.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 文本答案样本 */}
|
||||
{qStats.sampleAnswers && qStats.sampleAnswers.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
部分回答:
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{qStats.sampleAnswers.map((answer, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-sm p-2 bg-muted/50 rounded text-muted-foreground"
|
||||
>
|
||||
"{answer}"
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
暂无数据
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
{showUserSubmissions && (
|
||||
<TabsContent value="submissions" className="mt-4">
|
||||
<ScrollArea className="max-h-[60vh]">
|
||||
{userSubmissions.length === 0 ? (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
你还没有提交过这份问卷
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 pr-4">
|
||||
{userSubmissions.map((submission) => (
|
||||
<div key={submission.id} className="p-4 rounded-lg border">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Badge variant="outline">
|
||||
{new Date(submission.submittedAt).toLocaleString()}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
ID: {submission.id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{submission.answers.map((answer) => {
|
||||
const question = config.questions.find(
|
||||
q => q.id === answer.questionId
|
||||
)
|
||||
|
||||
if (!question) return null
|
||||
|
||||
// 格式化答案显示
|
||||
let displayValue: string
|
||||
if (Array.isArray(answer.value)) {
|
||||
const labels = answer.value.map(v => {
|
||||
const opt = question.options?.find(o => o.value === v)
|
||||
return opt?.label || v
|
||||
})
|
||||
displayValue = labels.join('、')
|
||||
} else if (typeof answer.value === 'number') {
|
||||
displayValue = answer.value.toString()
|
||||
} else {
|
||||
const opt = question.options?.find(
|
||||
o => o.value === answer.value
|
||||
)
|
||||
displayValue = opt?.label || answer.value
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={answer.questionId} className="text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{question.title}:
|
||||
</span>
|
||||
<span>{displayValue}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user