feat: 添加功能调查问卷,218项功能可在线打分

This commit is contained in:
selfrelease
2026-07-28 14:39:13 +08:00
parent 97c6ca9bab
commit 1c45056083
3 changed files with 649 additions and 1 deletions
+185
View File
@@ -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>
)
}
+11 -1
View File
@@ -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>