feat: 架构升级 + 设计升级(node:sqlite 迁移 / P0 安全修复 / 游客通道 / SSE stream-ticket / 墨蓝主题 / ResultView 拆分 / 全站无障碍)+ RainID 接线整合

This commit is contained in:
2026-08-12 02:52:12 +08:00
parent 0b0144877e
commit ed6bc69ca3
82 changed files with 1974 additions and 2007 deletions
+20 -12
View File
@@ -3,7 +3,11 @@ import { Typography } from 'antd';
const { Text } = Typography;
const KNOWN_DUPLICATES: [number, number][] = [
/**
* 默认内容重复题对(原硬编码的 MMPI 常用题对)。
* 通过 duplicatePairs prop 可覆盖为其他量表的题对,或传 [] 关闭该检测。
*/
const DEFAULT_DUPLICATE_PAIRS: [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],
@@ -16,9 +20,9 @@ const KNOWN_DUPLICATES: [number, number][] = [
[62, 469], [64, 503], [66, 494], [67, 509],
];
function countDuplicates(answers: Record<number, number>): number {
function countDuplicates(answers: Record<number, number>, pairs: [number, number][]): number {
let count = 0;
for (const [a, b] of KNOWN_DUPLICATES) {
for (const [a, b] of pairs) {
if (answers[a] !== undefined && answers[b] !== undefined && answers[a] !== answers[b]) {
count++;
}
@@ -30,6 +34,8 @@ interface Props {
answers: Record<number, number>;
totalQuestions: number;
responseType: string;
/** 内容重复题对(矛盾回答检测),默认内置 MMPI 常用题对 */
duplicatePairs?: [number, number][];
}
/* Arc path helper: relative to 0,0 with radius r */
@@ -42,7 +48,7 @@ function arcPath(r: number, startAngle: number, endAngle: number, sweep: boolean
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 }) => {
const AnswerPieChart: React.FC<Props> = ({ answers, totalQuestions, responseType, duplicatePairs = DEFAULT_DUPLICATE_PAIRS }) => {
if (!answers || typeof answers !== 'object') return null;
const entries = Object.entries(answers).filter(([k]) => !k.startsWith('_'));
const answered = entries.length;
@@ -54,18 +60,18 @@ const AnswerPieChart: React.FC<Props> = ({ answers, totalQuestions, responseType
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 duplicateCount = isYesNo ? countDuplicates(answers, duplicatePairs) : 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' });
if (yesCount > 0) segments.push({ label: `选「是」(${yesCount})`, value: yesCount, color: '#15803d' });
if (noCount > 0) segments.push({ label: `选「否」(${noCount})`, value: noCount, color: '#475569' });
if (otherCount > 0) segments.push({ label: `其他(${otherCount})`, value: otherCount, color: '#b45309' });
} else {
segments.push({ label: `已回答(${answered})`, value: answered, color: '#2563eb' });
segments.push({ label: `已回答(${answered})`, value: answered, color: '#1e3a5f' });
}
if (unanswered > 0) segments.push({ label: `未作答(${unanswered})`, value: unanswered, color: '#e2e8f0' });
if (duplicateCount > 0) segments.push({ label: `矛盾回答(${duplicateCount}对)`, value: duplicateCount, color: '#ef4444' });
if (duplicateCount > 0) segments.push({ label: `矛盾回答(${duplicateCount}对)`, value: duplicateCount, color: '#b91c1c' });
if (segments.length === 0) return null;
@@ -89,12 +95,14 @@ const AnswerPieChart: React.FC<Props> = ({ answers, totalQuestions, responseType
});
const legend = segments.filter(s => s.value > 0);
const ariaLabel = legend.map(l => `${l.label} ${Math.round((l.value / total) * 100)}%`).join('');
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 }}>
<svg viewBox="-120 -120 240 240" style={{ width: 200, height: 200, flexShrink: 0 }}
role="img" aria-label={`答题分布:${ariaLabel}`}>
{arcs.map((arc, i) => (
<g key={i}>
<path d={arc.path} fill={arc.color} stroke="#fff" strokeWidth={2} />
@@ -110,7 +118,7 @@ const AnswerPieChart: React.FC<Props> = ({ answers, totalQuestions, responseType
<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>
<text x={0} y={10} textAnchor="middle" fontSize={10} fill="#475569"></text>
</svg>
<div>
{legend.map((seg, i) => {