189 lines
4.8 KiB
TypeScript
189 lines
4.8 KiB
TypeScript
"use client";
|
|
|
|
import { type ReactNode, useState, useEffect } from "react";
|
|
import { useOrganization } from "@/hooks/use-organization";
|
|
import type { OrganizationNode } from "@/types/view-models";
|
|
|
|
interface OrganizationTreeProps {
|
|
onSelect?: (node: OrganizationNode) => void;
|
|
}
|
|
|
|
interface TreeNodeProps {
|
|
node: OrganizationNode;
|
|
level: number;
|
|
onSelect?: (node: OrganizationNode) => void;
|
|
}
|
|
|
|
const typeLabel: Record<OrganizationNode["type"], string> = {
|
|
school: "学校",
|
|
grade: "年级",
|
|
class: "班级",
|
|
};
|
|
|
|
function TreeNode({ node, level, onSelect }: TreeNodeProps): ReactNode {
|
|
const [expanded, setExpanded] = useState(level < 2);
|
|
const [children, setChildren] = useState<OrganizationNode[]>([]);
|
|
const [loaded, setLoaded] = useState(false);
|
|
const [selected, setSelected] = useState(false);
|
|
const { data, loading } = useOrganization(expanded ? node.id : undefined);
|
|
|
|
useEffect(() => {
|
|
if (expanded && !loaded && data.length > 0) {
|
|
setChildren(data);
|
|
setLoaded(true);
|
|
}
|
|
}, [expanded, loaded, data]);
|
|
|
|
const hasChildren = node.childrenCount > 0;
|
|
void loading;
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
setSelected(true);
|
|
onSelect?.(node);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<button
|
|
type="button"
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
width: "100%",
|
|
padding: "6px 12px",
|
|
paddingLeft: 12 + level * 16,
|
|
cursor: "pointer",
|
|
color: "var(--color-ink)",
|
|
fontSize: 13,
|
|
background: selected ? "var(--color-accent-light)" : "transparent",
|
|
border: "none",
|
|
borderBottom: "1px solid var(--color-rule)",
|
|
textAlign: "left",
|
|
}}
|
|
onClick={() => {
|
|
setSelected(true);
|
|
onSelect?.(node);
|
|
}}
|
|
onKeyDown={handleKeyDown}
|
|
role="treeitem"
|
|
aria-expanded={hasChildren ? expanded : undefined}
|
|
aria-selected={selected}
|
|
>
|
|
{hasChildren ? (
|
|
<span
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setExpanded((v) => !v);
|
|
}}
|
|
style={{
|
|
cursor: "pointer",
|
|
color: "var(--color-ink-muted)",
|
|
padding: 0,
|
|
marginRight: 4,
|
|
fontSize: 10,
|
|
userSelect: "none",
|
|
}}
|
|
role="button"
|
|
tabIndex={0}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setExpanded((v) => !v);
|
|
}
|
|
}}
|
|
aria-label={expanded ? "折叠" : "展开"}
|
|
>
|
|
{expanded ? "▼" : "▶"}
|
|
</span>
|
|
) : (
|
|
<span style={{ display: "inline-block", width: 14 }} />
|
|
)}
|
|
<span
|
|
style={{
|
|
fontFamily:
|
|
node.type === "school" ? "var(--font-serif)" : "var(--font-sans)",
|
|
}}
|
|
>
|
|
{node.name}
|
|
</span>
|
|
<span
|
|
style={{
|
|
marginLeft: 8,
|
|
fontSize: 10,
|
|
color: "var(--color-ink-muted)",
|
|
padding: "1px 6px",
|
|
border: "1px solid var(--color-rule)",
|
|
borderRadius: 8,
|
|
}}
|
|
>
|
|
{typeLabel[node.type]}
|
|
</span>
|
|
{hasChildren && (
|
|
<span
|
|
style={{
|
|
marginLeft: 8,
|
|
fontSize: 11,
|
|
color: "var(--color-ink-muted)",
|
|
}}
|
|
>
|
|
({node.childrenCount})
|
|
</span>
|
|
)}
|
|
</button>
|
|
{expanded && hasChildren && (
|
|
<div role="group">
|
|
{children.map((child) => (
|
|
<TreeNode
|
|
key={child.id}
|
|
node={child}
|
|
level={level + 1}
|
|
onSelect={onSelect}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function OrganizationTree({
|
|
onSelect,
|
|
}: OrganizationTreeProps): ReactNode {
|
|
const { data: roots, loading, error } = useOrganization(null);
|
|
|
|
if (loading)
|
|
return (
|
|
<div
|
|
style={{ padding: 16, color: "var(--color-ink-muted)", fontSize: 13 }}
|
|
>
|
|
加载中...
|
|
</div>
|
|
);
|
|
if (error)
|
|
return (
|
|
<div style={{ padding: 16, color: "var(--color-danger)", fontSize: 13 }}>
|
|
{error.message}
|
|
</div>
|
|
);
|
|
if (roots.length === 0)
|
|
return (
|
|
<div
|
|
style={{ padding: 16, color: "var(--color-ink-muted)", fontSize: 13 }}
|
|
>
|
|
暂无数据
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div role="tree" aria-label="组织架构树">
|
|
{roots.map((node) => (
|
|
<TreeNode key={node.id} node={node} level={0} onSelect={onSelect} />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|