Files
Edu/apps/admin-portal/src/components/organization-tree.tsx
SpecialX b3511910d1 feat(admin-portal): 完整实现 admin-portal 管理端微前端
包含 src 全部实现、Dockerfile、配置文件等
2026-07-10 19:09:12 +08:00

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>
);
}