feat: v2 可视化量表编辑器(卡片流+虚拟列表+拖拽+因子分组+批量+核对视图+撤销重做+草稿)+ ora P1 修复(MMPI items 因子迁移/展开区击键/双编辑器冲突保护)

This commit is contained in:
2026-08-12 17:27:08 +08:00
parent 189f63af91
commit 7f0e086888
15 changed files with 1964 additions and 11 deletions
+482
View File
@@ -0,0 +1,482 @@
import React, { useMemo, useRef, useState, useEffect, useCallback } from "react";
import { Empty, Tag, Typography } from "antd";
import { useVirtualizer } from "@tanstack/react-virtual";
import {
DndContext, DragOverlay, MeasuringStrategy,
PointerSensor, TouchSensor, KeyboardSensor, useSensor, useSensors,
type DragStartEvent, type DragEndEvent, type DragCancelEvent,
} from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy, useSortable, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import QuestionCard from "./QuestionCard";
import QuestionBatchBar from "./QuestionBatchBar";
import type { Question, ResponseOption } from "../types";
const { Text } = Typography;
const UNGROUPED = "未分组";
const MODE_LABELS: Record<string, string> = {
sum: "求和", directional: "方向计分", weighted: "权重", composite: "复合",
};
/** 因子归属:q.factor 优先,否则取 scoring.factors[f].items 含该题 id 的因子(MMPI 模型) */
function groupOf(q: Question, itemFactorMap: Map<number, string>): string {
return q.factor ?? itemFactorMap.get(q.id) ?? UNGROUPED;
}
/** 由 scoring.factors[].items 构建 题目 id → 因子名 映射 */
function buildItemFactorMap(scoringFactors: Record<string, { items?: number[] }> | undefined): Map<number, string> {
const m = new Map<number, string>();
for (const [f, def] of Object.entries(scoringFactors || {})) {
for (const id of def?.items || []) {
if (!m.has(id)) m.set(id, f);
}
}
return m;
}
export interface QuestionListProps {
questions: Question[];
search: string;
allFactors: string[];
/** scoring.factors 结构,用于分组头显示计分方式与 items 因子归属 */
scoringFactors?: Record<string, { name?: string; items?: number[]; scoring?: { mode?: string } }>;
defaultOptions?: ResponseOption[];
weightMode?: boolean;
/** 添加题目/校验跳转后滚动到指定题目 ID */
scrollToId?: number;
scrollNonce?: number;
onCommitQuestion: (index: number, patch: Partial<Question>) => void;
onDeleteQuestion: (index: number) => void;
onDuplicateQuestion: (index: number) => void;
onMoveQuestion: (index: number, dir: -1 | 1) => void;
onReorder: (oldIndex: number, newIndex: number) => void;
/** 跨组拖拽:移到目标组(targetQuestionIndex 为空表示落到组头) */
onDropIntoGroup: (movedIndex: number, targetGroup: string, targetQuestionIndex?: number) => void;
onBatchSetFactor: (ids: number[], factor?: string) => void;
onBatchSetReverse: (ids: number[], reverse: boolean) => void;
onBatchDelete: (ids: number[]) => void;
onBatchDuplicate: (ids: number[]) => void;
}
interface FilteredEntry {
q: Question;
realIndex: number;
}
type FlatRow =
| { key: string; kind: "header"; group: string; count: number; mode?: string }
| { key: string; kind: "question"; group: string; entry: FilteredEntry };
/** 可拖拽行:useSortable 在此层调用(DragOverlay 不能复用同一 useSortable 组件) */
const SortableRow: React.FC<{
id: string;
disabled?: boolean;
children: (dragHandleProps: Record<string, unknown>) => React.ReactNode;
}> = ({ id, disabled, children }) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, disabled });
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Translate.toString(transform),
transition,
opacity: isDragging ? 0.45 : 1,
width: "100%",
}}
>
{children({ ...attributes, ...listeners })}
</div>
);
};
/** 分组头(虚拟行内):作为拖拽落点(droppable-only,无 listeners/attributes → 不可拖起) */
const GroupHeaderRow: React.FC<{ row: Extract<FlatRow, { kind: "header" }> }> = ({ row }) => {
const { setNodeRef, transform, transition } = useSortable({ id: row.key });
return (
<div ref={setNodeRef} style={{ transform: CSS.Translate.toString(transform), transition }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "6px 12px",
background: "rgba(30,58,95,0.05)",
border: "1px solid transparent",
borderBottom: "1px solid #dbe4ef",
borderRadius: 8,
margin: "4px 2px",
}}
>
<Text strong style={{ fontSize: 13, color: "#1e3a5f" }}>{row.group}</Text>
<Tag style={{ marginInlineEnd: 0, fontSize: 11, color: "#475569", background: "rgba(30,58,95,0.08)", borderColor: "transparent" }}>{row.count} </Tag>
{row.mode && (
<Tag style={{ marginInlineEnd: 0, fontSize: 11, color: "#155e75", background: "rgba(8,145,178,0.1)", borderColor: "transparent" }}>
{MODE_LABELS[row.mode] || row.mode}
</Tag>
)}
</div>
</div>
);
};
const QuestionList: React.FC<QuestionListProps> = ({
questions, search, allFactors, scoringFactors, defaultOptions, weightMode,
scrollToId, scrollNonce,
onCommitQuestion, onDeleteQuestion, onDuplicateQuestion, onMoveQuestion,
onReorder, onDropIntoGroup,
onBatchSetFactor, onBatchSetReverse, onBatchDelete, onBatchDuplicate,
}) => {
const parentRef = useRef<HTMLDivElement | null>(null);
const [expanded, setExpanded] = useState<Set<number>>(new Set());
const [selected, setSelected] = useState<Set<number>>(new Set());
const [activeDrag, setActiveDrag] = useState<FilteredEntry | null>(null);
const [dragging, setDragging] = useState(false);
const [scrollTop, setScrollTop] = useState(0);
const hasFilter = !!search.trim();
// P1-1MMPI 等量表的因子归属在 scoring.factors[].items(题号数组),题目上无 q.factor
const itemFactorMap = useMemo(() => buildItemFactorMap(scoringFactors), [scoringFactors]);
const effectiveGroup = useCallback((q: Question) => groupOf(q, itemFactorMap), [itemFactorMap]);
// ===== 过滤 =====
const filteredEntries = useMemo<FilteredEntry[]>(() => {
const s = search.trim();
const list: FilteredEntry[] = [];
questions.forEach((q, realIndex) => {
if (!s) { list.push({ q, realIndex }); return; }
if (s.startsWith("#")) {
const id = parseInt(s.slice(1), 10);
if (!isNaN(id) && q.id === id) list.push({ q, realIndex });
return;
}
if (q.text.includes(s) || String(q.id).includes(s)) list.push({ q, realIndex });
});
return list;
}, [questions, search]);
// ===== 分组顺序:scoring.factors key 优先,再按题目出现顺序,未分组最后 =====
const factorOrder = useMemo(() => {
const order: string[] = [];
for (const k of Object.keys(scoringFactors || {})) if (!order.includes(k)) order.push(k);
for (const q of questions) {
const f = q.factor;
if (f && !order.includes(f)) order.push(f);
}
if (!order.includes(UNGROUPED)) order.push(UNGROUPED);
return order;
}, [scoringFactors, questions]);
// ===== 扁平行(分组模式:header + question;过滤模式:仅 question =====
const flatRows = useMemo<FlatRow[]>(() => {
if (hasFilter) {
return filteredEntries.map((e) => ({
key: String(e.q.id), kind: "question" as const, group: effectiveGroup(e.q), entry: e,
}));
}
const byGroup = new Map<string, FilteredEntry[]>();
for (const e of filteredEntries) {
const g = effectiveGroup(e.q);
if (!byGroup.has(g)) byGroup.set(g, []);
byGroup.get(g)!.push(e);
}
const groups = factorOrder.filter((g) => byGroup.has(g));
for (const g of byGroup.keys()) if (!groups.includes(g)) groups.push(g);
const rows: FlatRow[] = [];
for (const g of groups) {
const mode = scoringFactors?.[g]?.scoring?.mode;
rows.push({ key: `H:${g}`, kind: "header", group: g, count: byGroup.get(g)!.length, mode });
for (const e of byGroup.get(g)!) rows.push({ key: String(e.q.id), kind: "question", group: g, entry: e });
}
return rows;
}, [filteredEntries, hasFilter, factorOrder, scoringFactors, effectiveGroup]);
// ===== 组内序号(上移/下移按钮用) =====
const groupPos = useMemo(() => {
const m = new Map<number, { idx: number; count: number }>();
if (!hasFilter) {
let curIdx = 0;
let curCount = 0;
for (const r of flatRows) {
if (r.kind === "header") {
curIdx = 0;
curCount = r.count;
} else if (r.kind === "question" && r.entry) {
m.set(r.entry.q.id, { idx: curIdx, count: curCount });
curIdx++;
}
}
}
return m;
}, [flatRows, hasFilter]);
const groupMeta = useMemo(() => {
const meta: Record<string, { count: number; mode?: string }> = {};
for (const r of flatRows) {
if (r.kind === "header") meta[r.group] = { count: r.count, mode: r.mode };
}
return meta;
}, [flatRows]);
// ===== 虚拟列表 =====
const virtualizer = useVirtualizer({
count: flatRows.length,
getScrollElement: () => parentRef.current,
estimateSize: (i) => (flatRows[i]?.kind === "header" ? 36 : 120),
overscan: 5,
measureElement: (el) => el.getBoundingClientRect().height,
});
const virtualItems = virtualizer.getVirtualItems();
// ===== 当前分组(吸顶条) =====
const currentGroup = useMemo(() => {
if (hasFilter || virtualItems.length === 0) return null;
const atTop = virtualItems.find((v) => v.end > scrollTop) || virtualItems[0];
const row = flatRows[atTop?.index ?? 0];
if (!row) return null;
const meta = groupMeta[row.group] || { count: 0 };
return { group: row.group, count: meta.count, mode: meta.mode };
}, [hasFilter, virtualItems, flatRows, groupMeta, scrollTop]);
const onScroll = useCallback(() => {
setScrollTop(parentRef.current?.scrollTop ?? 0);
}, []);
// 添加题目/校验跳转后滚动(按题目 ID 定位)
useEffect(() => {
if (scrollNonce !== undefined && scrollToId !== undefined) {
const idx = flatRows.findIndex((r) => r.kind === "question" && r.entry!.q.id === scrollToId);
if (idx !== -1) virtualizer.scrollToIndex(idx, { align: "auto" });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scrollNonce]);
// ===== dndPointer/Touch/Keyboard:拖柄聚焦后 Space 或方向键排序) =====
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 120, tolerance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const handleDragStart = (e: DragStartEvent) => {
setDragging(true);
const row = flatRows.find((r): r is Extract<FlatRow, { kind: "question" }> =>
r.kind === "question" && r.key === String(e.active.id));
if (row) setActiveDrag(row.entry);
};
const handleDragEnd = (e: DragEndEvent) => {
setDragging(false);
setActiveDrag(null);
const { active, over } = e;
if (!over || active.id === over.id) return;
const fromRow = flatRows.find((r): r is Extract<FlatRow, { kind: "question" }> =>
r.kind === "question" && r.key === String(active.id));
const overRow = flatRows.find((r) => r.key === String(over.id));
if (!fromRow || !overRow) return;
if (overRow.kind === "header") {
// 落到组头:移入该组并置于组首
onDropIntoGroup(fromRow.entry.realIndex, overRow.group);
return;
}
if (overRow.group === fromRow.group) {
onReorder(fromRow.entry.realIndex, overRow.entry.realIndex);
} else {
// 跨组:重排并修改被拖题的 factor
onDropIntoGroup(fromRow.entry.realIndex, overRow.group, overRow.entry.realIndex);
}
};
const handleDragCancel = (_e: DragCancelEvent) => {
setDragging(false);
setActiveDrag(null);
};
// ===== 批量勾选 =====
const selectedIds = useMemo(() => [...selected], [selected]);
const toggleSelect = (id: number) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleSelectAll = () => setSelected(new Set(filteredEntries.map((e) => e.q.id)));
const handleClear = () => setSelected(new Set());
const handleBatchDelete = () => {
onBatchDelete(selectedIds);
setSelected(new Set());
};
// ===== 组内移动(上移/下移按钮) =====
const handleGroupMove = (entry: FilteredEntry, dir: -1 | 1) => {
if (hasFilter) { onMoveQuestion(entry.realIndex, dir); return; }
const members = flatRows.filter((r): r is Extract<FlatRow, { kind: "question" }> =>
r.kind === "question" && r.group === effectiveGroup(entry.q));
const gIdx = members.findIndex((r) => r.entry.q.id === entry.q.id);
const target = members[gIdx + dir];
if (target) onReorder(entry.realIndex, target.entry.realIndex);
};
const toggleExpand = (id: number) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
return (
<div>
<QuestionBatchBar
selectedCount={selectedIds.length}
totalCount={filteredEntries.length}
allFactors={allFactors}
onSelectAll={handleSelectAll}
onClear={handleClear}
onSetFactor={(f) => onBatchSetFactor(selectedIds, f)}
onSetReverse={(v) => onBatchSetReverse(selectedIds, v)}
onDelete={handleBatchDelete}
onDuplicate={() => onBatchDuplicate(selectedIds)}
/>
<div
ref={parentRef}
onScroll={onScroll}
style={{
height: "calc(100vh - 340px)",
minHeight: 320,
overflowY: "auto",
border: "1px solid #eef2f7",
borderRadius: 12,
background: "#fafbfc",
padding: "0 6px 8px",
}}
>
{/* 吸顶当前分组条(仅分组模式) */}
{currentGroup && (
<div
style={{
position: "sticky",
top: 0,
zIndex: 30,
display: "flex",
alignItems: "center",
gap: 8,
padding: "6px 12px",
background: "rgba(238,244,251,0.88)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
borderBottom: "1px solid #cfe0f2",
marginBottom: 4,
borderRadius: "0 0 8px 8px",
}}
>
<Text strong style={{ fontSize: 13, color: "#1e3a5f" }}>{currentGroup.group}</Text>
<Tag style={{ marginInlineEnd: 0, fontSize: 11, color: "#475569", background: "rgba(30,58,95,0.08)", borderColor: "transparent" }}>{currentGroup.count} </Tag>
{currentGroup.mode && (
<Tag style={{ marginInlineEnd: 0, fontSize: 11, color: "#155e75", background: "rgba(8,145,178,0.1)", borderColor: "transparent" }}>
{MODE_LABELS[currentGroup.mode] || currentGroup.mode}
</Tag>
)}
</div>
)}
{flatRows.length === 0 ? (
<div style={{ display: "flex", justifyContent: "center", paddingTop: 80 }}>
<Empty description={hasFilter ? "无匹配题目" : "暂无题目,点击「添加题目」创建"} />
</div>
) : (
<div style={{ height: virtualizer.getTotalSize(), width: "100%", position: "relative" }}>
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
// 虚拟列表只有视口内卡片在 DOM 中:Always 策略在拖拽期间每帧重测 droppable rect
measuring={{ droppable: { strategy: MeasuringStrategy.Always } }}
>
<SortableContext items={flatRows.map((r) => r.key)} strategy={verticalListSortingStrategy}>
{virtualItems.map((vr) => {
const row = flatRows[vr.index];
const rowStyle = {
position: "absolute" as const,
top: 0,
left: 0,
width: "100%",
transform: `translateY(${vr.start}px)`,
padding: "2px 0",
};
if (row.kind === "header") {
return (
<div key={row.key} data-index={vr.index} ref={virtualizer.measureElement} style={rowStyle}>
<GroupHeaderRow row={row} />
</div>
);
}
const q = row.entry!.q;
const pos = groupPos.get(q.id);
return (
<div key={row.key} data-index={vr.index} ref={virtualizer.measureElement} style={rowStyle}>
<SortableRow id={row.key} disabled={hasFilter}>
{(dragHandleProps) => (
<QuestionCard
question={q}
index={hasFilter ? row.entry!.realIndex : (pos?.idx ?? 0)}
isLast={hasFilter ? row.entry!.realIndex === questions.length - 1 : (pos ? pos.idx === pos.count - 1 : true)}
allFactors={allFactors}
defaultOptions={defaultOptions}
weightMode={weightMode}
dragHandleProps={dragHandleProps}
onCommit={(patch) => onCommitQuestion(row.entry!.realIndex, patch)}
onDelete={() => onDeleteQuestion(row.entry!.realIndex)}
onDuplicate={() => onDuplicateQuestion(row.entry!.realIndex)}
onMove={(dir) => handleGroupMove(row.entry!, dir)}
expanded={expanded.has(q.id)}
onToggleExpand={() => toggleExpand(q.id)}
selected={!dragging && selected.has(q.id)}
onToggleSelect={!dragging ? () => toggleSelect(q.id) : undefined}
/>
)}
</SortableRow>
</div>
);
})}
</SortableContext>
<DragOverlay dropAnimation={null}>
{activeDrag && (
<div
style={{
background: "#fff",
border: "1px solid #cfe0f2",
borderRadius: 12,
boxShadow: "0 12px 32px rgba(30,58,95,0.18)",
transform: "scale(1.02)",
padding: "10px 14px",
width: 380,
maxWidth: "calc(100vw - 32px)",
}}
>
<div style={{ fontSize: 13, fontWeight: 600, color: "#1e3a5f", marginBottom: 4 }}>
#{activeDrag.q.id}
{effectiveGroup(activeDrag.q) !== UNGROUPED && (
<span style={{ marginLeft: 8, fontWeight: 400 }}>{effectiveGroup(activeDrag.q)}</span>
)}
</div>
<div style={{ fontSize: 13, color: "#1f2937", lineHeight: 1.5 }}>
{activeDrag.q.text || "(空题干)"}
</div>
</div>
)}
</DragOverlay>
</DndContext>
</div>
)}
</div>
</div>
);
};
export default QuestionList;