feat: 添加功能调查问卷,218项功能可在线打分
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Star, ChevronDown, ChevronRight, Check, Search, ClipboardList, Send } from 'lucide-react'
|
||||
import Modal from './ui/Modal'
|
||||
import { toast } from 'sonner'
|
||||
import api from '../lib/api'
|
||||
import { surveyPages, totalFeatures } from '../data/surveyData'
|
||||
|
||||
interface FeatureScore {
|
||||
score: number
|
||||
useful: '' | 'yes' | 'no' | 'maybe'
|
||||
remark: string
|
||||
}
|
||||
|
||||
export default function SurveyModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const [expandedPage, setExpandedPage] = useState<number | null>(1)
|
||||
const [scores, setScores] = useState<Record<string, FeatureScore>>({})
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const setScore = (id: string, score: number) => {
|
||||
setScores(prev => ({
|
||||
...prev,
|
||||
[id]: { ...prev[id], score: prev[id]?.score === score ? 0 : score },
|
||||
}))
|
||||
}
|
||||
|
||||
const setUseful = (id: string, useful: 'yes' | 'no' | 'maybe') => {
|
||||
setScores(prev => ({
|
||||
...prev,
|
||||
[id]: { ...prev[id], useful: prev[id]?.useful === useful ? '' : useful },
|
||||
}))
|
||||
}
|
||||
|
||||
const setRemark = (id: string, remark: string) => {
|
||||
setScores(prev => ({
|
||||
...prev,
|
||||
[id]: { ...prev[id], remark },
|
||||
}))
|
||||
}
|
||||
|
||||
const ratedCount = useMemo(() => Object.values(scores).filter(s => s.score > 0).length, [scores])
|
||||
|
||||
const filteredPages = useMemo(() => {
|
||||
if (!searchQuery.trim()) return surveyPages
|
||||
const q = searchQuery.toLowerCase()
|
||||
return surveyPages.map(p => ({
|
||||
...p,
|
||||
features: p.features.filter(f => f.name.toLowerCase().includes(q) || f.desc.toLowerCase().includes(q)),
|
||||
})).filter(p => p.features.length > 0)
|
||||
}, [searchQuery])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const items = Object.entries(scores)
|
||||
.filter(([, v]) => v.score > 0 || v.useful || v.remark)
|
||||
.map(([id, v]) => ({ featureId: id, score: v.score, useful: v.useful, remark: v.remark }))
|
||||
if (items.length === 0) {
|
||||
toast.info('请至少对一项功能进行评分')
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await api.post('/survey/submit', { items })
|
||||
toast.success(`已提交 ${items.length} 项评分,感谢您的反馈!`)
|
||||
onClose()
|
||||
} catch {
|
||||
localStorage.setItem('survey-results', JSON.stringify({ items, submittedAt: new Date().toISOString() }))
|
||||
toast.success(`已保存 ${items.length} 项评分到本地`)
|
||||
onClose()
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="功能调查问卷" size="lg" className="p-0">
|
||||
<div className="border-b border-gray-200 px-4 py-3 space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<ClipboardList className="w-4 h-4 shrink-0" />
|
||||
<span>对 {totalFeatures} 项功能打分(1=无用 ~ 5=非常有用),帮助我们改进产品</span>
|
||||
<span className="ml-auto text-xs text-primary font-medium">已评 {ratedCount}/{totalFeatures}</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索功能名称..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-8 pr-3 py-1.5 text-sm border border-gray-200 rounded-md focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 页面列表 */}
|
||||
<div className="max-h-[60vh] overflow-y-auto">
|
||||
{filteredPages.map(page => {
|
||||
const isExpanded = expandedPage === page.id || !!searchQuery.trim()
|
||||
const pageRated = page.features.filter(f => scores[f.id]?.score).length
|
||||
return (
|
||||
<div key={page.id} className="border-b border-gray-100">
|
||||
<button
|
||||
onClick={() => setExpandedPage(isExpanded && !searchQuery ? null : page.id)}
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 hover:bg-gray-50 text-left"
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="w-4 h-4 text-gray-400 shrink-0" /> : <ChevronRight className="w-4 h-4 text-gray-400 shrink-0" />}
|
||||
<span className="text-sm font-medium text-gray-700">{page.id}. {page.name}</span>
|
||||
<span className="ml-auto text-xs text-gray-400">{page.features.length} 项{pageRated > 0 && ` · 已评 ${pageRated}`}</span>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-3 space-y-2">
|
||||
{page.features.map(f => {
|
||||
const s = scores[f.id]
|
||||
return (
|
||||
<div key={f.id} className="flex items-start gap-3 py-1.5 rounded-md hover:bg-gray-50 px-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-gray-700">{f.name}</div>
|
||||
<div className="text-xs text-gray-400">{f.desc}</div>
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
{[1, 2, 3, 4, 5].map(n => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => setScore(f.id, n)}
|
||||
className="p-0.5"
|
||||
aria-label={`${n}星`}
|
||||
>
|
||||
<Star
|
||||
className={`w-4 h-4 ${(s?.score || 0) >= n ? 'text-amber-400 fill-amber-400' : 'text-gray-300'}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
<div className="flex gap-1 ml-3">
|
||||
{([['yes', '有用', 'text-emerald-600 border-emerald-200 bg-emerald-50'], ['no', '无用', 'text-rose-600 border-rose-200 bg-rose-50'], ['maybe', '待定', 'text-amber-600 border-amber-200 bg-amber-50']] as const).map(([val, label, cls]) => (
|
||||
<button
|
||||
key={val}
|
||||
onClick={() => setUseful(f.id, val)}
|
||||
className={`px-2 py-0.5 text-xs rounded border transition-all ${s?.useful === val ? cls : 'text-gray-400 border-gray-200 hover:bg-gray-100'}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{(s?.score > 0 || s?.useful) && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="优化建议(可选)"
|
||||
value={s?.remark || ''}
|
||||
onChange={(e) => setRemark(f.id, e.target.value)}
|
||||
className="mt-1.5 w-full text-xs px-2 py-1 border border-gray-200 rounded focus:outline-none focus:border-primary"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 底部提交 */}
|
||||
<div className="border-t border-gray-200 px-4 py-3 flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500">已评分 {ratedCount} / {totalFeatures} 项</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-3 py-1.5 text-sm text-gray-600 border border-gray-200 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || ratedCount === 0}
|
||||
className="px-4 py-1.5 text-sm text-white bg-primary rounded-md hover:bg-primary/90 disabled:opacity-50 flex items-center gap-1.5"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
{submitting ? '提交中...' : '提交问卷'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { ChevronDown, Settings as SettingsIcon, Bell, Menu, HelpCircle, Smartphone } from 'lucide-react'
|
||||
import { ChevronDown, Settings as SettingsIcon, Bell, Menu, HelpCircle, Smartphone, ClipboardList } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
@@ -7,6 +7,7 @@ import api from '../../lib/api'
|
||||
import Breadcrumb from './Breadcrumb'
|
||||
import HelpModal from '../HelpModal'
|
||||
import PortalQRModal from '../PortalQRModal'
|
||||
import SurveyModal from '../SurveyModal'
|
||||
|
||||
export default function TopNav({ onMenuClick }: { onMenuClick?: () => void }) {
|
||||
const navigate = useNavigate()
|
||||
@@ -14,6 +15,7 @@ export default function TopNav({ onMenuClick }: { onMenuClick?: () => void }) {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [helpOpen, setHelpOpen] = useState(false)
|
||||
const [portalQROpen, setPortalQROpen] = useState(false)
|
||||
const [surveyOpen, setSurveyOpen] = useState(false)
|
||||
|
||||
const { data: dashboardData } = useQuery<any>({
|
||||
queryKey: ['dashboard'],
|
||||
@@ -58,6 +60,14 @@ export default function TopNav({ onMenuClick }: { onMenuClick?: () => void }) {
|
||||
<HelpCircle className="w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
<HelpModal open={helpOpen} onClose={() => setHelpOpen(false)} />
|
||||
<button
|
||||
onClick={() => setSurveyOpen(true)}
|
||||
className="p-1.5 rounded-md hover:bg-gray-100"
|
||||
aria-label="功能调查"
|
||||
>
|
||||
<ClipboardList className="w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
<SurveyModal open={surveyOpen} onClose={() => setSurveyOpen(false)} />
|
||||
<Link to="/settings" className="p-1.5 rounded-md hover:bg-gray-100" aria-label="设置">
|
||||
<SettingsIcon className="w-4 h-4 text-gray-600" />
|
||||
</Link>
|
||||
|
||||
Reference in New Issue
Block a user