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
@@ -53,7 +53,7 @@ const BasicInfoTab: React.FC<Props> = ({ scale, updateMeta, isEdit }) => {
<Select value={m.kind || 'standard'} onChange={v => updateMeta({ kind: v as any })}
options={[
{ value: 'standard', label: <><Tag color="blue" style={{ fontSize: 10, marginRight: 6 }}></Tag>GAD-7/PHQ-9sum计分+</> },
{ value: 'factor', label: <><Tag color="purple" style={{ fontSize: 10, marginRight: 6 }}></Tag>MMPI类directional计分+T分</> },
{ value: 'factor', label: <><Tag style={{ fontSize: 10, marginRight: 6, color: '#1e3a5f', background: 'rgba(30,58,95,0.1)', borderColor: 'transparent', borderRadius: 10 }}></Tag>MMPI类directional计分+T分</> },
{ value: 'typology', label: <><Tag color="cyan" style={{ fontSize: 10, marginRight: 6 }}></Tag>MBTI类+</> },
]} />
<Text type="secondary" style={{ fontSize: 12 }}>使</Text>
@@ -40,7 +40,7 @@ const CompositePanel: React.FC<Props> = ({ composites, factors, updateComposites
{composites.map((comp, idx) => (
<Card key={idx} size="small" style={{ marginBottom: 8, background: "#f8fafc" }}
extra={<Button icon={<DeleteOutlined />} size="small" danger onClick={() => remove(idx)} />}>
extra={<Button icon={<DeleteOutlined />} size="small" danger aria-label="删除复合分" onClick={() => remove(idx)} />}>
<Space direction="vertical" size={8} style={{ width: "100%" }}>
<Space>
<Input size="small" addonBefore="ID" value={comp.id} onChange={e => update(idx, { id: e.target.value })} style={{ width: 150 }} placeholder="如 totalIQ" />
@@ -1,82 +0,0 @@
import React from 'react';
import { Form, Select, Input, Switch, Typography, Card, Space } from 'antd';
import type { Scale } from '../../../types';
const { Text } = Typography;
interface Props {
scale: Scale;
updateScale: (updates: Partial<Scale>) => void;
}
const DataCollectionTab: React.FC<Props> = ({ scale, updateScale }) => {
const s = scale.settings;
const updateSettings = (updates: Partial<typeof s>) => {
updateScale({ settings: { ...s, ...updates } });
};
return (
<div style={{ maxWidth: 700 }}>
<Card title="用于计分" size="small" style={{ marginBottom: 16 }}>
<Form layout="vertical">
<Form.Item label="基于性别年龄评测">
<Space>
<Switch
checked={s.requireDemographics || false}
onChange={v => updateSettings({ requireDemographics: v })}
/>
<Text>{s.requireDemographics ? '测试前收集性别/年龄,用于常模匹配计分' : '不收集人口学信息'}</Text>
</Space>
</Form.Item>
{s.requireDemographics && (
<>
<Form.Item label="收集字段">
<Select
mode="multiple"
value={s.dataFields || ['gender', 'age']}
onChange={v => updateSettings({ dataFields: v })}
options={[
{ value: "gender", label: "性别" },
{ value: "age", label: "年龄" },
]}
placeholder="选择要收集的字段"
/>
</Form.Item>
<Form.Item label="提示文案">
<Input.TextArea
value={s.dataPrompt || ''}
onChange={e => updateSettings({ dataPrompt: e.target.value })}
rows={2}
placeholder="我们仅收集您的性别和年龄,用于常模对比和计分分析。"
/>
</Form.Item>
</>
)}
</Form>
</Card>
<Card title="用于数据科研收集" size="small">
<Form layout="vertical">
<Form.Item label="科研数据收集">
<Select
value={s.dataCollection || 'none'}
onChange={v => updateSettings({ dataCollection: v })}
options={[
{ value: "none", label: "不收集" },
{ value: "consent", label: "经用户同意后收集" },
{ value: "force", label: "强制收集" },
]}
/>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 4 }}>
</Text>
</Form.Item>
</Form>
</Card>
</div>
);
};
export default DataCollectionTab;
@@ -73,7 +73,7 @@ const FactorsTab: React.FC<Props> = ({ scale, updateScale }) => {
{Object.entries(factors).map(([id, f]) => (
<Card key={id} size="small" style={{ marginBottom: 8 }}
title={<Space>{id}<Tag>{f.name}</Tag></Space>}
extra={<Button icon={<DeleteOutlined />} size="small" danger onClick={() => removeFactor(id)} />}>
extra={<Button icon={<DeleteOutlined />} size="small" danger aria-label="删除因子" onClick={() => removeFactor(id)} />}>
<Collapse size="small" defaultActiveKey={["basic"]} items={[
{ key: "basic", label: "基本设置", children: (
<Form layout="inline" style={{ flexWrap: "wrap", gap: 8 }}>
@@ -136,12 +136,12 @@ const FactorsTab: React.FC<Props> = ({ scale, updateScale }) => {
<Input size="small" value={r.level || ""}
onChange={(e) => { var nr = [...(f.ranges || [])]; nr[ri] = { ...nr[ri], level: e.target.value }; updateFactor(id, { ranges: nr }); }}
style={{ width: 100 }} placeholder="等级" />
<Button icon={<DeleteOutlined />} size="small" danger
<Button icon={<DeleteOutlined />} size="small" danger aria-label="删除因子区间"
onClick={() => updateFactor(id, { ranges: (f.ranges || []).filter((_, i) => i !== ri) })} />
</Space>
))}
<Button icon={<PlusOutlined />} size="small"
onClick={() => updateFactor(id, { ranges: [...(f.ranges || []), { min: 0, max: 0, level: "", color: "#2563eb", severity: 0 }] })}
onClick={() => updateFactor(id, { ranges: [...(f.ranges || []), { min: 0, max: 0, level: "", color: "#1e3a5f", severity: 0 }] })}
style={{ marginTop: 4 }}></Button>
</div>
)},
@@ -69,7 +69,7 @@ const InterpretationTab: React.FC<Props> = ({ scale, updateScale }) => {
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 12 }}>
<Text type="secondary"></Text>
<Button icon={<PlusOutlined />} type="primary" onClick={addRule}
style={{ background: "#2563eb", border: "none" }}>
style={{ background: "#1e3a5f", border: "none" }}>
</Button>
</div>
@@ -78,9 +78,9 @@ const InterpretationTab: React.FC<Props> = ({ scale, updateScale }) => {
<Card key={idx} size="small" style={{ marginBottom: 12, background: "#f8fafc" }}
extra={
<Space size="small">
<Button icon={<ArrowUpOutlined />} size="small" disabled={idx === 0} onClick={() => moveRule(idx, -1)} />
<Button icon={<ArrowDownOutlined />} size="small" disabled={idx === rules.length - 1} onClick={() => moveRule(idx, 1)} />
<Button icon={<DeleteOutlined />} size="small" danger onClick={() => removeRule(idx)} />
<Button icon={<ArrowUpOutlined />} size="small" aria-label="上移规则" disabled={idx === 0} onClick={() => moveRule(idx, -1)} />
<Button icon={<ArrowDownOutlined />} size="small" aria-label="下移规则" disabled={idx === rules.length - 1} onClick={() => moveRule(idx, 1)} />
<Button icon={<DeleteOutlined />} size="small" danger aria-label="删除规则" onClick={() => removeRule(idx)} />
</Space>
}>
<div style={{ marginBottom: 12 }}>
@@ -72,14 +72,17 @@ const JsonTab: React.FC<Props> = ({ scale, setScale }) => {
<Button icon={<FormatPainterOutlined />} onClick={handleFormat}></Button>
<Button icon={<CompressOutlined />} onClick={handleCompress}></Button>
<Button icon={<CheckCircleOutlined />} type="primary" onClick={handleApply}
style={{ background: '#2563eb', border: 'none' }}>
style={{ background: '#1e3a5f', border: 'none' }}>
</Button>
</Space>
{error ? (
<Text type="danger" style={{ fontSize: 12 }}>{error}</Text>
) : (
<Text type="success" style={{ fontSize: 12 }}> JSON</Text>
<Space size={4} style={{ color: '#15803d' }}>
<CheckCircleOutlined />
<Text style={{ fontSize: 12 }}>JSON</Text>
</Space>
)}
</div>
@@ -93,7 +96,7 @@ const JsonTab: React.FC<Props> = ({ scale, setScale }) => {
fontSize: 13,
lineHeight: 1.5,
padding: 16,
border: error ? '2px solid #ef4444' : '1px solid #e2e8f0',
border: error ? '2px solid #b91c1c' : '1px solid #e2e8f0',
borderRadius: 8,
background: '#fafbfc',
color: '#0f172a',
@@ -75,7 +75,7 @@ const NormsManager: React.FC<Props> = ({ norms, updateNorms }) => {
size="small"
style={{ marginBottom: 8, background: "#f8fafc" }}
title={<Space><Tag color="blue">{group.id}</Tag><Text strong>{group.label}</Text></Space>}
extra={<Button icon={<DeleteOutlined />} size="small" danger onClick={() => removeGroup(idx)} />}
extra={<Button icon={<DeleteOutlined />} size="small" danger aria-label="删除常模组" onClick={() => removeGroup(idx)} />}
>
<Tabs
size="small"
@@ -155,7 +155,7 @@ const NormsManager: React.FC<Props> = ({ norms, updateNorms }) => {
{ title: "原始分(Max)", dataIndex: "rawMax", width: 100, render: (v: number, _: any, i: number) => <InputNumber size="small" value={v} onChange={n => updateLookupEntry(idx, i, { rawMax: n ?? 0 })} style={{ width: 80 }} /> },
{ title: "标准分", dataIndex: "standardScore", width: 100, render: (v: number, _: any, i: number) => <InputNumber size="small" value={v} onChange={n => updateLookupEntry(idx, i, { standardScore: n ?? 0 })} style={{ width: 80 }} /> },
{ title: "百分位", dataIndex: "percentile", width: 80, render: (v: number | undefined, _: any, i: number) => <InputNumber size="small" value={v} onChange={n => updateLookupEntry(idx, i, { percentile: n ?? undefined })} style={{ width: 60 }} /> },
{ title: "", width: 40, render: (_: any, __: any, i: number) => <Button icon={<DeleteOutlined />} size="small" danger onClick={() => removeLookupEntry(idx, i)} /> },
{ title: "", width: 40, render: (_: any, __: any, i: number) => <Button icon={<DeleteOutlined />} size="small" danger aria-label="删除查表项" onClick={() => removeLookupEntry(idx, i)} /> },
]}
/>
<Button icon={<PlusOutlined />} size="small" onClick={() => addLookupEntry(idx)} style={{ marginTop: 8 }}></Button>
@@ -118,7 +118,7 @@ const ProcessTab: React.FC<Props> = ({ scale, updateScale }) => {
sections.map((sec, idx) => (
<Card key={idx} size="small" style={{ marginBottom: 8, background: "#f8fafc" }}
title={<Space><Tag color="blue"> {idx + 1}</Tag><Input size="small" value={sec.title} onChange={(e) => updateSection(idx, { title: e.target.value })} placeholder="区块标题" style={{ width: 200 }} /></Space>}
extra={<Button icon={<DeleteOutlined />} size="small" danger onClick={() => removeSection(idx)} />}>
extra={<Button icon={<DeleteOutlined />} size="small" danger aria-label="删除题组" onClick={() => removeSection(idx)} />}>
<Space direction="vertical" size={8} style={{ width: "100%" }}>
<Space wrap>
<Input.TextArea size="small" value={sec.instruction || ""} onChange={(e) => updateSection(idx, { instruction: e.target.value })} placeholder="指导语(可选)" style={{ width: 400 }} rows={1} />
@@ -162,7 +162,7 @@ const ProcessTab: React.FC<Props> = ({ scale, updateScale }) => {
extra={<Button icon={<PlusOutlined />} size="small" onClick={addSkipRule}></Button>}>
{skipRules.map((rule, idx) => (
<Card key={idx} size="small" style={{ marginBottom: 8, background: "#f8fafc" }}
extra={<Button icon={<DeleteOutlined />} size="small" danger onClick={() => removeSkipRule(idx)} />}>
extra={<Button icon={<DeleteOutlined />} size="small" danger aria-label="删除跳题规则" onClick={() => removeSkipRule(idx)} />}>
<Space wrap>
<Input size="small" value={rule.id} onChange={(e) => updateSkipRule(idx, { id: e.target.value })} style={{ width: 100 }} placeholder="规则ID" />
<Select size="small" value={rule.condition.field} onChange={(v) => updateSkipRule(idx, { condition: { ...rule.condition, field: v } })} style={{ width: 180 }}>
@@ -56,9 +56,9 @@ const ProfilePanel: React.FC<Props> = ({ profileTypes, factors, updateProfileTyp
title={<Input size="small" value={pt.name} onChange={e => update(idx, { name: e.target.value })} placeholder="剖面类型名称" style={{ width: 200 }} />}
extra={
<Space size="small">
<Button icon={<ArrowUpOutlined />} size="small" disabled={idx === 0} onClick={() => move(idx, -1)} />
<Button icon={<ArrowDownOutlined />} size="small" disabled={idx === profileTypes.length - 1} onClick={() => move(idx, 1)} />
<Button icon={<DeleteOutlined />} size="small" danger onClick={() => remove(idx)} />
<Button icon={<ArrowUpOutlined />} size="small" aria-label="上移剖面类型" disabled={idx === 0} onClick={() => move(idx, -1)} />
<Button icon={<ArrowDownOutlined />} size="small" aria-label="下移剖面类型" disabled={idx === profileTypes.length - 1} onClick={() => move(idx, 1)} />
<Button icon={<DeleteOutlined />} size="small" danger aria-label="删除剖面类型" onClick={() => remove(idx)} />
</Space>
}>
<Space direction="vertical" size={8} style={{ width: "100%" }}>
@@ -161,9 +161,9 @@ const QuestionsResponseTab: React.FC<Props> = ({ scale, updateScale }) => {
title: '操作', width: 110,
render: (_: any, __: Question, idx: number) => (
<Space size="small">
<Button icon={<ArrowUpOutlined />} size="small" disabled={idx === 0} onClick={() => moveQuestion(idx, -1)} />
<Button icon={<ArrowDownOutlined />} size="small" disabled={idx === questions.length - 1} onClick={() => moveQuestion(idx, 1)} />
<Button icon={<DeleteOutlined />} size="small" danger onClick={() => removeQuestion(idx)} />
<Button icon={<ArrowUpOutlined />} size="small" aria-label="上移题目" disabled={idx === 0} onClick={() => moveQuestion(idx, -1)} />
<Button icon={<ArrowDownOutlined />} size="small" aria-label="下移题目" disabled={idx === questions.length - 1} onClick={() => moveQuestion(idx, 1)} />
<Button icon={<DeleteOutlined />} size="small" danger aria-label="删除题目" onClick={() => removeQuestion(idx)} />
</Space>
),
},
@@ -182,7 +182,7 @@ const QuestionsResponseTab: React.FC<Props> = ({ scale, updateScale }) => {
<Space>
<Button icon={<ImportOutlined />} onClick={() => setImportVisible(true)}></Button>
<Button icon={<PlusOutlined />} type="primary" onClick={addQuestion}
style={{ background: '#2563eb', border: 'none' }}></Button>
style={{ background: '#1e3a5f', border: 'none' }}></Button>
</Space>
</div>
<Table columns={columns} dataSource={filtered} rowKey="id" pagination={false}
@@ -224,7 +224,7 @@ const QuestionsResponseTab: React.FC<Props> = ({ scale, updateScale }) => {
onChange={(e) => updateOption(idx, "label", e.target.value)} />
<Input style={{ width: 120 }} value={opt.labelEn || ""} placeholder="英文标签(可选)"
onChange={(e) => updateOption(idx, "labelEn", e.target.value || undefined)} />
<Button icon={<DeleteOutlined />} danger size="small" onClick={() => removeOption(idx)} />
<Button icon={<DeleteOutlined />} danger size="small" aria-label="删除选项" onClick={() => removeOption(idx)} />
</Space>
</Card>
))}
@@ -273,7 +273,7 @@ const QuestionsResponseTab: React.FC<Props> = ({ scale, updateScale }) => {
copy[oi] = { ...copy[oi], score: v ?? undefined };
setOptionsModal({ ...optionsModal, options: copy });
}} placeholder="得分" style={{ width: 80 }} />
<Button icon={<DeleteOutlined />} size="small" danger onClick={() => {
<Button icon={<DeleteOutlined />} size="small" danger aria-label="删除专属选项" onClick={() => {
setOptionsModal({ ...optionsModal, options: optionsModal.options.filter((_, i) => i !== oi) });
}} />
</Space>
@@ -1,155 +0,0 @@
import React, { useState } from 'react';
import { Button, Input, Table, Space, Modal, message, Select } from 'antd';
import { PlusOutlined, DeleteOutlined, ImportOutlined, ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
import type { Scale, Question } from '../../../types';
interface Props {
scale: Scale;
updateScale: (updates: Partial<Scale>) => void;
}
const QuestionsTab: React.FC<Props> = ({ scale, updateScale }) => {
const [search, setSearch] = useState('');
const [importVisible, setImportVisible] = useState(false);
const [importText, setImportText] = useState('');
const [editIdx, setEditIdx] = useState<number | null>(null);
// Get unique factors from questions
const factors = [...new Set(scale.questions.map(q => q.factor).filter(Boolean))];
const filtered = scale.questions.filter(q =>
!search || q.text.includes(search) || String(q.id).includes(search)
);
const addQuestion = () => {
const maxId = scale.questions.length > 0 ? Math.max(...scale.questions.map(q => q.id)) + 1 : 1;
updateScale({ questions: [...scale.questions, { id: maxId, text: '' }] });
setEditIdx(scale.questions.length);
};
const removeQuestion = (idx: number) => {
const newQ = scale.questions.filter((_, i) => i !== idx);
updateScale({ questions: newQ });
if (editIdx === idx) setEditIdx(null);
};
const updateQuestion = (idx: number, updates: Partial<Question>) => {
const newQ = [...scale.questions];
newQ[idx] = { ...newQ[idx], ...updates };
updateScale({ questions: newQ });
};
const moveQuestion = (idx: number, dir: number) => {
const newIdx = idx + dir;
if (newIdx < 0 || newIdx >= scale.questions.length) return;
const newQ = [...scale.questions];
[newQ[idx], newQ[newIdx]] = [newQ[newIdx], newQ[idx]];
updateScale({ questions: newQ });
};
const handleBulkImport = () => {
const lines = importText.split('\n').filter(l => l.trim());
const newQuestions: Question[] = [];
const startId = scale.questions.length > 0 ? Math.max(...scale.questions.map(q => q.id)) + 1 : 1;
lines.forEach((line, i) => {
// Support formats: "1. question" or "question" or "id\tquestion"
let text = line.trim();
let id = startId + i;
const tabMatch = text.match(/^(\d+)\t(.+)/);
const dotMatch = text.match(/^(\d+)[.\s、]+(.+)/);
if (tabMatch) { id = parseInt(tabMatch[1]); text = tabMatch[2]; }
else if (dotMatch) { id = parseInt(dotMatch[1]); text = dotMatch[2]; }
if (text) newQuestions.push({ id, text });
});
if (newQuestions.length === 0) { message.warning('未识别到有效题目'); return; }
updateScale({ questions: [...scale.questions, ...newQuestions] });
setImportVisible(false);
setImportText('');
message.success(`已导入 ${newQuestions.length} 道题目`);
};
const columns = [
{ title: '#', width: 50, render: (_: any, __: any, idx: number) => idx + 1 },
{
title: '题目内容', dataIndex: 'text', ellipsis: true,
render: (text: string, _: any, idx: number) => editIdx === idx ? (
<Input value={text} autoFocus onChange={e => updateQuestion(idx, { text: e.target.value })}
onBlur={() => setEditIdx(null)} onPressEnter={() => setEditIdx(null)} />
) : (
<span onClick={() => setEditIdx(idx)} style={{ cursor: 'pointer' }}>{text || '(点击编辑)'}</span>
),
},
{
title: '反向', width: 60,
render: (_: any, q: Question, idx: number) => (
<input type="checkbox" checked={!!q.reverse}
onChange={e => updateQuestion(idx, { reverse: e.target.checked })} />
),
},
{
title: '因子', width: 120,
render: (_: any, q: Question, idx: number) => (
<Select size="small" value={q.factor || undefined} allowClear placeholder="-"
style={{ width: 100 }}
onChange={v => updateQuestion(idx, { factor: v || undefined })}
options={factors.map(f => ({ value: f, label: f }))} />
),
},
{
title: '操作', width: 120,
render: (_: any, __: Question, idx: number) => (
<Space size="small">
<Button icon={<ArrowUpOutlined />} size="small" disabled={idx === 0}
onClick={() => moveQuestion(idx, -1)} />
<Button icon={<ArrowDownOutlined />} size="small" disabled={idx === scale.questions.length - 1}
onClick={() => moveQuestion(idx, 1)} />
<Button icon={<DeleteOutlined />} size="small" danger onClick={() => removeQuestion(idx)} />
</Space>
),
},
];
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12 }}>
<Space>
<Input placeholder="搜索题目..." value={search} onChange={e => setSearch(e.target.value)}
allowClear style={{ width: 250 }} />
<span style={{ color: '#94a3b8' }}> {scale.questions.length} </span>
</Space>
<Space>
<Button icon={<ImportOutlined />} onClick={() => setImportVisible(true)}></Button>
<Button icon={<PlusOutlined />} type="primary" onClick={addQuestion}
style={{ background: '#2563eb', border: 'none' }}>
</Button>
</Space>
</div>
<Table columns={columns} dataSource={filtered} rowKey="id" pagination={false}
size="small" scroll={{ y: 500 }}
locale={{ emptyText: '暂无题目,点击"添加题目"或"批量导入"' }} />
<Modal title="批量导入题目" open={importVisible} onOk={handleBulkImport}
onCancel={() => { setImportVisible(false); setImportText(''); }}
okText="导入" cancelText="取消" width={600}>
<p style={{ marginBottom: 8, color: '#64748b' }}></p>
<ul style={{ marginBottom: 12, color: '#64748b', fontSize: 13 }}>
<li></li>
<li>1. </li>
<li>Tab分隔1[TAB]</li>
</ul>
<Input.TextArea rows={15} value={importText} onChange={e => setImportText(e.target.value)}
placeholder={"感到心情低落\n感到紧张焦虑\n入睡困难\n..."} style={{ fontFamily: 'monospace' }} />
</Modal>
</div>
);
};
export default QuestionsTab;
@@ -106,7 +106,7 @@ const ResponseTab: React.FC<Props> = ({ scale, updateScale }) => {
onChange={(e) => updateOption(idx, "label", e.target.value)} />
<Input style={{ width: 120 }} value={opt.labelEn || ""} placeholder="英文标签(可选)"
onChange={(e) => updateOption(idx, "labelEn", e.target.value || undefined)} />
<Button icon={<DeleteOutlined />} danger size="small" onClick={() => removeOption(idx)} />
<Button icon={<DeleteOutlined />} danger size="small" aria-label="删除选项" onClick={() => removeOption(idx)} />
</Space>
</Card>
))}
@@ -27,7 +27,7 @@ const ScoringTab: React.FC<Props> = ({ scale, updateScale }) => {
const ranges = scoring.ranges || [];
const addRange = () => {
updateScoring({ ranges: [...ranges, { min: 0, max: 0, level: "", color: "#2563eb", severity: 0 }] });
updateScoring({ ranges: [...ranges, { min: 0, max: 0, level: "", color: "#1e3a5f", severity: 0 }] });
};
const removeRange = (idx: number) => {
updateScoring({ ranges: ranges.filter((_, i) => i !== idx) });
@@ -48,7 +48,7 @@ const ScoringTab: React.FC<Props> = ({ scale, updateScale }) => {
{/* Guide card */}
<Card size="small" style={{ marginBottom: 16, background: '#f8fafc' }}>
<Space>
<InfoCircleOutlined style={{ color: '#2563eb' }} />
<InfoCircleOutlined style={{ color: '#1e3a5f' }} />
<Text type="secondary" style={{ fontSize: 13 }}>
</Text>
@@ -115,7 +115,7 @@ const ScoringTab: React.FC<Props> = ({ scale, updateScale }) => {
{ title: "等级", dataIndex: "level", render: (v, _, idx) => <Input size="small" value={v} onChange={(e) => updateRange(idx, { level: e.target.value })} /> },
{ title: "颜色", dataIndex: "color", width: 100, render: (v, _, idx) => <Space size={4}><input type="color" value={v} onChange={(e) => updateRange(idx, { color: e.target.value })} style={{ width: 30, height: 30, border: "none", cursor: "pointer" }} />{v}</Space> },
{ title: "严重度", dataIndex: "severity", width: 70, render: (v, _, idx) => <InputNumber size="small" value={v} min={0} max={10} onChange={(n) => updateRange(idx, { severity: n || 0 })} style={{ width: 60 }} /> },
{ width: 40, render: (_, __, idx) => <Button icon={<DeleteOutlined />} size="small" danger onClick={() => removeRange(idx)} /> },
{ width: 40, render: (_, __, idx) => <Button icon={<DeleteOutlined />} size="small" danger aria-label="删除计分区间" onClick={() => removeRange(idx)} /> },
]} />
<Button icon={<PlusOutlined />} onClick={addRange} size="small" style={{ marginTop: 8 }}></Button>
</Card>
@@ -1 +0,0 @@
21
@@ -1,200 +0,0 @@
import React from "react";
import { Card, Button, Space, Typography, Input, InputNumber, Select, Tag } from "antd";
import { PlusOutlined, DeleteOutlined, SplitCellsOutlined, ShareAltOutlined } from "@ant-design/icons";
import type { Scale, Section, SkipRule } from "../../../types";
const { Text } = Typography;
interface Props {
scale: Scale;
updateScale: (updates: Partial<Scale>) => void;
}
const TestFlowTab: React.FC<Props> = ({ scale, updateScale }) => {
const settings = scale.settings;
const sections = settings.sections || [];
const skipRules = settings.skipRules || [];
const questions = scale.questions;
const updateSettings = (updates: Partial<typeof settings>) => {
updateScale({ settings: { ...settings, ...updates } });
};
// ===== Sections =====
const addSection = () => {
updateSettings({
sections: [...sections, { id: `section-${sections.length + 1}`, title: "", questionIds: [] }],
});
};
const removeSection = (idx: number) => {
updateSettings({ sections: sections.filter((_, i) => i !== idx) });
};
const updateSection = (idx: number, updates: Partial<Section>) => {
const copy = [...sections];
copy[idx] = { ...copy[idx], ...updates };
updateSettings({ sections: copy });
};
const moveQuestionToSection = (qId: number, secIdx: number) => {
const copy = [...sections];
// Remove from all other sections
for (const sec of copy) {
sec.questionIds = sec.questionIds.filter((id) => id !== qId);
}
if (secIdx >= 0 && !copy[secIdx].questionIds.includes(qId)) {
copy[secIdx].questionIds = [...copy[secIdx].questionIds, qId];
}
updateSettings({ sections: copy });
};
// ===== Skip Rules =====
const addSkipRule = () => {
updateSettings({
skipRules: [...skipRules, { id: `skip-${skipRules.length + 1}`, condition: { field: `question.${questions[0]?.id || 1}`, op: ">=", value: 0 }, action: "skipTo", target: (questions[questions.length - 1]?.id || 1) + 1 }],
});
};
const removeSkipRule = (idx: number) => {
updateSettings({ skipRules: skipRules.filter((_, i) => i !== idx) });
};
const updateSkipRule = (idx: number, updates: Partial<SkipRule>) => {
const copy = [...skipRules];
copy[idx] = { ...copy[idx], ...updates };
updateSettings({ skipRules: copy });
};
const unassignedQuestions = questions.filter(
(q) => !sections.some((s) => s.questionIds.includes(q.id))
);
return (
<div style={{ maxWidth: 900 }}>
{/* ===== Time Limits ===== */}
<Card title="时限设置" size="small" style={{ marginBottom: 16 }}>
<Space>
<InputNumber size="small" addonBefore="总时限(分钟)" value={settings.timeLimit} min={0}
onChange={(v) => updateSettings({ timeLimit: v || undefined })} style={{ width: 180 }} />
<Text type="secondary" style={{ fontSize: 12 }}></Text>
</Space>
</Card>
{/* ===== Sections ===== */}
<Card title={<Space><SplitCellsOutlined /></Space>} size="small" style={{ marginBottom: 16 }}
extra={<Button icon={<PlusOutlined />} size="small" onClick={addSection}></Button>}>
{!settings.sections || settings.sections.length === 0 ? (
<Card style={{ textAlign: "center", padding: 20, background: "#f8fafc", marginBottom: 12 }}>
<Text type="secondary"></Text>
</Card>
) : (
sections.map((sec, idx) => (
<Card key={idx} size="small" style={{ marginBottom: 8, background: "#f8fafc" }}
title={<Space><Tag color="blue"> {idx + 1}</Tag><Input size="small" value={sec.title} onChange={(e) => updateSection(idx, { title: e.target.value })} placeholder="区块标题" style={{ width: 200 }} /></Space>}
extra={<Button icon={<DeleteOutlined />} size="small" danger onClick={() => removeSection(idx)} />}>
<Space direction="vertical" size={8} style={{ width: "100%" }}>
<Space wrap>
<Input.TextArea size="small" value={sec.instruction || ""} onChange={(e) => updateSection(idx, { instruction: e.target.value })} placeholder="指导语(可选)" style={{ width: 400 }} rows={1} />
<InputNumber size="small" addonBefore="时限(分)" value={sec.timeLimit} min={0} onChange={(v) => updateSection(idx, { timeLimit: v || undefined })} style={{ width: 150 }} />
<label style={{ fontSize: 12 }}><input type="checkbox" checked={sec.shuffle || false} onChange={(e) => updateSection(idx, { shuffle: e.target.checked })} /> </label>
</Space>
<div>
<Text type="secondary" style={{ fontSize: 11 }}></Text>
<Space wrap>
{sec.questionIds.map((qId) => {
const q = questions.find((qq) => qq.id === qId);
return q ? (
<Tag key={qId} closable onClose={() => {
const copy = [...sections];
copy[idx].questionIds = copy[idx].questionIds.filter((id) => id !== qId);
updateSettings({ sections: copy });
}} style={{ fontSize: 11 }}>#{q.id} {q.text.slice(0, 20)}</Tag>
) : null;
})}
</Space>
</div>
{questions.length > 0 && (
<div style={{ marginTop: 4 }}>
<Select size="small" placeholder="添加题目到区块" showSearch style={{ width: 300 }}
onChange={(qId) => moveQuestionToSection(qId, idx)}
filterOption={(input, option) => (option?.label as string || "").includes(input)}>
{unassignedQuestions.map((q) => (
<Select.Option key={q.id} value={q.id} label={`#${q.id} ${q.text}`}>
#{q.id} {q.text}
</Select.Option>
))}
</Select>
</div>
)}
</Space>
</Card>
))
)}
</Card>
{/* ===== Skip Logic ===== */}
<Card title={<Space><ShareAltOutlined /></Space>} size="small" style={{ marginBottom: 16 }}
extra={<Button icon={<PlusOutlined />} size="small" onClick={addSkipRule}></Button>}>
{skipRules.map((rule, idx) => (
<Card key={idx} size="small" style={{ marginBottom: 8, background: "#f8fafc" }}
extra={<Button icon={<DeleteOutlined />} size="small" danger onClick={() => removeSkipRule(idx)} />}>
<Space wrap>
<Input size="small" value={rule.id} onChange={(e) => updateSkipRule(idx, { id: e.target.value })} style={{ width: 100 }} placeholder="规则ID" />
<Select size="small" value={rule.condition.field} onChange={(v) => updateSkipRule(idx, { condition: { ...rule.condition, field: v } })} style={{ width: 180 }}>
<Select.Option value="total"></Select.Option>
{questions.map((q) => (
<Select.Option key={q.id} value={`question.${q.id}`}> #{q.id}</Select.Option>
))}
</Select>
<Select size="small" value={rule.condition.op} onChange={(v: any) => updateSkipRule(idx, { condition: { ...rule.condition, op: v } })} style={{ width: 70 }}>
<Select.Option value=">=">{">="}</Select.Option>
<Select.Option value="<=">{"<="}</Select.Option>
<Select.Option value=">">{">"}</Select.Option>
<Select.Option value="<">{"<"}</Select.Option>
<Select.Option value="==">==</Select.Option>
<Select.Option value="!=">!=</Select.Option>
</Select>
<InputNumber size="small" value={rule.condition.value} onChange={(v) => updateSkipRule(idx, { condition: { ...rule.condition, value: v || 0 } })} style={{ width: 80 }} />
<Select size="small" value={rule.action} onChange={(v: any) => updateSkipRule(idx, { action: v })} style={{ width: 100 }}>
<Select.Option value="skipTo"></Select.Option>
<Select.Option value="hide"></Select.Option>
<Select.Option value="show"></Select.Option>
</Select>
{rule.action === "skipTo" && (
<Select size="small" value={rule.target} onChange={(v) => updateSkipRule(idx, { target: v })} style={{ width: 120 }} placeholder="目标题号">
{questions.filter((q) => q.id > (parseInt(rule.condition.field.split(".")[1]) || 0)).map((q) => (
<Select.Option key={q.id} value={q.id}>#{q.id}</Select.Option>
))}
</Select>
)}
</Space>
</Card>
))}
{skipRules.length === 0 && (
<Card style={{ textAlign: "center", padding: 20, background: "#f8fafc" }}>
<Text type="secondary">使</Text>
</Card>
)}
</Card>
{/* ===== Preview: question distribution ===== */}
<Card title="题目分布预览" size="small">
{sections.length > 0 ? (
<Space direction="vertical" style={{ width: "100%" }}>
{sections.map((sec, idx) => (
<div key={idx} style={{ padding: "8px 12px", background: "#f8fafc", borderRadius: 8, border: "1px solid #e2e8f0" }}>
<Text strong>{sec.title || `区块 ${idx + 1}`}</Text>
<Text type="secondary" style={{ marginLeft: 8 }}>{sec.questionIds.length} {sec.timeLimit ? ` (${sec.timeLimit}分)` : ""}</Text>
</div>
))}
{unassignedQuestions.length > 0 && (
<div style={{ padding: "8px 12px", background: "#fff7e6", borderRadius: 8, border: "1px solid #ffd591" }}>
<Text type="warning">: {unassignedQuestions.length} </Text>
</div>
)}
</Space>
) : (
<Text type="secondary"> {questions.length} </Text>
)}
</Card>
</div>
);
};
export default TestFlowTab;
@@ -104,11 +104,11 @@ const TypologyTab: React.FC<Props> = ({ scale, updateScale }) => {
const d = dims[id]; if (!d) return null;
return (
<Card key={id} size="small" style={{ marginBottom: 8, background: "#f8fafc" }}
title={<Space><Tag color="purple">{id}</Tag><Text strong>{d.name}</Text></Space>}
title={<Space><Tag style={{ color: '#1e3a5f', background: 'rgba(30,58,95,0.1)', borderColor: 'transparent', borderRadius: 10 }}>{id}</Tag><Text strong>{d.name}</Text></Space>}
extra={<Space>
<Button size="small" disabled={idx === 0} onClick={() => moveDim(idx, -1)}></Button>
<Button size="small" disabled={idx === dimOrder.length - 1} onClick={() => moveDim(idx, 1)}></Button>
<Button size="small" danger icon={<DeleteOutlined />} onClick={() => removeDimension(id)} />
<Button size="small" aria-label="上移维度" disabled={idx === 0} onClick={() => moveDim(idx, -1)}></Button>
<Button size="small" aria-label="下移维度" disabled={idx === dimOrder.length - 1} onClick={() => moveDim(idx, 1)}></Button>
<Button size="small" danger icon={<DeleteOutlined />} aria-label="删除维度" onClick={() => removeDimension(id)} />
</Space>}>
<Space wrap style={{ width: "100%" }}>
<span style={{ fontSize: 12 }}>: <Input size="small" value={d.leftPole} onChange={e => updateDimension(id, { leftPole: e.target.value })} style={{ width: 50 }} /></span>
@@ -138,12 +138,12 @@ const TypologyTab: React.FC<Props> = ({ scale, updateScale }) => {
{ title: "选项分数", width: 150, render: (_: any, r: any) => r.optionScores ? JSON.stringify(r.optionScores) : "-" },
{ title: "操作", width: 100, render: (_: any, __: any, idx: number) => (
<Space size={0}>
<Button size="small" type="link" icon={<EditOutlined />}
<Button size="small" type="link" icon={<EditOutlined />} aria-label="编辑映射"
onClick={() => setEditMapping({
idx, qId: mappings[idx].questionId, dim: mappings[idx].dimension,
sign: mappings[idx].sign, optScores: mappings[idx].optionScores ? Object.entries(mappings[idx].optionScores).map(([k, v]) => `${k}=${v}`).join(", ") : ""
})} />
<Button size="small" type="link" danger icon={<DeleteOutlined />} onClick={() => removeMapping(idx)} />
<Button size="small" type="link" danger icon={<DeleteOutlined />} aria-label="删除映射" onClick={() => removeMapping(idx)} />
</Space>
)},
]} />
@@ -172,7 +172,7 @@ const TypologyTab: React.FC<Props> = ({ scale, updateScale }) => {
{outcomes.map((o, idx) => (
<Card key={idx} size="small" style={{ marginBottom: 8, background: "#f8fafc" }}
title={<Space><Tag color="cyan">{o.code || "?"}</Tag><Text strong>{o.name}</Text></Space>}
extra={<Button size="small" danger icon={<DeleteOutlined />} onClick={() => removeOutcome(idx)} />}>
extra={<Button size="small" danger icon={<DeleteOutlined />} aria-label="删除类型结果" onClick={() => removeOutcome(idx)} />}>
<Space direction="vertical" style={{ width: "100%" }} size={8}>
<Space wrap>
<span>: <Input size="small" value={o.code} onChange={e => updateOutcome(idx, { code: e.target.value })} style={{ width: 80 }} /></span>