Initial commit

This commit is contained in:
shanshuilala
2026-07-13 16:21:02 +08:00
commit da16d2ae6e
183 changed files with 87241 additions and 0 deletions
@@ -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-9sum计分+</> },
{ 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;