Initial commit

This commit is contained in:
shanshuilala
2026-07-13 16:21:02 +08:00
commit da16d2ae6e
183 changed files with 87241 additions and 0 deletions
+132
View File
@@ -0,0 +1,132 @@
import React from 'react';
import { Typography } from 'antd';
const { Text } = Typography;
const KNOWN_DUPLICATES: [number, number][] = [
[1, 400], [2, 395], [3, 432], [5, 495], [8, 449],
[9, 318], [11, 483], [14, 476], [15, 468], [17, 438],
[18, 466], [19, 481], [20, 444], [22, 460], [24, 489],
[25, 486], [27, 472], [28, 496], [31, 493], [32, 504],
[36, 457], [37, 508], [38, 471], [39, 420], [40, 510],
[41, 507], [42, 455], [43, 501], [44, 502], [45, 464],
[46, 485], [47, 498], [48, 497], [49, 505], [50, 491],
[51, 506], [52, 488], [53, 500], [54, 459], [55, 478],
[56, 492], [57, 474], [58, 490], [59, 484], [61, 463],
[62, 469], [64, 503], [66, 494], [67, 509],
];
function countDuplicates(answers: Record<number, number>): number {
let count = 0;
for (const [a, b] of KNOWN_DUPLICATES) {
if (answers[a] !== undefined && answers[b] !== undefined && answers[a] !== answers[b]) {
count++;
}
}
return count;
}
interface Props {
answers: Record<number, number>;
totalQuestions: number;
responseType: string;
}
/* Arc path helper: relative to 0,0 with radius r */
function arcPath(r: number, startAngle: number, endAngle: number, sweep: boolean): string {
const large = (endAngle - startAngle) > Math.PI ? 1 : 0;
const sx = r * Math.cos(startAngle);
const sy = r * Math.sin(startAngle);
const ex = r * Math.cos(endAngle);
const ey = r * Math.sin(endAngle);
return `M 0,0 L ${sx.toFixed(1)},${sy.toFixed(1)} A ${r} ${r} 0 ${large} ${sweep ? 1 : 0} ${ex.toFixed(1)},${ey.toFixed(1)} Z`;
}
const AnswerPieChart: React.FC<Props> = ({ answers, totalQuestions, responseType }) => {
if (!answers || typeof answers !== 'object') return null;
const entries = Object.entries(answers).filter(([k]) => !k.startsWith('_'));
const answered = entries.length;
const unanswered = totalQuestions - answered;
if (answered === 0) return null;
const isYesNo = responseType === 'yesno';
const yesCount = entries.filter(([, v]) => Number(v) === 1).length;
const noCount = entries.filter(([, v]) => Number(v) === 0).length;
const otherCount = answered - yesCount - noCount;
const duplicateCount = isYesNo ? countDuplicates(answers) : 0;
const segments: { label: string; value: number; color: string }[] = [];
if (isYesNo) {
if (yesCount > 0) segments.push({ label: `选「是」(${yesCount})`, value: yesCount, color: '#22c55e' });
if (noCount > 0) segments.push({ label: `选「否」(${noCount})`, value: noCount, color: '#64748b' });
if (otherCount > 0) segments.push({ label: `其他(${otherCount})`, value: otherCount, color: '#f59e0b' });
} else {
segments.push({ label: `已回答(${answered})`, value: answered, color: '#2563eb' });
}
if (unanswered > 0) segments.push({ label: `未作答(${unanswered})`, value: unanswered, color: '#e2e8f0' });
if (duplicateCount > 0) segments.push({ label: `矛盾回答(${duplicateCount}对)`, value: duplicateCount, color: '#ef4444' });
if (segments.length === 0) return null;
const total = segments.reduce((s, seg) => s + seg.value, 0);
if (total === 0) return null;
const r = 100;
let curAngle = -Math.PI / 2;
const arcs = segments.map(seg => {
const angle = (seg.value / total) * 2 * Math.PI;
const path = seg.value === total
? `M -${r},0 A ${r},${r} 0 1,1 ${r - 0.01},0 Z`
: arcPath(r, curAngle, curAngle + angle, true);
const labelAngle = curAngle + angle / 2;
const labelR = r * 0.65;
const lx = labelR * Math.cos(labelAngle);
const ly = labelR * Math.sin(labelAngle);
curAngle += angle;
return { path, color: seg.color, label: seg.label, lx, ly, pct: Math.round((seg.value / total) * 100) };
});
const legend = segments.filter(s => s.value > 0);
return (
<div>
<Text strong style={{ fontSize: 14, marginBottom: 8, display: 'block' }}></Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 24, justifyContent: 'center' }}>
<svg viewBox="-120 -120 240 240" style={{ width: 200, height: 200, flexShrink: 0 }}>
{arcs.map((arc, i) => (
<g key={i}>
<path d={arc.path} fill={arc.color} stroke="#fff" strokeWidth={2} />
{arc.pct >= 5 && (
<text x={arc.lx} y={arc.ly} textAnchor="middle" dominantBaseline="middle"
fontSize={11} fill="#fff" fontWeight="bold">
{arc.pct}%
</text>
)}
</g>
))}
<circle cx={0} cy={0} r={35} fill="#fff" />
<text x={0} y={-6} textAnchor="middle" fontSize={13} fontWeight="bold" fill="#1e293b">
{answered}/{totalQuestions}
</text>
<text x={0} y={10} textAnchor="middle" fontSize={10} fill="#64748b"></text>
</svg>
<div>
{legend.map((seg, i) => {
const pct = Math.round((seg.value / total) * 100);
return (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
<span style={{ width: 10, height: 10, borderRadius: 2, background: seg.color, display: 'inline-block' }} />
<Text style={{ fontSize: 12 }}>{seg.label}</Text>
<Text type="secondary" style={{ fontSize: 11 }}>{pct}%</Text>
</div>
);
})}
</div>
</div>
</div>
);
};
export default AnswerPieChart;