把 components/admin/* 和 components/platform/* 共 11 个文件
移到对应 features/<name>/ 下,components/ 只保留跨特性共享的
common/ 子目录(features/admin/* 之前和 components/admin/* 同名
共存,迫使 features/admin/AdminPages.tsx 出现 barrel,顺带消除了
barrel 副作用 CSS 丢失的隐患)。
新的目录约定:
- features/<name>/ = 特性模块,所有页面 / Modal / state / hooks
/ routes 都在内,无 components/<name>/ 并列
- components/common/ = 仅放 ≥2 个特性共用的 widgets
(Icon、Sidebar、Toast、Topbar、WelcomePanel)
改动:
- 11 文件 git mv (components/{admin,platform}/* → features/*)
- 8 文件的 Icon 相对路径 '../common/Icon' → '../../components/common/Icon'
- 2 文件补 admin.css side-effect import(UserManagementPage /
ProjectManagementPage 之前依赖 AdminPages barrel)
- 3 文件(引用方)更新 import:SystemAdminPage、DashboardRoute、
ScriptsPage
- 删除 features/admin/AdminPages.tsx barrel(无消费者)
- 删除空目录 components/admin、components/platform
验证:
- pnpm typecheck 通过
- pnpm build 通过,CSS 体积 35.11 kB 与重构前一致(无样式增减)
- routes.ts / routes/platform.tsx 路径未动,route id 不变
踩坑:
第一轮把 '../common/Icon' 错改成 '../../../components/common/Icon',
typecheck 报 11 个 Cannot find module。原因:features/admin/ 和
components/admin/ 都是 depth 2,'../../X' 在两边都解析到 app/X,
只有 '../X' 才需要多一层。正确改法是 '../../components/common/Icon'。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
173 lines
5.4 KiB
TypeScript
173 lines
5.4 KiB
TypeScript
import { useEffect, useState, useRef } from "react";
|
||
|
||
import { type Employee } from "../../services/api";
|
||
|
||
// 多选用户下拉框组件 - 带 tag 显示
|
||
export function UserMultiSelect({
|
||
users,
|
||
selectedUserIds,
|
||
onChange,
|
||
existingMemberIds,
|
||
}: {
|
||
users: Employee[];
|
||
selectedUserIds: string[];
|
||
onChange: (ids: string[]) => void;
|
||
existingMemberIds?: string[];
|
||
}) {
|
||
const [isOpen, setIsOpen] = useState(false);
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
|
||
// 点击外部关闭下拉框
|
||
useEffect(() => {
|
||
const handleClickOutside = (event: MouseEvent) => {
|
||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||
setIsOpen(false);
|
||
}
|
||
};
|
||
document.addEventListener("mousedown", handleClickOutside);
|
||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||
}, []);
|
||
|
||
const selectedUsers = users.filter((user) => selectedUserIds.includes(user.user_id));
|
||
|
||
const toggleUser = (userId: string) => {
|
||
if (selectedUserIds.includes(userId)) {
|
||
onChange(selectedUserIds.filter((id) => id !== userId));
|
||
} else {
|
||
onChange([...selectedUserIds, userId]);
|
||
}
|
||
};
|
||
|
||
const removeUser = (userId: string, event: React.MouseEvent) => {
|
||
event.stopPropagation();
|
||
onChange(selectedUserIds.filter((id) => id !== userId));
|
||
};
|
||
|
||
return (
|
||
<div ref={containerRef} style={{ position: "relative" }}>
|
||
{/* 选择框 - 显示 tag */}
|
||
<div
|
||
className="admin-toolbar__input"
|
||
onClick={() => setIsOpen(!isOpen)}
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
flexWrap: "wrap",
|
||
gap: "4px",
|
||
padding: "6px 32px 6px 8px",
|
||
minHeight: "36px",
|
||
border: "1px solid #ddd",
|
||
borderRadius: "4px",
|
||
cursor: "pointer",
|
||
backgroundColor: "#fff",
|
||
}}
|
||
>
|
||
{selectedUsers.length === 0 ? (
|
||
<span style={{ color: "#999" }}>请选择用户</span>
|
||
) : (
|
||
selectedUsers.map((user) => (
|
||
<span
|
||
key={user.user_id}
|
||
style={{
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
backgroundColor: "#e6f7ff",
|
||
color: "#1890ff",
|
||
border: "1px solid #91d5ff",
|
||
borderRadius: "2px",
|
||
padding: "2px 6px",
|
||
fontSize: "13px",
|
||
}}
|
||
>
|
||
{user.display_name}
|
||
<span
|
||
onClick={(e) => removeUser(user.user_id, e)}
|
||
style={{
|
||
marginLeft: "4px",
|
||
cursor: "pointer",
|
||
fontWeight: "bold",
|
||
fontSize: "14px",
|
||
}}
|
||
>
|
||
×
|
||
</span>
|
||
</span>
|
||
))
|
||
)}
|
||
<span
|
||
style={{
|
||
position: "absolute",
|
||
right: "12px",
|
||
top: "50%",
|
||
transform: "translateY(-50%)",
|
||
color: "#999",
|
||
pointerEvents: "none",
|
||
}}
|
||
>
|
||
{isOpen ? "▲" : "▼"}
|
||
</span>
|
||
</div>
|
||
|
||
{/* 下拉列表 */}
|
||
{isOpen && (
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
top: "100%",
|
||
left: 0,
|
||
right: 0,
|
||
maxHeight: "136px",
|
||
overflowY: "auto",
|
||
border: "1px solid #1890ff",
|
||
borderRadius: "4px",
|
||
backgroundColor: "#fff",
|
||
zIndex: 100,
|
||
marginTop: "2px",
|
||
boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
|
||
}}
|
||
>
|
||
{users.length === 0 ? (
|
||
<div style={{ padding: "12px", color: "#999", textAlign: "center", fontSize: "13px" }}>
|
||
所有可选用户已在项目中
|
||
</div>
|
||
) : (
|
||
users.map((user) => {
|
||
const isSelected = selectedUserIds.includes(user.user_id);
|
||
const isExisting = existingMemberIds?.includes(user.user_id);
|
||
const isDisabled = isExisting;
|
||
return (
|
||
<div
|
||
key={user.user_id}
|
||
onClick={() => !isDisabled && toggleUser(user.user_id)}
|
||
style={{
|
||
display: "flex",
|
||
justifyContent: "space-between",
|
||
alignItems: "center",
|
||
padding: "6px 12px",
|
||
cursor: isDisabled ? "not-allowed" : "pointer",
|
||
backgroundColor: isSelected ? "#e6f7ff" : isExisting ? "#f5f5f5" : "transparent",
|
||
borderBottom: "1px solid #f0f0f0",
|
||
opacity: isDisabled ? 0.6 : 1,
|
||
}}
|
||
>
|
||
<span>
|
||
{user.display_name} <span style={{ color: "#999" }}>({user.username})</span>
|
||
</span>
|
||
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
||
{isExisting && (
|
||
<span style={{ fontSize: "11px", color: "#999", backgroundColor: "#f0f0f0", padding: "1px 6px", borderRadius: "2px" }}>
|
||
已在项目中
|
||
</span>
|
||
)}
|
||
{isSelected && <span style={{ color: "#1890ff" }}>✓</span>}
|
||
</div>
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|