Initial commit
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
import React from 'react';
|
||||
import { Form, Input, InputNumber, Select, Typography, Tag } from 'antd';
|
||||
import type { Scale } from '../../../types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const categories = [
|
||||
'情绪与心境', '人格与气质', '心理健康综合', '压力与应对',
|
||||
'自尊与自我', '睡眠与疲劳', '创伤与应激', '成瘾行为',
|
||||
'注意力与认知', '社交与共情', '强迫症', '幸福感与积极心理',
|
||||
'儿童青少年', '职业与学业', '其他',
|
||||
];
|
||||
|
||||
interface Props {
|
||||
scale: Scale;
|
||||
updateMeta: (updates: Partial<Scale['meta']>) => void;
|
||||
isEdit: boolean;
|
||||
}
|
||||
|
||||
const BasicInfoTab: React.FC<Props> = ({ scale, updateMeta, isEdit }) => {
|
||||
const m = scale.meta;
|
||||
|
||||
return (
|
||||
<Form layout="vertical" style={{ maxWidth: 700 }}>
|
||||
<Form.Item label="量表ID" required>
|
||||
<Input value={m.id} onChange={e => updateMeta({ id: e.target.value.replace(/[^a-z0-9-]/g, '') })}
|
||||
disabled={isEdit} placeholder="如: phq9, scl90, mmpi" />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>唯一标识符,只能用小写字母、数字和连字符</Text>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="中文名称" required>
|
||||
<Input value={m.name} onChange={e => updateMeta({ name: e.target.value })}
|
||||
placeholder="如: PHQ-9 患者健康问卷-9" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="英文名称">
|
||||
<Input value={m.nameEn} onChange={e => updateMeta({ nameEn: e.target.value })}
|
||||
placeholder="如: Patient Health Questionnaire-9" />
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item label="分类" style={{ flex: 1 }}>
|
||||
<Select value={m.category} onChange={v => updateMeta({ category: v })}
|
||||
options={categories.map(c => ({ value: c, label: c }))}
|
||||
placeholder="选择分类" showSearch allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item label="版本" style={{ width: 120 }}>
|
||||
<Input value={m.version} onChange={e => updateMeta({ version: e.target.value })} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item label="计分类型">
|
||||
<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: 'typology', label: <><Tag color="cyan" style={{ fontSize: 10, marginRight: 6 }}>类型</Tag>人格类型(MBTI类,极间计分+模式匹配)</> },
|
||||
]} />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>计分类型决定引擎使用哪种评分算法</Text>
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item label="作者/开发者" style={{ flex: 1 }}>
|
||||
<Input value={m.author} onChange={e => updateMeta({ author: e.target.value })}
|
||||
placeholder="如: Kroenke, Spitzer & Williams" />
|
||||
</Form.Item>
|
||||
<Form.Item label="年份" style={{ width: 120 }}>
|
||||
<InputNumber value={m.year} onChange={v => updateMeta({ year: v || undefined })}
|
||||
min={1900} max={2100} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item label="评估时间范围" style={{ flex: 1 }}>
|
||||
<Input value={m.timeRange} onChange={e => updateMeta({ timeRange: e.target.value })}
|
||||
placeholder="如: 过去2周、过去一个月" />
|
||||
</Form.Item>
|
||||
<Form.Item label="预计时长" style={{ width: 160 }}>
|
||||
<Input value={m.duration} onChange={e => updateMeta({ duration: e.target.value })}
|
||||
placeholder="如: 5-10分钟" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item label="量表描述">
|
||||
<Input.TextArea value={m.description} onChange={e => updateMeta({ description: e.target.value })}
|
||||
rows={3} placeholder="简要描述量表的用途和适用人群" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="参考文献">
|
||||
<Input.TextArea value={(m.references || []).join('\n')}
|
||||
onChange={e => updateMeta({ references: e.target.value.split('\n').filter(Boolean) })}
|
||||
rows={3} placeholder="每行一条参考文献" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default BasicInfoTab;
|
||||
@@ -0,0 +1,78 @@
|
||||
import React from "react";
|
||||
import { Card, Button, Space, Typography, Input, Tag } from "antd";
|
||||
import { PlusOutlined, DeleteOutlined } from "@ant-design/icons";
|
||||
import type { CompositeScore } from "../../../types";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface Props {
|
||||
composites: CompositeScore[];
|
||||
factors: Record<string, { name: string }>;
|
||||
updateComposites: (composites: CompositeScore[]) => void;
|
||||
}
|
||||
|
||||
const PRESET_FORMULAS = [
|
||||
{ label: "D + Hs + Hy", formula: "d + hs + hy" },
|
||||
{ label: "(D + Pt) / 2", formula: "(d + pt) / 2" },
|
||||
];
|
||||
|
||||
const CompositePanel: React.FC<Props> = ({ composites, factors, updateComposites }) => {
|
||||
const add = () => {
|
||||
updateComposites([...composites, { id: "", name: "", formula: "" }]);
|
||||
};
|
||||
|
||||
const remove = (idx: number) => {
|
||||
updateComposites(composites.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const update = (idx: number, updates: Partial<CompositeScore>) => {
|
||||
const copy = [...composites];
|
||||
copy[idx] = { ...copy[idx], ...updates };
|
||||
updateComposites(copy);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 12 }}>
|
||||
<Text type="secondary">复合分通过公式计算得出,例如 d + hs + hy * 0.5。支持 +, -, *, /, (, ) 和因子ID引用。</Text>
|
||||
<Button icon={<PlusOutlined />} size="small" onClick={add}>添加复合分</Button>
|
||||
</div>
|
||||
|
||||
{composites.map((comp, idx) => (
|
||||
<Card key={idx} size="small" style={{ marginBottom: 8, background: "#f8fafc" }}
|
||||
extra={<Button icon={<DeleteOutlined />} size="small" danger 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" />
|
||||
<Input size="small" addonBefore="名称" value={comp.name} onChange={e => update(idx, { name: e.target.value })} style={{ width: 200 }} />
|
||||
</Space>
|
||||
<Space style={{ width: "100%" }}>
|
||||
<Input size="small" addonBefore="公式" value={comp.formula} onChange={e => update(idx, { formula: e.target.value })} style={{ flex: 1 }} placeholder='如 d + hs + hy * 0.5' />
|
||||
</Space>
|
||||
{Object.keys(factors).length > 0 && (
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>可用因子:</Text>
|
||||
{Object.entries(factors).map(([id, fd]) => (
|
||||
<Tag key={id} style={{ fontSize: 11, cursor: "pointer" }} onClick={() => update(idx, { formula: comp.formula + ` ${id}` })}>{id}={fd.name}</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Space wrap>
|
||||
{PRESET_FORMULAS.map((pf, i) => (
|
||||
<Button key={i} size="small" onClick={() => update(idx, { formula: pf.formula })}>{pf.label}</Button>
|
||||
))}
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{composites.length === 0 && (
|
||||
<Card style={{ textAlign: "center", padding: 24, background: "#f8fafc" }}>
|
||||
<Text type="secondary">无复合分定义。复合分用于计算因子组合分数,如 WAIS 智商分。</Text>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompositePanel;
|
||||
@@ -0,0 +1,82 @@
|
||||
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;
|
||||
@@ -0,0 +1,184 @@
|
||||
import React, { useState } from "react";
|
||||
import { Form, Select, Input, Button, Card, Space, Typography, Tag, InputNumber, message, Collapse, Modal } from "antd";
|
||||
import { PlusOutlined, DeleteOutlined, AimOutlined } from "@ant-design/icons";
|
||||
import type { Scale, FactorDef } from "../../../types";
|
||||
import NormsManager from "./NormsManager";
|
||||
import CompositePanel from "./CompositePanel";
|
||||
import ProfilePanel from "./ProfilePanel";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface Props {
|
||||
scale: Scale;
|
||||
updateScale: (updates: Partial<Scale>) => void;
|
||||
}
|
||||
|
||||
const FactorsTab: React.FC<Props> = ({ scale, updateScale }) => {
|
||||
const scoring = scale.scoring;
|
||||
const factors = scoring.factors || {};
|
||||
const [showNormsModal, setShowNormsModal] = useState(false);
|
||||
const [showCompositeModal, setShowCompositeModal] = useState(false);
|
||||
const [showProfileModal, setShowProfileModal] = useState(false);
|
||||
const [newFactorId, setNewFactorId] = useState("");
|
||||
|
||||
const updateScoring = (updates: any) => {
|
||||
updateScale({ scoring: { ...scoring, ...updates } });
|
||||
};
|
||||
|
||||
const addFactor = () => {
|
||||
if (!newFactorId) { message.error("请输入因子ID"); return; }
|
||||
if (factors[newFactorId]) { message.error("因子ID已存在"); return; }
|
||||
updateScoring({ factors: { ...factors, [newFactorId]: { name: newFactorId, items: [] } } });
|
||||
setNewFactorId("");
|
||||
};
|
||||
const removeFactor = (id: string) => {
|
||||
const copy = { ...factors };
|
||||
delete copy[id];
|
||||
updateScoring({ factors: copy });
|
||||
};
|
||||
const updateFactor = (id: string, updates: Partial<FactorDef>) => {
|
||||
updateScoring({ factors: { ...factors, [id]: { ...factors[id], ...updates } } });
|
||||
};
|
||||
|
||||
const autoDetectFactors = () => {
|
||||
const qFactors = new Set(scale.questions.filter((q) => q.factor).map((q) => q.factor));
|
||||
if (qFactors.size === 0) { message.warning("题目中没有设置因子归属,无法推导"); return; }
|
||||
const existing = Object.keys(factors);
|
||||
const missing = [...qFactors].filter((f): f is string => !!f && !existing.includes(f));
|
||||
if (missing.length === 0) { message.info("所有因子已定义"); return; }
|
||||
const newFactors = { ...factors };
|
||||
for (const fid of missing) {
|
||||
const items = scale.questions.filter((q) => q.factor === fid).map((q) => q.id);
|
||||
newFactors[fid] = { name: fid, items };
|
||||
}
|
||||
updateScoring({ factors: newFactors });
|
||||
message.success(`自动创建了 ${missing.length} 个:${missing.join(", ")}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 900 }}>
|
||||
{/* Factor Definitions */}
|
||||
<Card title={<Space><AimOutlined />因子/维度定义</Space>} size="small" style={{ marginBottom: 16 }}
|
||||
extra={<Space>
|
||||
<Input size="small" value={newFactorId} onChange={(e) => setNewFactorId(e.target.value)}
|
||||
placeholder="因子ID (如 d, hs)" style={{ width: 150 }} />
|
||||
<Button size="small" onClick={addFactor}>添加因子</Button>
|
||||
<Button size="small" icon={<AimOutlined />} onClick={autoDetectFactors}>从题目推导</Button>
|
||||
</Space>}>
|
||||
{Object.entries(factors).length === 0 && (
|
||||
<Card style={{ textAlign: "center", padding: 24, background: "#fafafa" }}>
|
||||
<Text type="secondary">无因子定义。可从题目自动推导或手动添加。</Text>
|
||||
</Card>
|
||||
)}
|
||||
{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)} />}>
|
||||
<Collapse size="small" defaultActiveKey={["basic"]} items={[
|
||||
{ key: "basic", label: "基本设置", children: (
|
||||
<Form layout="inline" style={{ flexWrap: "wrap", gap: 8 }}>
|
||||
<Form.Item label="包含题目">
|
||||
<Text style={{ fontSize: 12 }}>{(f.items || []).join(", ") || "暂无"}</Text>
|
||||
</Form.Item>
|
||||
<Form.Item label="计分模式">
|
||||
<Select size="small" value={f.scoring?.mode || "sum"}
|
||||
onChange={(v) => updateFactor(id, { scoring: { ...(f.scoring || {}), mode: v } })}
|
||||
style={{ width: 200 }}
|
||||
options={[
|
||||
{ value: "sum", label: "普通求和(含反向计分)" },
|
||||
{ value: "directional", label: "方向计分(MMPI true/false)" },
|
||||
{ value: "weighted", label: "权重计分" },
|
||||
]} />
|
||||
</Form.Item>
|
||||
{f.scoring?.mode === "directional" && (
|
||||
<>
|
||||
<Form.Item label="正向题 (答'是'计分)">
|
||||
<Select size="small" mode="multiple" value={f.scoring?.true || []}
|
||||
onChange={(v) => updateFactor(id, { scoring: { ...(f.scoring || {}), true: v } })}
|
||||
style={{ minWidth: 200 }} placeholder="选择题号"
|
||||
options={scale.questions.map(q => ({ value: q.id, label: `#${q.id} ${q.text.slice(0, 20)}` }))} />
|
||||
</Form.Item>
|
||||
<Form.Item label="反向题 (答'否'计分)">
|
||||
<Select size="small" mode="multiple" value={f.scoring?.false || []}
|
||||
onChange={(v) => updateFactor(id, { scoring: { ...(f.scoring || {}), false: v } })}
|
||||
style={{ minWidth: 200 }} placeholder="选择题号"
|
||||
options={scale.questions.map(q => ({ value: q.id, label: `#${q.id} ${q.text.slice(0, 20)}` }))} />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
)},
|
||||
{ key: "k", label: "K校正", children: (
|
||||
<Form layout="inline">
|
||||
<Form.Item label="K校正系数">
|
||||
<InputNumber size="small" value={f.kCorrection} min={0} step={0.1}
|
||||
onChange={(v) => updateFactor(id, { kCorrection: v || undefined })} style={{ width: 80 }} />
|
||||
</Form.Item>
|
||||
<Form.Item label={<><Tag>K</Tag> 因子原始分 × 系数</>}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>仅 MMPI 使用</Text>
|
||||
</Form.Item>
|
||||
<Form.Item label="T分反向">
|
||||
<input type="checkbox" checked={!!f.tScoreReverse}
|
||||
onChange={(e) => updateFactor(id, { tScoreReverse: e.target.checked })} />
|
||||
<Text type="secondary" style={{ fontSize: 12, marginLeft: 4 }}>女性时 T = 100 - T</Text>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)},
|
||||
{ key: "ranges", label: "因子等级划分", children: (
|
||||
<div>{(f.ranges || []).map((r, ri) => (
|
||||
<Space key={ri} style={{ marginBottom: 4 }}>
|
||||
<InputNumber size="small" value={r.min}
|
||||
onChange={(v) => { var nr = [...(f.ranges || [])]; nr[ri] = { ...nr[ri], min: v || 0 }; updateFactor(id, { ranges: nr }); }}
|
||||
style={{ width: 60 }} placeholder="min" />
|
||||
<InputNumber size="small" value={r.max}
|
||||
onChange={(v) => { var nr = [...(f.ranges || [])]; nr[ri] = { ...nr[ri], max: v || 0 }; updateFactor(id, { ranges: nr }); }}
|
||||
style={{ width: 60 }} placeholder="max" />
|
||||
<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
|
||||
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 }] })}
|
||||
style={{ marginTop: 4 }}>添加区间</Button>
|
||||
</div>
|
||||
)},
|
||||
]} />
|
||||
</Card>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
{/* Advanced Features */}
|
||||
<Card title="常模与复合分" size="small">
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "8px 0", borderBottom: "1px solid #f0f0f0" }}>
|
||||
<Space><Text strong>常模表</Text><Text type="secondary" style={{ fontSize: 12 }}>{(scoring.norms || []).length} 组</Text></Space>
|
||||
<Button size="small" onClick={() => setShowNormsModal(true)}>管理常模</Button>
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "8px 0", borderBottom: "1px solid #f0f0f0" }}>
|
||||
<Space><Text strong>复合分</Text><Text type="secondary" style={{ fontSize: 12 }}>{(scoring.composites || []).length} 个</Text></Space>
|
||||
<Button size="small" onClick={() => setShowCompositeModal(true)}>管理复合分</Button>
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "8px 0" }}>
|
||||
<Space><Text strong>剖面分类</Text><Text type="secondary" style={{ fontSize: 12 }}>{(scoring.profileTypes || []).length} 个</Text></Space>
|
||||
<Button size="small" onClick={() => setShowProfileModal(true)}>管理剖面分类</Button>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Modal title="常模管理" open={showNormsModal} onCancel={() => setShowNormsModal(false)} footer={null} width={800}>
|
||||
<NormsManager norms={scoring.norms || []} updateNorms={(norms) => updateScoring({ norms })} />
|
||||
</Modal>
|
||||
<Modal title="复合分管理" open={showCompositeModal} onCancel={() => setShowCompositeModal(false)} footer={null} width={700}>
|
||||
<CompositePanel composites={scoring.composites || []} factors={factors} updateComposites={(composites) => updateScoring({ composites })} />
|
||||
</Modal>
|
||||
<Modal title="剖面分类管理" open={showProfileModal} onCancel={() => setShowProfileModal(false)} footer={null} width={800}>
|
||||
<ProfilePanel profileTypes={scoring.profileTypes || []} factors={factors} updateProfileTypes={(profileTypes) => updateScoring({ profileTypes })} />
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FactorsTab;
|
||||
@@ -0,0 +1,135 @@
|
||||
import React from "react";
|
||||
import { Button, Card, Space, Typography, Select, Input, Tag } from "antd";
|
||||
import { PlusOutlined, DeleteOutlined, ArrowUpOutlined, ArrowDownOutlined } from "@ant-design/icons";
|
||||
import type { Scale, ScoringRule } from "../../../types";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const fieldOptions = [
|
||||
{ value: "total", label: "总分" },
|
||||
{ value: "question.9", label: "单题分数 (question.N)" },
|
||||
{ value: "factor.d", label: "因子分 (factor.xxx)" },
|
||||
{ value: "composite.total", label: "复合分 (composite.xxx)" },
|
||||
{ value: "matchedProfile", label: "剖面分类匹配 (matchedProfile)" },
|
||||
];
|
||||
|
||||
const opOptions = [
|
||||
{ value: ">=", label: ">=" },
|
||||
{ value: "<=", label: "<=" },
|
||||
{ value: ">", label: ">" },
|
||||
{ value: "<", label: "<" },
|
||||
{ value: "==", label: "==" },
|
||||
{ value: "!=", label: "!=" },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
scale: Scale;
|
||||
updateScale: (updates: Partial<Scale>) => void;
|
||||
}
|
||||
|
||||
const InterpretationTab: React.FC<Props> = ({ scale, updateScale }) => {
|
||||
const rules = scale.interpretation?.rules || [];
|
||||
|
||||
const updateRules = (newRules: ScoringRule[]) => {
|
||||
updateScale({ interpretation: { rules: newRules } });
|
||||
};
|
||||
|
||||
const addRule = () => {
|
||||
updateRules([
|
||||
...rules,
|
||||
{
|
||||
condition: { field: "total", op: ">=", value: 0 },
|
||||
result: { summary: "", advice: "", alert: false, urgent: false },
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const removeRule = (idx: number) => {
|
||||
updateRules(rules.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const moveRule = (idx: number, dir: number) => {
|
||||
const n = idx + dir;
|
||||
if (n < 0 || n >= rules.length) return;
|
||||
const copy = [...rules];
|
||||
[copy[idx], copy[n]] = [copy[n], copy[idx]];
|
||||
updateRules(copy);
|
||||
};
|
||||
|
||||
const updateRule = (idx: number, updates: any) => {
|
||||
const copy = [...rules];
|
||||
copy[idx] = { ...copy[idx], ...updates };
|
||||
if (updates.condition) copy[idx].condition = { ...copy[idx].condition, ...updates.condition };
|
||||
if (updates.result) copy[idx].result = { ...copy[idx].result, ...updates.result };
|
||||
updateRules(copy);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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" }}>
|
||||
添加规则
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{rules.map((rule, idx) => (
|
||||
<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)} />
|
||||
</Space>
|
||||
}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Text strong>条件:</Text>
|
||||
<Space style={{ marginTop: 4 }}>
|
||||
<Select size="small" value={rule.condition.field} onChange={(v) => updateRule(idx, { condition: { field: v } })}
|
||||
options={fieldOptions} style={{ width: 200 }} />
|
||||
<Select size="small" value={rule.condition.op} onChange={(v) => updateRule(idx, { condition: { op: v } })}
|
||||
options={opOptions} style={{ width: 70 }} />
|
||||
<Input size="small" type="number" value={rule.condition.value}
|
||||
onChange={(e) => updateRule(idx, { condition: { value: parseFloat(e.target.value) || 0 } })} style={{ width: 80 }} />
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Text strong>分类:</Text>
|
||||
<Input size="small" value={rule.result.category || ""} placeholder="如 总分、抑郁、焦虑"
|
||||
onChange={(e) => updateRule(idx, { result: { ...rule.result, category: e.target.value } })} style={{ width: 120, marginLeft: 8 }} />
|
||||
</div>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Text strong>摘要:</Text>
|
||||
<Input.TextArea size="small" value={rule.result.summary} onChange={(e) => updateRule(idx, { result: { ...rule.result, summary: e.target.value } })}
|
||||
rows={2} placeholder="解读摘要文本" />
|
||||
</div>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Text strong>建议:</Text>
|
||||
<Input.TextArea size="small" value={rule.result.advice || ""} onChange={(e) => updateRule(idx, { result: { ...rule.result, advice: e.target.value || undefined } })}
|
||||
rows={2} placeholder="建议文本(可选)" />
|
||||
</div>
|
||||
<Space>
|
||||
<label>
|
||||
<input type="checkbox" checked={rule.result.alert} onChange={(e) => updateRule(idx, { result: { ...rule.result, alert: e.target.checked } })} />
|
||||
<Tag color="orange" style={{ marginLeft: 4 }}>警告</Tag>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" checked={rule.result.urgent || false} onChange={(e) => updateRule(idx, { result: { ...rule.result, urgent: e.target.checked } })} />
|
||||
<Tag color="red" style={{ marginLeft: 4 }}>紧急</Tag>
|
||||
</label>
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{rules.length === 0 && (
|
||||
<Card style={{ textAlign: "center", padding: 40, background: "#f8fafc" }}>
|
||||
<Text type="secondary">无解读规则。点击"添加规则"开始。</Text>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InterpretationTab;
|
||||
@@ -0,0 +1,118 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Button, Space, Typography, message } from 'antd';
|
||||
import { FormatPainterOutlined, CompressOutlined, CheckCircleOutlined } from '@ant-design/icons';
|
||||
import type { Scale } from '../../../types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface Props {
|
||||
scale: Scale;
|
||||
setScale: (scale: Scale) => void;
|
||||
}
|
||||
|
||||
const JsonTab: React.FC<Props> = ({ scale, setScale }) => {
|
||||
const [jsonText, setJsonText] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Sync from scale to text
|
||||
useEffect(() => {
|
||||
setJsonText(JSON.stringify(scale, null, 2));
|
||||
setError(null);
|
||||
}, [scale]);
|
||||
|
||||
const handleFormat = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText);
|
||||
setJsonText(JSON.stringify(parsed, null, 2));
|
||||
setError(null);
|
||||
message.success('已格式化');
|
||||
} catch (e: any) {
|
||||
setError('JSON格式错误: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompress = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText);
|
||||
setJsonText(JSON.stringify(parsed));
|
||||
setError(null);
|
||||
message.success('已压缩');
|
||||
} catch (e: any) {
|
||||
setError('JSON格式错误: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText);
|
||||
if (!parsed.meta?.id) { setError('缺少 meta.id 字段'); return; }
|
||||
setScale(parsed);
|
||||
setError(null);
|
||||
message.success('已应用到编辑器');
|
||||
} catch (e: any) {
|
||||
setError('JSON格式错误: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setJsonText(e.target.value);
|
||||
// Try to parse to show errors
|
||||
try {
|
||||
JSON.parse(e.target.value);
|
||||
setError(null);
|
||||
} catch (e: any) {
|
||||
setError('JSON格式错误');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<Space>
|
||||
<Button icon={<FormatPainterOutlined />} onClick={handleFormat}>格式化</Button>
|
||||
<Button icon={<CompressOutlined />} onClick={handleCompress}>压缩</Button>
|
||||
<Button icon={<CheckCircleOutlined />} type="primary" onClick={handleApply}
|
||||
style={{ background: '#2563eb', border: 'none' }}>
|
||||
应用到编辑器
|
||||
</Button>
|
||||
</Space>
|
||||
{error ? (
|
||||
<Text type="danger" style={{ fontSize: 12 }}>{error}</Text>
|
||||
) : (
|
||||
<Text type="success" style={{ fontSize: 12 }}>✓ 有效JSON</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={jsonText}
|
||||
onChange={handleTextChange}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 600,
|
||||
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
padding: 16,
|
||||
border: error ? '2px solid #ef4444' : '1px solid #e2e8f0',
|
||||
borderRadius: 8,
|
||||
background: '#fafbfc',
|
||||
color: '#0f172a',
|
||||
resize: 'vertical',
|
||||
outline: 'none',
|
||||
}}
|
||||
spellCheck={false}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: 8, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
修改JSON后点击"应用到编辑器"同步到可视化编辑
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{jsonText.length} 字符
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default JsonTab;
|
||||
@@ -0,0 +1,179 @@
|
||||
import React from "react";
|
||||
import { Card, Button, Space, Typography, Input, InputNumber, Tag, Select, Tabs, Table } from "antd";
|
||||
import { PlusOutlined, DeleteOutlined } from "@ant-design/icons";
|
||||
import type { NormGroup, LookupEntry } from "../../../types";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface Props {
|
||||
norms: NormGroup[];
|
||||
updateNorms: (norms: NormGroup[]) => void;
|
||||
}
|
||||
|
||||
const NormsManager: React.FC<Props> = ({ norms, updateNorms }) => {
|
||||
|
||||
const addGroup = () => {
|
||||
const idx = norms.length + 1;
|
||||
const newGroup: NormGroup = {
|
||||
id: `norm-${idx}`,
|
||||
label: `常模组 ${idx}`,
|
||||
demographics: {},
|
||||
tScore: { mean: 50, sd: 10 },
|
||||
};
|
||||
updateNorms([...norms, newGroup]);
|
||||
};
|
||||
|
||||
const removeGroup = (idx: number) => {
|
||||
updateNorms(norms.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const updateGroup = (idx: number, updates: Partial<NormGroup>) => {
|
||||
const copy = [...norms];
|
||||
copy[idx] = { ...copy[idx], ...updates };
|
||||
updateNorms(copy);
|
||||
};
|
||||
|
||||
const updateDemographics = (idx: number, key: string, value: string) => {
|
||||
const copy = [...norms];
|
||||
copy[idx].demographics = { ...copy[idx].demographics, [key]: value };
|
||||
if (!value) delete copy[idx].demographics[key];
|
||||
updateNorms(copy);
|
||||
};
|
||||
|
||||
const addLookupEntry = (idx: number) => {
|
||||
const copy = [...norms];
|
||||
const table = copy[idx].lookupTable || [];
|
||||
table.push({ rawMin: 0, rawMax: 0, standardScore: 0 });
|
||||
copy[idx].lookupTable = table;
|
||||
updateNorms(copy);
|
||||
};
|
||||
|
||||
const updateLookupEntry = (nIdx: number, eIdx: number, updates: Partial<LookupEntry>) => {
|
||||
const copy = [...norms];
|
||||
const table = [...(copy[nIdx].lookupTable || [])];
|
||||
table[eIdx] = { ...table[eIdx], ...updates };
|
||||
copy[nIdx].lookupTable = table;
|
||||
updateNorms(copy);
|
||||
};
|
||||
|
||||
const removeLookupEntry = (nIdx: number, eIdx: number) => {
|
||||
const copy = [...norms];
|
||||
copy[nIdx].lookupTable = (copy[nIdx].lookupTable || []).filter((_, i) => i !== eIdx);
|
||||
updateNorms(copy);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 12 }}>
|
||||
<Text type="secondary">常模组按 demographics 匹配,匹配字段最多的组被选中。</Text>
|
||||
<Button icon={<PlusOutlined />} size="small" onClick={addGroup}>添加常模组</Button>
|
||||
</div>
|
||||
|
||||
{norms.map((group, idx) => (
|
||||
<Card
|
||||
key={idx}
|
||||
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)} />}
|
||||
>
|
||||
<Tabs
|
||||
size="small"
|
||||
items={[
|
||||
{
|
||||
key: "basic", label: "基本信息",
|
||||
children: (
|
||||
<Space direction="vertical" size={8} style={{ width: "100%" }}>
|
||||
<Space>
|
||||
<Input size="small" addonBefore="ID" value={group.id} onChange={e => updateGroup(idx, { id: e.target.value })} style={{ width: 150 }} />
|
||||
<Input size="small" addonBefore="标签" value={group.label} onChange={e => updateGroup(idx, { label: e.target.value })} style={{ width: 200 }} />
|
||||
</Space>
|
||||
<Text strong style={{ fontSize: 12 }}>匹配条件(人口学特征)</Text>
|
||||
<Space>
|
||||
<Select size="small" placeholder="字段名" value={Object.keys(group.demographics)[0] || undefined} onChange={v => updateDemographics(idx, v, group.demographics[v] || "")} style={{ width: 120 }} allowClear>
|
||||
<Select.Option value="gender">gender</Select.Option>
|
||||
<Select.Option value="ageGroup">ageGroup</Select.Option>
|
||||
<Select.Option value="education">education</Select.Option>
|
||||
</Select>
|
||||
<Input size="small" placeholder="值" value={Object.values(group.demographics)[0] || ""} onChange={e => {
|
||||
const key = Object.keys(group.demographics)[0];
|
||||
if (key) updateDemographics(idx, key, e.target.value);
|
||||
}} style={{ width: 120 }} />
|
||||
</Space>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "tScore", label: "T分",
|
||||
children: (
|
||||
<Space>
|
||||
<InputNumber size="small" addonBefore="均值(mean)" value={group.tScore?.mean} onChange={v => updateGroup(idx, { tScore: { ...group.tScore, mean: v ?? 50, sd: group.tScore?.sd ?? 10 } })} />
|
||||
<InputNumber size="small" addonBefore="标准差(sd)" value={group.tScore?.sd} onChange={v => updateGroup(idx, { tScore: { ...group.tScore, mean: group.tScore?.mean ?? 50, sd: v ?? 10 } })} />
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "percentile", label: "百分位",
|
||||
children: (
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12, display: "block", marginBottom: 8 }}>粘贴逗号分隔的百分位值,索引=原始分(如 1,2,5,10,20,30,50)</Text>
|
||||
<Input.TextArea size="small" rows={4} value={(group.percentile || []).join(", ")} onChange={e => {
|
||||
const arr = e.target.value.split(",").map(s => Number(s.trim())).filter(n => !isNaN(n));
|
||||
updateGroup(idx, { percentile: arr.length > 0 ? arr : undefined });
|
||||
}} placeholder="1, 2, 5, 10, 20, 30, 50, 70, 85, 95, 99" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "sten", label: "标准分",
|
||||
children: (
|
||||
<Space direction="vertical" size={8} style={{ width: "100%" }}>
|
||||
<div>
|
||||
<Text strong style={{ fontSize: 12 }}>标准十分 (Sten)</Text>
|
||||
<Input.TextArea size="small" rows={2} value={(group.sten || []).join(", ")} onChange={e => {
|
||||
const arr = e.target.value.split(",").map(s => Number(s.trim())).filter(n => !isNaN(n));
|
||||
updateGroup(idx, { sten: arr.length > 0 ? arr : undefined });
|
||||
}} />
|
||||
</div>
|
||||
<div>
|
||||
<Text strong style={{ fontSize: 12 }}>标准九分 (Stanine)</Text>
|
||||
<Input.TextArea size="small" rows={2} value={(group.stanine || []).join(", ")} onChange={e => {
|
||||
const arr = e.target.value.split(",").map(s => Number(s.trim())).filter(n => !isNaN(n));
|
||||
updateGroup(idx, { stanine: arr.length > 0 ? arr : undefined });
|
||||
}} />
|
||||
</div>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "lookup", label: "查表法",
|
||||
children: (
|
||||
<div>
|
||||
<Table size="small" pagination={false} dataSource={(group.lookupTable || []).map((e, i) => ({ ...e, _idx: i }))} rowKey="_idx"
|
||||
columns={[
|
||||
{ title: "原始分(Min)", dataIndex: "rawMin", width: 100, render: (v: number, _: any, i: number) => <InputNumber size="small" value={v} onChange={n => updateLookupEntry(idx, i, { rawMin: n ?? 0 })} style={{ width: 80 }} /> },
|
||||
{ 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)} /> },
|
||||
]}
|
||||
/>
|
||||
<Button icon={<PlusOutlined />} size="small" onClick={() => addLookupEntry(idx)} style={{ marginTop: 8 }}>添加查表项</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{norms.length === 0 && (
|
||||
<Card style={{ textAlign: "center", padding: 40, background: "#f8fafc" }}>
|
||||
<Text type="secondary">无常模组定义。点击"添加常模组"开始。</Text>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NormsManager;
|
||||
@@ -0,0 +1,229 @@
|
||||
import React from "react";
|
||||
import { Card, Button, Space, Typography, Input, InputNumber, Select, Tag, Switch, Form } 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 ProcessTab: 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 } });
|
||||
};
|
||||
|
||||
// ===== Data Collection Section =====
|
||||
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];
|
||||
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 });
|
||||
};
|
||||
|
||||
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 }}>
|
||||
{/* ===== Data Collection ===== */}
|
||||
<Card title="开始测试前收集信息" size="small" style={{ marginBottom: 16 }}>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="收集性别年龄用于计分">
|
||||
<Space>
|
||||
<Switch checked={settings.requireDemographics || false}
|
||||
onChange={v => updateSettings({ requireDemographics: v })} />
|
||||
<Text>{settings.requireDemographics ? '测试前收集性别/年龄,用于常模匹配计分' : '不收集人口学信息'}</Text>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
{settings.requireDemographics && (
|
||||
<Form.Item label="收集字段">
|
||||
<Select mode="multiple" value={settings.dataFields || ['gender', 'age']}
|
||||
onChange={v => updateSettings({ dataFields: v })}
|
||||
options={[{ value: "gender", label: "性别" }, { value: "age", label: "年龄" }]}
|
||||
placeholder="选择要收集的字段" style={{ maxWidth: 300 }} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item label="科研数据收集">
|
||||
<Select value={settings.dataCollection || 'none'}
|
||||
onChange={v => updateSettings({ dataCollection: v })}
|
||||
options={[
|
||||
{ value: "none", label: "不收集" },
|
||||
{ value: "consent", label: "经用户同意后收集" },
|
||||
{ value: "force", label: "强制收集" },
|
||||
]} style={{ maxWidth: 300 }} />
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 4 }}>
|
||||
将匿名答题数据提交到数据收集库,用于科研分析
|
||||
</Text>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
{/* ===== 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: 200 }} />
|
||||
<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>
|
||||
{unassignedQuestions.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 ===== */}
|
||||
<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 ProcessTab;
|
||||
@@ -0,0 +1,111 @@
|
||||
import React from "react";
|
||||
import { Card, Button, Space, Typography, Input, InputNumber, Tag } from "antd";
|
||||
import { PlusOutlined, DeleteOutlined, ArrowUpOutlined, ArrowDownOutlined } from "@ant-design/icons";
|
||||
import type { ProfileType, FactorDef } from "../../../types";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface Props {
|
||||
profileTypes: ProfileType[];
|
||||
factors: Record<string, Pick<FactorDef, "name">>;
|
||||
updateProfileTypes: (pts: ProfileType[]) => void;
|
||||
}
|
||||
|
||||
const ProfilePanel: React.FC<Props> = ({ profileTypes, factors, updateProfileTypes }) => {
|
||||
const add = () => {
|
||||
updateProfileTypes([
|
||||
...profileTypes,
|
||||
{ name: "", description: "", pattern: {}, result: { summary: "", alert: false } },
|
||||
]);
|
||||
};
|
||||
|
||||
const remove = (idx: number) => {
|
||||
updateProfileTypes(profileTypes.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const move = (idx: number, dir: number) => {
|
||||
const n = idx + dir;
|
||||
if (n < 0 || n >= profileTypes.length) return;
|
||||
const copy = [...profileTypes];
|
||||
[copy[idx], copy[n]] = [copy[n], copy[idx]];
|
||||
updateProfileTypes(copy);
|
||||
};
|
||||
|
||||
const update = (idx: number, updates: Partial<ProfileType>) => {
|
||||
const copy = [...profileTypes];
|
||||
copy[idx] = { ...copy[idx], ...updates };
|
||||
updateProfileTypes(copy);
|
||||
};
|
||||
|
||||
const updatePattern = (idx: number, fid: string, bounds: { min?: number; max?: number }) => {
|
||||
const copy = [...profileTypes];
|
||||
copy[idx].pattern = { ...copy[idx].pattern, [fid]: bounds };
|
||||
if (bounds.min === undefined && bounds.max === undefined) delete copy[idx].pattern[fid];
|
||||
updateProfileTypes(copy);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 12 }}>
|
||||
<Text type="secondary">剖面分类按顺序匹配,匹配到第一个符合条件的即停止。用于 MMPI 编码类型、MBTI 等。</Text>
|
||||
<Button icon={<PlusOutlined />} size="small" onClick={add}>添加剖面类型</Button>
|
||||
</div>
|
||||
|
||||
{profileTypes.map((pt, idx) => (
|
||||
<Card key={idx} size="small" style={{ marginBottom: 8, background: "#f8fafc" }}
|
||||
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)} />
|
||||
</Space>
|
||||
}>
|
||||
<Space direction="vertical" size={8} style={{ width: "100%" }}>
|
||||
<Input size="small" value={pt.description} onChange={e => update(idx, { description: e.target.value })} placeholder="描述(可选)" />
|
||||
<Text strong style={{ fontSize: 12 }}>匹配模式(满足所有条件即匹配)</Text>
|
||||
{Object.keys(factors).length > 0 && (
|
||||
<Space wrap>
|
||||
{Object.entries(factors).map(([fid, fd]) => {
|
||||
const pattern = pt.pattern[fid] || {};
|
||||
return (
|
||||
<Card key={fid} size="small" style={{ background: "#fff", border: "1px solid #e2e8f0", padding: "4px 8px" }}>
|
||||
<Text style={{ fontSize: 11 }}><Tag style={{ fontSize: 10 }}>{fid}</Tag>{fd.name}</Text>
|
||||
<Space size={4}>
|
||||
<InputNumber size="small" placeholder="min" value={pattern.min} onChange={v => updatePattern(idx, fid, { ...pattern, min: v ?? undefined })} style={{ width: 60 }} />
|
||||
<Text style={{ fontSize: 11 }}>~</Text>
|
||||
<InputNumber size="small" placeholder="max" value={pattern.max} onChange={v => updatePattern(idx, fid, { ...pattern, max: v ?? undefined })} style={{ width: 60 }} />
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
)}
|
||||
<div style={{ borderTop: "1px solid #e2e8f0", paddingTop: 8 }}>
|
||||
<Text strong style={{ fontSize: 12 }}>匹配结果</Text>
|
||||
<Space direction="vertical" size={4} style={{ width: "100%", marginTop: 4 }}>
|
||||
<Input size="small" value={pt.result.summary} onChange={e => update(idx, { result: { ...pt.result, summary: e.target.value } })} placeholder="摘要文本" />
|
||||
<Space>
|
||||
<label style={{ fontSize: 12 }}>
|
||||
<input type="checkbox" checked={pt.result.alert} onChange={e => update(idx, { result: { ...pt.result, alert: e.target.checked } })} /> 标记警告
|
||||
</label>
|
||||
<label style={{ fontSize: 12 }}>
|
||||
<input type="checkbox" checked={pt.result.urgent || false} onChange={e => update(idx, { result: { ...pt.result, urgent: e.target.checked || undefined } })} /> 标记紧急
|
||||
</label>
|
||||
</Space>
|
||||
</Space>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{profileTypes.length === 0 && (
|
||||
<Card style={{ textAlign: "center", padding: 24, background: "#f8fafc" }}>
|
||||
<Text type="secondary">无剖面分类定义。</Text>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfilePanel;
|
||||
@@ -0,0 +1,305 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button, Input, InputNumber, Table, Space, Modal, message, Select, Form, Typography, Card } from 'antd';
|
||||
import { PlusOutlined, DeleteOutlined, ImportOutlined, ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
|
||||
import type { Scale, Question, ResponseOption } from '../../../types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const presets: Record<string, ResponseOption[]> = {
|
||||
likert4: [
|
||||
{ value: 1, label: "没有或很少时间" }, { value: 2, label: "少部分时间" },
|
||||
{ value: 3, label: "相当多时间" }, { value: 4, label: "绝大部分或全部时间" },
|
||||
],
|
||||
likert5: [
|
||||
{ value: 1, label: "非常不同意" }, { value: 2, label: "不同意" },
|
||||
{ value: 3, label: "不确定" }, { value: 4, label: "同意" }, { value: 5, label: "非常同意" },
|
||||
],
|
||||
frequency4: [
|
||||
{ value: 0, label: "完全没有" }, { value: 1, label: "几天" },
|
||||
{ value: 2, label: "一半以上天数" }, { value: 3, label: "几乎每天" },
|
||||
],
|
||||
severity5: [
|
||||
{ value: 0, label: "没有" }, { value: 1, label: "很轻" }, { value: 2, label: "中等" },
|
||||
{ value: 3, label: "偏重" }, { value: 4, label: "严重" },
|
||||
],
|
||||
yesno: [
|
||||
{ value: 1, label: "是" }, { value: 0, label: "否" },
|
||||
],
|
||||
};
|
||||
|
||||
interface Props {
|
||||
scale: Scale;
|
||||
updateScale: (updates: Partial<Scale>) => void;
|
||||
}
|
||||
|
||||
const QuestionsResponseTab: 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);
|
||||
const [optionsModal, setOptionsModal] = useState<{ idx: number; options: { value: string; label: string; score?: number }[] } | null>(null);
|
||||
|
||||
const opts = scale.responseOptions;
|
||||
const questions = scale.questions;
|
||||
const factors = [...new Set(questions.map(q => q.factor).filter(Boolean))];
|
||||
|
||||
const filtered = questions.filter(q =>
|
||||
!search || q.text.includes(search) || String(q.id).includes(search)
|
||||
);
|
||||
|
||||
// ===== Questions =====
|
||||
const addQuestion = () => {
|
||||
const maxId = questions.length > 0 ? Math.max(...questions.map(q => q.id)) + 1 : 1;
|
||||
updateScale({ questions: [...questions, { id: maxId, text: '' }] });
|
||||
setEditIdx(questions.length);
|
||||
};
|
||||
const removeQuestion = (idx: number) => {
|
||||
const newQ = questions.filter((_, i) => i !== idx);
|
||||
updateScale({ questions: newQ });
|
||||
if (editIdx === idx) setEditIdx(null);
|
||||
};
|
||||
const updateQuestion = (idx: number, updates: Partial<Question>) => {
|
||||
const newQ = [...questions];
|
||||
newQ[idx] = { ...newQ[idx], ...updates };
|
||||
updateScale({ questions: newQ });
|
||||
};
|
||||
const moveQuestion = (idx: number, dir: number) => {
|
||||
const newIdx = idx + dir;
|
||||
if (newIdx < 0 || newIdx >= questions.length) return;
|
||||
const newQ = [...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 = questions.length > 0 ? Math.max(...questions.map(q => q.id)) + 1 : 1;
|
||||
lines.forEach((line, i) => {
|
||||
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: [...questions, ...newQuestions] });
|
||||
setImportVisible(false); setImportText('');
|
||||
message.success(`已导入 ${newQuestions.length} 道题目`);
|
||||
};
|
||||
|
||||
// ===== Response Options =====
|
||||
const updateOptions = (options: ResponseOption[]) => {
|
||||
updateScale({ responseOptions: { ...opts, options } });
|
||||
};
|
||||
const updateType = (type: string) => {
|
||||
updateScale({ responseOptions: { ...opts, type: type as any } });
|
||||
};
|
||||
const addOption = () => {
|
||||
const maxVal = opts.options.length > 0 ? Math.max(...opts.options.map((o) => o.value)) + 1 : 0;
|
||||
updateOptions([...opts.options, { value: maxVal, label: "" }]);
|
||||
};
|
||||
const removeOption = (idx: number) => {
|
||||
updateOptions(opts.options.filter((_, i) => i !== idx));
|
||||
};
|
||||
const updateOption = (idx: number, field: keyof ResponseOption, value: any) => {
|
||||
const copy = [...opts.options];
|
||||
copy[idx] = { ...copy[idx], [field]: value };
|
||||
updateOptions(copy);
|
||||
};
|
||||
|
||||
// ===== Settings =====
|
||||
const updateSettings = (updates: any) => {
|
||||
updateScale({ settings: { ...scale.settings, ...updates } });
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '#', width: 40, 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 || <Text type="secondary">(点击编辑)</Text>}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '权重', width: 70,
|
||||
render: (_: any, q: Question, idx: number) => (
|
||||
<InputNumber size="small" value={q.weight ?? 1} min={0} step={0.1}
|
||||
onChange={(v) => updateQuestion(idx, { weight: v ?? undefined })} style={{ width: 60 }} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '反向', width: 50,
|
||||
render: (_: any, q: Question, idx: number) => (
|
||||
<input type="checkbox" checked={!!q.reverse}
|
||||
onChange={e => updateQuestion(idx, { reverse: e.target.checked })} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '因子', width: 110,
|
||||
render: (_: any, q: Question, idx: number) => (
|
||||
<Select size="small" value={q.factor || undefined} allowClear placeholder="-"
|
||||
style={{ width: 90 }} onChange={v => updateQuestion(idx, { factor: v || undefined })}
|
||||
options={factors.map(f => ({ value: f, label: f }))} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '选项', width: 60,
|
||||
render: (_: any, q: Question, idx: number) => (
|
||||
<Button size="small" type="link"
|
||||
onClick={() => setOptionsModal({ idx, options: q.options ? q.options.map(o => ({ ...o })) : [] })}>
|
||||
{q.options ? `${q.options.length}项` : '默认'}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
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)} />
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 900 }}>
|
||||
{/* ===== Section: Questions ===== */}
|
||||
<Card title="题目列表" size="small" style={{ marginBottom: 16 }}>
|
||||
<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' }}>共 {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: 400 }}
|
||||
locale={{ emptyText: '暂无题目,点击"添加题目"或"批量导入"' }} />
|
||||
</Card>
|
||||
|
||||
{/* ===== Section: Response Options ===== */}
|
||||
<Card title="全局答题选项" size="small" style={{ marginBottom: 16 }}>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="答题类型">
|
||||
<Select value={opts.type} onChange={updateType} style={{ width: 400 }}>
|
||||
<Select.Option value="likert">李克特量表(多级评分)</Select.Option>
|
||||
<Select.Option value="yesno">是/否(二选一)</Select.Option>
|
||||
<Select.Option value="forcedChoice">迫选题(二选一)</Select.Option>
|
||||
<Select.Option value="custom">自定义</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
{(opts.type === "likert" || opts.type === "yesno" || opts.type === "custom") && (
|
||||
<Form.Item label="快速预设">
|
||||
<Space wrap>
|
||||
{Object.entries(presets).map(([key, options]) => (
|
||||
<Button key={key} size="small" onClick={() => updateOptions(options)}>{key}</Button>
|
||||
))}
|
||||
</Space>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item label="选项列表">
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{opts.options.map((opt, idx) => (
|
||||
<Card key={idx} size="small" style={{ background: "#f8fafc" }}>
|
||||
<Space style={{ width: "100%" }}>
|
||||
<Text style={{ width: 30, textAlign: "center" }}>#{idx + 1}</Text>
|
||||
<Input style={{ width: 70 }} value={String(opt.value)} placeholder="值"
|
||||
onChange={(e) => updateOption(idx, "value", parseInt(e.target.value) || 0)} />
|
||||
<Input style={{ flex: 1 }} value={opt.label} placeholder="标签文字"
|
||||
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)} />
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<Button icon={<PlusOutlined />} onClick={addOption} style={{ marginTop: 8 }}>添加选项</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
{/* ===== Section: Test Settings ===== */}
|
||||
<Card title="测试设置" size="small">
|
||||
<Space direction="vertical">
|
||||
<label><input type="checkbox" checked={scale.settings.required} onChange={(e) => updateSettings({ required: e.target.checked })} /> 必须作答</label>
|
||||
<label><input type="checkbox" checked={scale.settings.showProgress} onChange={(e) => updateSettings({ showProgress: e.target.checked })} /> 显示进度条</label>
|
||||
<label><input type="checkbox" checked={scale.settings.allowBack} onChange={(e) => updateSettings({ allowBack: e.target.checked })} /> 允许返回上一题</label>
|
||||
<label><input type="checkbox" checked={scale.settings.shuffle || false} onChange={(e) => updateSettings({ shuffle: e.target.checked })} /> 随机打乱题目顺序</label>
|
||||
<label><input type="checkbox" checked={scale.settings.allowSkip || false} onChange={(e) => updateSettings({ allowSkip: e.target.checked })} /> 允许跳过题目</label>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* Per-question Options Modal */}
|
||||
<Modal title="自定义选项" open={!!optionsModal} onCancel={() => setOptionsModal(null)}
|
||||
onOk={() => {
|
||||
if (!optionsModal) return;
|
||||
const items = optionsModal.options.filter(o => o.value || o.label);
|
||||
updateQuestion(optionsModal.idx, { options: items.length > 0 ? items : undefined });
|
||||
setOptionsModal(null);
|
||||
}} okText="保存" width={500}>
|
||||
{optionsModal && (
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>设置该题目的专属选项,覆盖全局选项。留空使用全局选项。</Text>
|
||||
{optionsModal.options.map((opt, oi) => (
|
||||
<Space key={oi} style={{ width: "100%" }}>
|
||||
<Input size="small" value={opt.value} onChange={e => {
|
||||
const copy = [...optionsModal.options];
|
||||
copy[oi] = { ...copy[oi], value: e.target.value };
|
||||
setOptionsModal({ ...optionsModal, options: copy });
|
||||
}} placeholder="值" style={{ width: 80 }} />
|
||||
<Input size="small" value={opt.label} onChange={e => {
|
||||
const copy = [...optionsModal.options];
|
||||
copy[oi] = { ...copy[oi], label: e.target.value };
|
||||
setOptionsModal({ ...optionsModal, options: copy });
|
||||
}} placeholder="标签" style={{ width: 180 }} />
|
||||
<InputNumber size="small" value={opt.score} onChange={v => {
|
||||
const copy = [...optionsModal.options];
|
||||
copy[oi] = { ...copy[oi], score: v ?? undefined };
|
||||
setOptionsModal({ ...optionsModal, options: copy });
|
||||
}} placeholder="得分" style={{ width: 80 }} />
|
||||
<Button icon={<DeleteOutlined />} size="small" danger onClick={() => {
|
||||
setOptionsModal({ ...optionsModal, options: optionsModal.options.filter((_, i) => i !== oi) });
|
||||
}} />
|
||||
</Space>
|
||||
))}
|
||||
<Button icon={<PlusOutlined />} size="small" onClick={() => {
|
||||
setOptionsModal({ ...optionsModal, options: [...optionsModal.options, { value: "", label: "" }] });
|
||||
}}>添加选项</Button>
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Import Modal */}
|
||||
<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 QuestionsResponseTab;
|
||||
@@ -0,0 +1,155 @@
|
||||
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;
|
||||
@@ -0,0 +1,130 @@
|
||||
import React from "react";
|
||||
import { Form, Select, Button, Input, Space, Typography, Card } from "antd";
|
||||
import { PlusOutlined, DeleteOutlined } from "@ant-design/icons";
|
||||
import type { Scale, ResponseOption } from "../../../types";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const presets: Record<string, ResponseOption[]> = {
|
||||
likert4: [
|
||||
{ value: 1, label: "没有或很少时间" },
|
||||
{ value: 2, label: "少部分时间" },
|
||||
{ value: 3, label: "相当多时间" },
|
||||
{ value: 4, label: "绝大部分或全部时间" },
|
||||
],
|
||||
likert5: [
|
||||
{ value: 1, label: "非常不同意" },
|
||||
{ value: 2, label: "不同意" },
|
||||
{ value: 3, label: "不确定" },
|
||||
{ value: 4, label: "同意" },
|
||||
{ value: 5, label: "非常同意" },
|
||||
],
|
||||
frequency4: [
|
||||
{ value: 0, label: "完全没有" },
|
||||
{ value: 1, label: "几天" },
|
||||
{ value: 2, label: "一半以上天数" },
|
||||
{ value: 3, label: "几乎每天" },
|
||||
],
|
||||
severity5: [
|
||||
{ value: 0, label: "没有" },
|
||||
{ value: 1, label: "很轻" },
|
||||
{ value: 2, label: "中等" },
|
||||
{ value: 3, label: "偏重" },
|
||||
{ value: 4, label: "严重" },
|
||||
],
|
||||
yesno: [
|
||||
{ value: 1, label: "是" },
|
||||
{ value: 0, label: "否" },
|
||||
],
|
||||
};
|
||||
|
||||
interface Props {
|
||||
scale: Scale;
|
||||
updateScale: (updates: Partial<Scale>) => void;
|
||||
}
|
||||
|
||||
const ResponseTab: React.FC<Props> = ({ scale, updateScale }) => {
|
||||
const opts = scale.responseOptions;
|
||||
|
||||
const updateOptions = (options: ResponseOption[]) => {
|
||||
updateScale({ responseOptions: { ...opts, options } });
|
||||
};
|
||||
|
||||
const updateType = (type: string) => {
|
||||
updateScale({ responseOptions: { ...opts, type: type as any } });
|
||||
};
|
||||
|
||||
const addOption = () => {
|
||||
const maxVal = opts.options.length > 0 ? Math.max(...opts.options.map((o) => o.value)) + 1 : 0;
|
||||
updateOptions([...opts.options, { value: maxVal, label: "" }]);
|
||||
};
|
||||
|
||||
const removeOption = (idx: number) => {
|
||||
updateOptions(opts.options.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const updateOption = (idx: number, field: keyof ResponseOption, value: any) => {
|
||||
const copy = [...opts.options];
|
||||
copy[idx] = { ...copy[idx], [field]: value };
|
||||
updateOptions(copy);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 700 }}>
|
||||
<Form.Item label="答题类型">
|
||||
<Select value={opts.type} onChange={updateType}>
|
||||
<Select.Option value="likert">李克特量表(多级评分)</Select.Option>
|
||||
<Select.Option value="yesno">是/否(二选一)</Select.Option>
|
||||
<Select.Option value="forcedChoice">迫选题(二选一)</Select.Option>
|
||||
<Select.Option value="custom">自定义</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
{/* Presets */}
|
||||
{(opts.type === "likert" || opts.type === "yesno" || opts.type === "custom") && (
|
||||
<Form.Item label="快速预设">
|
||||
<Space wrap>
|
||||
{Object.entries(presets).map(([key, options]) => (
|
||||
<Button key={key} size="small" onClick={() => updateOptions(options)}>
|
||||
{key}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{/* Option list */}
|
||||
<Form.Item label="选项列表">
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{opts.options.map((opt, idx) => (
|
||||
<Card key={idx} size="small" style={{ background: "#f8fafc" }}>
|
||||
<Space style={{ width: "100%" }}>
|
||||
<Text style={{ width: 40, textAlign: "center" }}>#{idx + 1}</Text>
|
||||
<Input style={{ width: 80 }} value={String(opt.value)} placeholder="值"
|
||||
onChange={(e) => updateOption(idx, "value", parseInt(e.target.value) || 0)} />
|
||||
<Input style={{ flex: 1 }} value={opt.label} placeholder="标签文字"
|
||||
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)} />
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<Button icon={<PlusOutlined />} onClick={addOption} style={{ marginTop: 8 }}>添加选项</Button>
|
||||
</Form.Item>
|
||||
|
||||
{/* Test Settings */}
|
||||
<Form.Item label="测试设置">
|
||||
<Space direction="vertical">
|
||||
<label><input type="checkbox" checked={scale.settings.required} onChange={(e) => updateScale({ settings: { ...scale.settings, required: e.target.checked } })} /> 必须作答</label>
|
||||
<label><input type="checkbox" checked={scale.settings.showProgress} onChange={(e) => updateScale({ settings: { ...scale.settings, showProgress: e.target.checked } })} /> 显示进度条</label>
|
||||
<label><input type="checkbox" checked={scale.settings.allowBack} onChange={(e) => updateScale({ settings: { ...scale.settings, allowBack: e.target.checked } })} /> 允许返回上一题</label>
|
||||
<label><input type="checkbox" checked={scale.settings.shuffle || false} onChange={(e) => updateScale({ settings: { ...scale.settings, shuffle: e.target.checked } })} /> 随机打乱题目顺序</label>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResponseTab;
|
||||
@@ -0,0 +1,126 @@
|
||||
import React from "react";
|
||||
import { Form, Select, Input, Button, Card, Space, Typography, Table, Tag, InputNumber } from "antd";
|
||||
import { PlusOutlined, DeleteOutlined, BarChartOutlined, InfoCircleOutlined } from "@ant-design/icons";
|
||||
import type { Scale, ScoreRange } from "../../../types";
|
||||
import TypologyTab from "./TypologyTab";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface Props {
|
||||
scale: Scale;
|
||||
updateScale: (updates: Partial<Scale>) => void;
|
||||
}
|
||||
|
||||
const METHOD_OPTIONS = [
|
||||
{ value: "sum", label: "简单求和", desc: "所有题目得分相加(PHQ-9, GAD-7 等)" },
|
||||
{ value: "factorSum", label: "因子求和", desc: "总分 = 各因子原始分之和(SCL-90 等)" },
|
||||
{ value: "weighted", label: "权重计分", desc: "每题乘以权重系数后求和" },
|
||||
{ value: "composite", label: "复合分", desc: "通过公式计算总分(WAIS 等)" },
|
||||
];
|
||||
|
||||
const ScoringTab: React.FC<Props> = ({ scale, updateScale }) => {
|
||||
const scoring = scale.scoring;
|
||||
|
||||
const updateScoring = (updates: any) => {
|
||||
updateScale({ scoring: { ...scoring, ...updates } });
|
||||
};
|
||||
|
||||
const ranges = scoring.ranges || [];
|
||||
const addRange = () => {
|
||||
updateScoring({ ranges: [...ranges, { min: 0, max: 0, level: "", color: "#2563eb", severity: 0 }] });
|
||||
};
|
||||
const removeRange = (idx: number) => {
|
||||
updateScoring({ ranges: ranges.filter((_, i) => i !== idx) });
|
||||
};
|
||||
const updateRange = (idx: number, updates: Partial<ScoreRange>) => {
|
||||
const copy = [...ranges];
|
||||
copy[idx] = { ...copy[idx], ...updates };
|
||||
updateScoring({ ranges: copy });
|
||||
};
|
||||
|
||||
const totalRange = ranges.reduce((acc, r) => Math.max(acc, r.max), 100);
|
||||
const isTypology = scale.meta.kind === 'typology';
|
||||
|
||||
if (isTypology) return <TypologyTab scale={scale} updateScale={updateScale} />;
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 900 }}>
|
||||
{/* Guide card */}
|
||||
<Card size="small" style={{ marginBottom: 16, background: '#f8fafc' }}>
|
||||
<Space>
|
||||
<InfoCircleOutlined style={{ color: '#2563eb' }} />
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
计分规则告诉系统如何从答题结果计算出总分。大多数量表只需要选择方法、设置区间即可。
|
||||
</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* Method Selector */}
|
||||
<Card title="计分方法" size="small" style={{ marginBottom: 16 }}>
|
||||
<Form.Item label="选择计分方式">
|
||||
<Select value={scoring.method} onChange={(v) => updateScoring({ method: v })} style={{ width: 500 }}>
|
||||
{METHOD_OPTIONS.map((opt) => (
|
||||
<Select.Option key={opt.value} value={opt.value}>
|
||||
<Space><Text strong>{opt.label}</Text><Text type="secondary" style={{ fontSize: 12 }}>{opt.desc}</Text></Space>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
{scoring.method === "factorSum" && (
|
||||
<Card size="small" style={{ background: "#fefce8", marginBottom: 8 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
已选择因子求和方式,请切换到"因子与常模"标签页配置各因子定义、常模和复合分。
|
||||
</Text>
|
||||
</Card>
|
||||
)}
|
||||
{scoring.method === "weighted" && (
|
||||
<Card size="small" style={{ background: "#fefce8" }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
已选择权重计分,请在"题目与选项"标签页中为每道题设置权重系数。
|
||||
</Text>
|
||||
</Card>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Post Process */}
|
||||
<Card title="后处理公式(可选)" size="small" style={{ marginBottom: 16 }}>
|
||||
<Form.Item label="公式">
|
||||
<Input value={scoring.postProcess || ""} placeholder="如 raw * 1.25" onChange={(e) => updateScoring({ postProcess: e.target.value || undefined })} style={{ maxWidth: 400 }} />
|
||||
</Form.Item>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>对原始总分进行运算,<Tag>raw</Tag>代表原始总分。例如 SDS 用 <Tag>raw * 1.25</Tag> 转换为标准分,PSQI 用 <Tag>raw / 2</Tag> 调整范围。</Text>
|
||||
</Card>
|
||||
|
||||
{/* Ranges */}
|
||||
<Card title={<Space><BarChartOutlined />总分等级区间</Space>} size="small">
|
||||
{ranges.length > 0 && (
|
||||
<div style={{ marginBottom: 12, padding: "8px 0" }}>
|
||||
<div style={{ height: 32, borderRadius: 6, overflow: "hidden", display: "flex", background: "#f1f5f9" }}>
|
||||
{ranges.map((r, i) => {
|
||||
const width = totalRange > 0 ? ((r.max - r.min + 1) / totalRange) * 100 : 0;
|
||||
return (
|
||||
<div key={i} style={{ width: `${width}%`, background: r.color, display: "flex", alignItems: "center", justifyContent: "center", minWidth: 0 }}
|
||||
title={`${r.level}: ${r.min}-${r.max}`}>
|
||||
<Text style={{ color: "#fff", fontSize: 10, fontWeight: 600, textShadow: "0 1px 2px rgba(0,0,0,0.3)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{r.level || `#${i + 1}`}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Table size="small" pagination={false} dataSource={ranges.map((r, i) => ({ ...r, _idx: i }))} rowKey="_idx" columns={[
|
||||
{ title: "最小值", dataIndex: "min", width: 80, render: (v, _, idx) => <InputNumber size="small" value={v} onChange={(n) => updateRange(idx, { min: n || 0 })} style={{ width: 70 }} /> },
|
||||
{ title: "最大值", dataIndex: "max", width: 80, render: (v, _, idx) => <InputNumber size="small" value={v} onChange={(n) => updateRange(idx, { max: n || 0 })} style={{ width: 70 }} /> },
|
||||
{ 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)} /> },
|
||||
]} />
|
||||
<Button icon={<PlusOutlined />} onClick={addRange} size="small" style={{ marginTop: 8 }}>添加区间</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScoringTab;
|
||||
@@ -0,0 +1 @@
|
||||
21
|
||||
@@ -0,0 +1,200 @@
|
||||
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;
|
||||
@@ -0,0 +1,201 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button, Card, Space, Typography, Input, InputNumber, Tag, message, Modal, Table, Select } from "antd";
|
||||
import { PlusOutlined, DeleteOutlined, EditOutlined, AimOutlined } from "@ant-design/icons";
|
||||
import type { Scale } from "../../../types";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface Props {
|
||||
scale: Scale;
|
||||
updateScale: (updates: Partial<Scale>) => void;
|
||||
}
|
||||
|
||||
const TypologyTab: React.FC<Props> = ({ scale, updateScale }) => {
|
||||
const scoring = scale.scoring;
|
||||
const dims = scoring.dimensions || {};
|
||||
const dimOrder = scoring.dimensionOrder || Object.keys(dims);
|
||||
const mappings = scoring.questionMappings || [];
|
||||
const outcomes = scoring.outcomes || [];
|
||||
|
||||
const updateScoring = (up: any) => updateScale({ scoring: { ...scoring, ...up } });
|
||||
|
||||
// ===== Dimensions =====
|
||||
const [newDimId, setNewDimId] = useState("");
|
||||
|
||||
const addDimension = () => {
|
||||
if (!newDimId) { message.error("请输入维度ID"); return; }
|
||||
if (dims[newDimId]) { message.error("维度ID已存在"); return; }
|
||||
updateScoring({
|
||||
dimensions: { ...dims, [newDimId]: { name: newDimId, leftPole: "L", rightPole: "R", constant: 24, threshold: 24, maxDeviation: 24 } },
|
||||
dimensionOrder: [...dimOrder, newDimId],
|
||||
});
|
||||
setNewDimId("");
|
||||
};
|
||||
|
||||
const removeDimension = (id: string) => {
|
||||
const copy = { ...dims }; delete copy[id];
|
||||
updateScoring({ dimensions: copy, dimensionOrder: dimOrder.filter(d => d !== id) });
|
||||
};
|
||||
|
||||
const updateDimension = (id: string, up: any) => {
|
||||
updateScoring({ dimensions: { ...dims, [id]: { ...dims[id], ...up } } });
|
||||
};
|
||||
|
||||
const moveDim = (idx: number, dir: number) => {
|
||||
const newIdx = idx + dir;
|
||||
if (newIdx < 0 || newIdx >= dimOrder.length) return;
|
||||
const copy = [...dimOrder];
|
||||
[copy[idx], copy[newIdx]] = [copy[newIdx], copy[idx]];
|
||||
updateScoring({ dimensionOrder: copy });
|
||||
};
|
||||
|
||||
// ===== Mappings =====
|
||||
const [editMapping, setEditMapping] = useState<{ idx: number; qId: number; dim: string; sign: number; optScores: string } | null>(null);
|
||||
|
||||
const addMapping = () => {
|
||||
const nextQ = (scale.questions.find(q => !mappings.some(m => m.questionId === q.id))?.id || scale.questions[0]?.id || 1);
|
||||
const firstDim = dimOrder[0] || Object.keys(dims)[0] || "";
|
||||
const newM = [...mappings, { questionId: nextQ, dimension: firstDim, sign: 1 }];
|
||||
updateScoring({ questionMappings: newM });
|
||||
};
|
||||
|
||||
const removeMapping = (idx: number) => {
|
||||
updateScoring({ questionMappings: mappings.filter((_, i) => i !== idx) });
|
||||
};
|
||||
|
||||
const saveMapping = () => {
|
||||
if (!editMapping) return;
|
||||
const copy = [...mappings];
|
||||
const optScores = editMapping.optScores.trim() ? Object.fromEntries(editMapping.optScores.split(",").map(s => {
|
||||
const [k, v] = s.split("=").map(x => x.trim());
|
||||
return [k, isNaN(Number(v)) ? v : Number(v)];
|
||||
})) : undefined;
|
||||
copy[editMapping.idx] = { questionId: editMapping.qId, dimension: editMapping.dim, sign: editMapping.sign, ...(optScores ? { optionScores: optScores } : {}) };
|
||||
updateScoring({ questionMappings: copy });
|
||||
setEditMapping(null);
|
||||
};
|
||||
|
||||
// ===== Outcomes =====
|
||||
const defaultOutcome = { code: "", name: "", oneLiner: "", description: "", traits: [], strengths: [], weaknesses: [], suggestions: [], imageUrl: "" };
|
||||
|
||||
const addOutcome = () => {
|
||||
updateScoring({ outcomes: [...outcomes, { ...defaultOutcome, code: `TYPE${outcomes.length + 1}` }] });
|
||||
};
|
||||
|
||||
const removeOutcome = (idx: number) => {
|
||||
updateScoring({ outcomes: outcomes.filter((_, i) => i !== idx) });
|
||||
};
|
||||
|
||||
const updateOutcome = (idx: number, up: any) => {
|
||||
const copy = [...outcomes];
|
||||
copy[idx] = { ...copy[idx], ...up };
|
||||
updateScoring({ outcomes: copy });
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 900 }}>
|
||||
{/* ===== Dimensions ===== */}
|
||||
<Card title={<Space><AimOutlined />维度定义</Space>} size="small" style={{ marginBottom: 16 }}
|
||||
extra={<Space>
|
||||
<Input size="small" value={newDimId} onChange={e => setNewDimId(e.target.value)} placeholder="维度ID (如 EI)" style={{ width: 120 }} />
|
||||
<Button size="small" onClick={addDimension}>添加维度</Button>
|
||||
</Space>}>
|
||||
{dimOrder.map((id, idx) => {
|
||||
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>}
|
||||
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)} />
|
||||
</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>
|
||||
<span style={{ fontSize: 12 }}>右极: <Input size="small" value={d.rightPole} onChange={e => updateDimension(id, { rightPole: e.target.value })} style={{ width: 50 }} /></span>
|
||||
<span style={{ fontSize: 12 }}>名称: <Input size="small" value={d.name} onChange={e => updateDimension(id, { name: e.target.value })} style={{ width: 140 }} /></span>
|
||||
<span style={{ fontSize: 12 }}>常数: <InputNumber size="small" value={d.constant} onChange={v => updateDimension(id, { constant: v || 0 })} style={{ width: 60 }} /></span>
|
||||
<span style={{ fontSize: 12 }}>阈值: <InputNumber size="small" value={d.threshold} onChange={v => updateDimension(id, { threshold: v || 0 })} style={{ width: 60 }} /></span>
|
||||
<span style={{ fontSize: 12 }}>最大偏差: <InputNumber size="small" value={d.maxDeviation} onChange={v => updateDimension(id, { maxDeviation: v || 1 })} style={{ width: 60 }} /></span>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
{dimOrder.length === 0 && <Text type="secondary">暂无维度定义,请添加维度。</Text>}
|
||||
</Card>
|
||||
|
||||
{/* ===== Question Mappings ===== */}
|
||||
<Card title={<Space>题目映射 <Text type="secondary" style={{ fontSize: 12 }}>{mappings.length} 条</Text></Space>}
|
||||
size="small" style={{ marginBottom: 16 }}
|
||||
extra={<Button size="small" icon={<PlusOutlined />} onClick={addMapping}>添加映射</Button>}>
|
||||
<Table size="small" pagination={false} dataSource={mappings.map((m, i) => ({ ...m, _idx: i }))} rowKey="_idx"
|
||||
columns={[
|
||||
{ title: "#", width: 40, render: (_, __, idx) => idx + 1 },
|
||||
{ title: "题号", dataIndex: "questionId", width: 50 },
|
||||
{ title: "题目", width: 200, render: (_, r) => { const q = scale.questions.find(q => q.id === r.questionId); return q?.text?.slice(0, 40) || "?"; } },
|
||||
{ title: "维度", dataIndex: "dimension", width: 60, render: (v: string) => <Tag>{v}</Tag> },
|
||||
{ title: "符号", dataIndex: "sign", width: 50, render: (v: number) => v > 0 ? "+" : "−" },
|
||||
{ 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 />}
|
||||
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)} />
|
||||
</Space>
|
||||
)},
|
||||
]} />
|
||||
</Card>
|
||||
|
||||
{/* Edit Mapping Modal */}
|
||||
<Modal title="编辑题目映射" open={!!editMapping} onCancel={() => setEditMapping(null)}
|
||||
onOk={saveMapping} okText="保存" width={500}>
|
||||
{editMapping && (
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
<span>题号: <InputNumber value={editMapping.qId} onChange={v => setEditMapping({ ...editMapping, qId: v || 0 })} style={{ width: 100 }} /></span>
|
||||
<span>维度: <Select value={editMapping.dim} onChange={v => setEditMapping({ ...editMapping, dim: v })} style={{ width: 150 }}
|
||||
options={dimOrder.map(d => ({ value: d, label: d }))} /></span>
|
||||
<span>符号: <Select value={editMapping.sign} onChange={v => setEditMapping({ ...editMapping, sign: v })} style={{ width: 100 }}
|
||||
options={[{ value: 1, label: "+ 正向" }, { value: -1, label: "− 反向" }]} /></span>
|
||||
<span>选项得分映射 (可选): <Input value={editMapping.optScores} onChange={e => setEditMapping({ ...editMapping, optScores: e.target.value })}
|
||||
placeholder="A=0, B=1" style={{ width: 250 }} /></span>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>格式: 选项=分数, 用逗号分隔。留空表示用原始答题值。</Text>
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* ===== Outcomes ===== */}
|
||||
<Card title={<Space>类型结果 <Text type="secondary" style={{ fontSize: 12 }}>{outcomes.length} 个</Text></Space>}
|
||||
size="small" extra={<Button size="small" icon={<PlusOutlined />} onClick={addOutcome}>添加类型</Button>}>
|
||||
{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)} />}>
|
||||
<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>
|
||||
<span>名称: <Input size="small" value={o.name} onChange={e => updateOutcome(idx, { name: e.target.value })} style={{ width: 150 }} /></span>
|
||||
</Space>
|
||||
<span>一句话描述: <Input size="small" value={o.oneLiner || ""} onChange={e => updateOutcome(idx, { oneLiner: e.target.value })} style={{ width: 400 }} /></span>
|
||||
<span>详细描述: <Input.TextArea size="small" value={o.description || ""} onChange={e => updateOutcome(idx, { description: e.target.value })} rows={2} style={{ width: 500 }} /></span>
|
||||
<Space wrap>
|
||||
<span>特质: <Input size="small" value={(o.traits || []).join(", ")} onChange={e => updateOutcome(idx, { traits: e.target.value.split(",").map(s => s.trim()).filter(Boolean) })} style={{ width: 250 }} placeholder="逗号分隔" /></span>
|
||||
<span>优势: <Input size="small" value={(o.strengths || []).join(", ")} onChange={e => updateOutcome(idx, { strengths: e.target.value.split(",").map(s => s.trim()).filter(Boolean) })} style={{ width: 250 }} placeholder="逗号分隔" /></span>
|
||||
</Space>
|
||||
<Space wrap>
|
||||
<span>劣势: <Input size="small" value={(o.weaknesses || []).join(", ")} onChange={e => updateOutcome(idx, { weaknesses: e.target.value.split(",").map(s => s.trim()).filter(Boolean) })} style={{ width: 250 }} placeholder="逗号分隔" /></span>
|
||||
<span>建议: <Input size="small" value={(o.suggestions || []).join(", ")} onChange={e => updateOutcome(idx, { suggestions: e.target.value.split(",").map(s => s.trim()).filter(Boolean) })} style={{ width: 250 }} placeholder="逗号分隔" /></span>
|
||||
</Space>
|
||||
<span>图片URL: <Input size="small" value={o.imageUrl || ""} onChange={e => updateOutcome(idx, { imageUrl: e.target.value })} style={{ width: 400 }} /></span>
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
{outcomes.length === 0 && <Text type="secondary">暂无类型结果定义。</Text>}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TypologyTab;
|
||||
Reference in New Issue
Block a user