feat: v2 可视化量表编辑器(卡片流+虚拟列表+拖拽+因子分组+批量+核对视图+撤销重做+草稿)+ ora P1 修复(MMPI items 因子迁移/展开区击键/双编辑器冲突保护)
This commit is contained in:
@@ -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-1:MMPI 等量表的因子归属在 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]);
|
||||
|
||||
// ===== dnd(Pointer/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;
|
||||
Reference in New Issue
Block a user