Files
model-platform/frontend/app/features/admin/UserMultiSelect.tsx
T
tao.chenandClaude Fable 5 91031342b8 refactor(frontend): consolidate features & components into single tree
把 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>
2026-08-25 11:27:26 +08:00

173 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}