feat: 新增验收测试清单(199项24域)替代功能调查问卷; 修复工作台评分环文字重叠

- 新增 acceptance-test.html 交互式验收测试页面(验收人姓名/自动补全/自动统计/CSV+Excel下载)
- 新增 AcceptanceTestModal 组件, TopNav 中用验收测试弹窗替代 SurveyModal
- Settings 页问卷导出替换为验收测试结果导出(支持多验收人汇总MD)
- 修复 Dashboard 合规健康度评分环文字重叠: 圆环增大至w-32, 加overflow-hidden+truncate
This commit is contained in:
selfrelease
2026-08-02 09:22:51 +08:00
parent 787897e6d3
commit 68ebe6a4ff
7 changed files with 2619 additions and 96 deletions
@@ -0,0 +1,57 @@
import { useEffect, useState } from 'react'
import { X } from 'lucide-react'
/**
* 验收测试弹窗 — 全屏加载 acceptance-test.html,支持验收人姓名、自动统计、按姓名保存、下载结果
*/
export default function AcceptanceTestModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const [show, setShow] = useState(false)
useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden'
requestAnimationFrame(() => setShow(true))
} else {
document.body.style.overflow = ''
setShow(false)
}
return () => {
document.body.style.overflow = ''
}
}, [open])
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className={`fixed inset-0 bg-black/50 transition-opacity duration-200 ${show ? 'opacity-100' : 'opacity-0'}`}
onClick={onClose}
/>
<div
className={`relative bg-white rounded-lg shadow-xl w-full h-[95vh] transition-all duration-200 ${
show ? 'opacity-100 scale-100' : 'opacity-0 scale-95'
}`}
>
{/* 弹窗头部 */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-gray-200">
<h3 className="font-medium text-gray-900 text-sm"></h3>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700 p-1 rounded-md hover:bg-gray-100 transition-colors"
aria-label="关闭"
>
<X className="w-4 h-4" />
</button>
</div>
{/* iframe 加载验收测试页面 */}
<iframe
src="/acceptance-test.html"
className="w-full"
style={{ height: 'calc(95vh - 42px)', border: 'none' }}
title="验收测试清单"
/>
</div>
</div>
)
}
+5 -5
View File
@@ -7,7 +7,7 @@ import { dashboardApi } from '../../lib/api-services'
import Breadcrumb from './Breadcrumb'
import HelpModal from '../HelpModal'
import PortalQRModal from '../PortalQRModal'
import SurveyModal from '../SurveyModal'
import AcceptanceTestModal from '../AcceptanceTestModal'
export default function TopNav({ onMenuClick }: { onMenuClick?: () => void }) {
const navigate = useNavigate()
@@ -15,7 +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 [acceptanceOpen, setAcceptanceOpen] = useState(false)
const { data: dashboardData } = useQuery<any>({
queryKey: ['dashboard'],
@@ -58,13 +58,13 @@ export default function TopNav({ onMenuClick }: { onMenuClick?: () => void }) {
</button>
<HelpModal open={helpOpen} onClose={() => setHelpOpen(false)} />
<button
onClick={() => setSurveyOpen(true)}
onClick={() => setAcceptanceOpen(true)}
className="p-1.5 rounded-md hover:bg-gray-100"
aria-label="功能调查"
aria-label="验收测试"
>
<ClipboardList className="w-4 h-4 text-gray-600" />
</button>
<SurveyModal open={surveyOpen} onClose={() => setSurveyOpen(false)} />
<AcceptanceTestModal open={acceptanceOpen} onClose={() => setAcceptanceOpen(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>
+4 -4
View File
@@ -265,7 +265,7 @@ export default function Dashboard() {
<Card>
<div className="flex items-center gap-4">
{/* SVG 环形评分 */}
<div className="relative w-28 h-28 shrink-0">
<div className="relative w-32 h-32 shrink-0">
<ResponsiveContainer width="100%" height="100%">
<RadialBarChart
innerRadius="70%"
@@ -278,9 +278,9 @@ export default function Dashboard() {
<RadialBar background dataKey="value" cornerRadius={8} />
</RadialBarChart>
</ResponsiveContainer>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className={`text-2xl font-bold ${complianceScore?.level === 'safe' ? 'text-safe' : complianceScore?.level === 'warning' ? 'text-warning' : 'text-danger'}`}>{complianceScore?.overallScore ?? complianceScore?.totalScore ?? 0}</span>
<span className="text-xs text-gray-500">{complianceScore?.levelLabel ?? complianceScore?.summary ?? ''}</span>
<div className="absolute inset-0 flex flex-col items-center justify-center px-2 overflow-hidden">
<span className={`text-2xl font-bold leading-none ${complianceScore?.level === 'safe' ? 'text-safe' : complianceScore?.level === 'warning' ? 'text-warning' : 'text-danger'}`}>{complianceScore?.overallScore ?? complianceScore?.totalScore ?? 0}</span>
<span className="text-xs text-gray-500 mt-1 truncate max-w-full">{complianceScore?.levelLabel ?? ''}</span>
</div>
</div>
{/* 5 维度评分 */}
+55 -87
View File
@@ -8,7 +8,7 @@ import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
import Modal from '../components/ui/Modal'
import { surveyPages, totalFeatures } from '../data/surveyData'
export default function Settings() {
const queryClient = useQueryClient()
@@ -526,125 +526,93 @@ function ExportSettings() {
<Download className="w-4 h-4 mr-1" />{exporting ? '导出中...' : '导出选中数据'}
</Button>
{/* 问卷结果分析导出 */}
{/* 验收测试结果导出 */}
<div className="border-t border-gray-200 pt-3 mt-3">
<div className="flex items-center gap-2 mb-2">
<ClipboardList className="w-4 h-4 text-primary" />
<h3 className="text-sm font-medium"></h3>
<h3 className="text-sm font-medium"></h3>
</div>
<p className="text-xs text-gray-500 mb-2"> Markdown </p>
<Button variant="secondary" size="sm" onClick={handleSurveyExport}>
<Download className="w-4 h-4 mr-1" />MD
<p className="text-xs text-gray-500 mb-2"></p>
<Button variant="secondary" size="sm" onClick={handleAcceptanceExport}>
<Download className="w-4 h-4 mr-1" />MD
</Button>
</div>
</div>
)
}
/** 导出问卷结果为 Markdown 文档 */
function handleSurveyExport() {
/** 导出验收测试结果为 Markdown 文档 */
function handleAcceptanceExport() {
try {
const raw = localStorage.getItem('survey-results')
if (!raw) {
toast.info('暂无已提交的问卷结果,请先在功能调查问卷中提交')
return
}
const data = JSON.parse(raw)
const items: Array<{ featureId: string; score: number; useful: string; remark: string }> = data.items || []
if (items.length === 0) {
toast.info('问卷结果为空')
const PREFIX = 'turbohr_acceptance_'
const keys = Object.keys(localStorage).filter(k => k.startsWith(PREFIX))
if (keys.length === 0) {
toast.info('暂无已保存的验收结果,请先在顶部「验收测试」中填写并保存')
return
}
const scoreMap = new Map(items.map(i => [i.featureId, i]))
const usefulLabels: Record<string, string> = { yes: '有用', no: '无用', maybe: '待定', '': '' }
const submittedAt = data.submittedAt ? new Date(data.submittedAt).toLocaleString('zh-CN') : ''
let md = `# 功能调查问卷结果分析\n\n`
const resultMap: Record<string, string> = { pass: '✅通过', fail: '❌失败', partial: '⚠️部分通过', untested: '🚫未测试' }
let md = `# TurboHR 验收测试结果\n\n`
md += `> **导出时间:** ${new Date().toLocaleString('zh-CN')}\n`
if (submittedAt) md += `> **提交时间** ${submittedAt}\n`
md += `> **总功能数:** ${totalFeatures}\n`
md += `> **已评功能数:** ${items.length}\n\n`
md += `> **验收人数** ${keys.length}\n\n`
const rated = items.filter(i => i.score > 0)
const avgScore = rated.length > 0 ? (rated.reduce((s, i) => s + i.score, 0) / rated.length).toFixed(2) : '0'
const usefulCount = items.filter(i => i.useful === 'yes').length
const notUsefulCount = items.filter(i => i.useful === 'no').length
const maybeCount = items.filter(i => i.useful === 'maybe').length
for (const key of keys) {
const raw = localStorage.getItem(key)
if (!raw) continue
const data = JSON.parse(raw)
const name = data.verifier || key.substring(PREFIX.length)
const results: Record<string, string> = data.results || {}
const remarks: Record<string, string> = data.remarks || {}
md += `## 统计概览\n\n`
md += `| 指标 | 数值 |\n|------|------|\n`
md += `| 已评功能数 | ${items.length} / ${totalFeatures} |\n`
md += `| 平均评分 | ${avgScore} / 5 |\n`
md += `| 标记有用 | ${usefulCount} |\n`
md += `| 标记无用 | ${notUsefulCount} |\n`
md += `| 标记待定 | ${maybeCount} |\n\n`
const values = Object.values(results)
const pass = values.filter(v => v === 'pass').length
const fail = values.filter(v => v === 'fail').length
const partial = values.filter(v => v === 'partial').length
const untested = values.filter(v => v === 'untested' || !v).length
const total = values.length
const tested = pass + fail + partial
const rate = total > 0 ? Math.round(tested / total * 100) : 0
md += `## 评分分布\n\n`
for (let s = 5; s >= 1; s--) {
const count = rated.filter(i => i.score === s).length
const pct = rated.length > 0 ? ((count / rated.length) * 100).toFixed(1) : '0'
md += `- **${s}星**${count} 项(${pct}%\n`
}
md += `\n`
md += `## 验收人:${name}\n\n`
if (data.savedAt) md += `> 保存时间:${new Date(data.savedAt).toLocaleString('zh-CN')}\n`
md += `\n### 统计\n\n`
md += `| 指标 | 数值 |\n|------|------|\n`
md += `| 总项数 | ${total} |\n`
md += `| 通过 | ${pass} |\n`
md += `| 失败 | ${fail} |\n`
md += `| 部分通过 | ${partial} |\n`
md += `| 未测试 | ${untested} |\n`
md += `| 完成率 | ${rate}% |\n\n`
md += `## 详细评分\n\n`
if (data.conclusionResult) {
const crText: Record<string, string> = { pass: '通过', conditional: '有条件通过', fail: '不通过' }
md += `### 验收结论\n\n`
md += `- **总体结论:** ${crText[data.conclusionResult] || '未选择'}\n`
if (data.conclusionDate) md += `- **验收日期:** ${data.conclusionDate}\n`
if (data.conclusionIssues) md += `- **遗留问题:**\n${data.conclusionIssues}\n`
md += `\n`
}
for (const page of surveyPages) {
const pageItems = page.features.filter(f => scoreMap.has(f.id))
if (pageItems.length === 0) continue
md += `### ${page.id}. ${page.name}\n\n`
md += `> 📍 ${page.menu}`
if (page.tab) md += ` | Tab: ${page.tab}`
md += `\n\n`
md += `| # | 功能 | 评分 | 有用性 | 备注 |\n`
md += `|---|------|:----:|:------:|------|\n`
for (const f of pageItems) {
const s = scoreMap.get(f.id)!
const stars = '★'.repeat(s.score) + '☆'.repeat(5 - s.score)
md += `| ${f.id} | ${f.name} | ${stars} (${s.score}) | ${usefulLabels[s.useful] || ''} | ${s.remark || ''} |\n`
md += `### 明细\n\n`
md += `| # | 结果 | 备注 |\n|---|:----:|------|\n`
for (const [fid, result] of Object.entries(results)) {
md += `| ${fid} | ${resultMap[result] || '🚫未测试'} | ${remarks[fid] || ''} |\n`
}
md += `\n`
}
md += `## 低分功能(≤2分)\n\n`
const lowScore = rated.filter(i => i.score <= 2).sort((a, b) => a.score - b.score)
if (lowScore.length > 0) {
md += `| # | 功能 | 评分 | 备注 |\n|---|------|:----:|------|\n`
for (const item of lowScore) {
const feat = surveyPages.flatMap(p => p.features).find(f => f.id === item.featureId)
md += `| ${item.featureId} | ${feat?.name || item.featureId} | ${item.score} | ${item.remark || ''} |\n`
}
} else {
md += `无低分功能\n`
}
md += `\n`
md += `## 高分功能(≥4分)\n\n`
const highScore = rated.filter(i => i.score >= 4).sort((a, b) => b.score - a.score)
if (highScore.length > 0) {
md += `| # | 功能 | 评分 | 备注 |\n|---|------|:----:|------|\n`
for (const item of highScore) {
const feat = surveyPages.flatMap(p => p.features).find(f => f.id === item.featureId)
md += `| ${item.featureId} | ${feat?.name || item.featureId} | ${item.score} | ${item.remark || ''} |\n`
}
} else {
md += `无高分功能\n`
}
md += `\n---\n*由企业用工专家系统自动生成*\n`
const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `survey-results-${new Date().toISOString().slice(0, 10)}.md`
a.download = `acceptance-results-${new Date().toISOString().slice(0, 10)}.md`
a.click()
URL.revokeObjectURL(url)
toast.success('问卷结果已导出为 Markdown 文档')
toast.success(`已导出 ${keys.length} 位验收人的测试结果`)
} catch {
toast.error('导出问卷结果失败')
toast.error('导出验收结果失败')
}
}