feat: 架构升级 + 设计升级(node:sqlite 迁移 / P0 安全修复 / 游客通道 / SSE stream-ticket / 墨蓝主题 / ResultView 拆分 / 全站无障碍)+ RainID 接线整合
This commit is contained in:
@@ -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-9类,sum计分+等级区间)</> },
|
||||
{ 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>
|
||||
|
||||
Reference in New Issue
Block a user