360 lines
11 KiB
TypeScript
360 lines
11 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
||
|
||
import { ArrowLeft, Search } from "lucide-react";
|
||
import { type Employee } from "../../services/api";
|
||
import { Button } from "~/components/ui/button";
|
||
import { dialogSecondaryButtonClass } from "~/components/common/AppFormDialog";
|
||
import { primaryGradientButtonClass } from "~/components/common/buttonClasses";
|
||
import { useDebouncedValue } from "./useDebouncedValue";
|
||
|
||
export type LoadUsersFn = (input: {
|
||
q: string;
|
||
cursor: string | null;
|
||
limit: number;
|
||
}) => Promise<{
|
||
items: Employee[];
|
||
hasMore: boolean;
|
||
nextCursor: string | null;
|
||
}>;
|
||
|
||
export function MemberAddPanel({
|
||
existingMemberIds,
|
||
saving,
|
||
loadUsers,
|
||
onBack,
|
||
onAddMembers,
|
||
}: {
|
||
existingMemberIds: string[];
|
||
saving: boolean;
|
||
loadUsers: LoadUsersFn;
|
||
onBack: () => void;
|
||
onAddMembers: (userIds: string[]) => Promise<void> | void;
|
||
}) {
|
||
const [keyword, setKeyword] = useState("");
|
||
const debouncedKeyword = useDebouncedValue(keyword, 300);
|
||
const [users, setUsers] = useState<Employee[]>([]);
|
||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||
const [selectedCache, setSelectedCache] = useState<Record<string, Employee>>({});
|
||
const [loading, setLoading] = useState(false);
|
||
const [loadingMore, setLoadingMore] = useState(false);
|
||
const [hasMore, setHasMore] = useState(false);
|
||
const nextCursorRef = useRef<string | null>(null);
|
||
const requestIdRef = useRef(0);
|
||
|
||
const fetchPage = useCallback(
|
||
async (cursor: string | null, append: boolean) => {
|
||
const requestId = ++requestIdRef.current;
|
||
if (append) setLoadingMore(true);
|
||
else setLoading(true);
|
||
try {
|
||
const result = await loadUsers({
|
||
q: debouncedKeyword,
|
||
cursor,
|
||
limit: 10,
|
||
});
|
||
if (requestId !== requestIdRef.current) return;
|
||
setUsers((current) => (append ? [...current, ...result.items] : result.items));
|
||
setHasMore(result.hasMore);
|
||
nextCursorRef.current = result.nextCursor;
|
||
} finally {
|
||
if (requestId === requestIdRef.current) {
|
||
setLoading(false);
|
||
setLoadingMore(false);
|
||
}
|
||
}
|
||
},
|
||
[debouncedKeyword, loadUsers],
|
||
);
|
||
|
||
useEffect(() => {
|
||
nextCursorRef.current = null;
|
||
void fetchPage(null, false);
|
||
}, [debouncedKeyword, fetchPage]);
|
||
|
||
const toggleUser = (user: Employee) => {
|
||
if (existingMemberIds.includes(user.user_id)) return;
|
||
setSelectedCache((cache) => ({ ...cache, [user.user_id]: user }));
|
||
setSelectedIds((ids) =>
|
||
ids.includes(user.user_id)
|
||
? ids.filter((id) => id !== user.user_id)
|
||
: [...ids, user.user_id],
|
||
);
|
||
};
|
||
|
||
const removeSelected = (userId: string) => {
|
||
setSelectedIds((ids) => ids.filter((id) => id !== userId));
|
||
};
|
||
|
||
const loadMore = () => {
|
||
if (loadingMore || loading || !hasMore || !nextCursorRef.current) return;
|
||
void fetchPage(nextCursorRef.current, true);
|
||
};
|
||
|
||
const submitAdd = async () => {
|
||
if (selectedIds.length === 0) return;
|
||
try {
|
||
await onAddMembers(selectedIds);
|
||
onBack();
|
||
} catch {
|
||
// stay on add panel; page already toasts
|
||
}
|
||
};
|
||
|
||
const selectedUsers = selectedIds
|
||
.map((id) => selectedCache[id] ?? users.find((u) => u.user_id === id))
|
||
.filter((u): u is Employee => Boolean(u));
|
||
|
||
return (
|
||
<>
|
||
<div
|
||
style={{
|
||
padding: "12px 20px",
|
||
borderBottom: "1px solid #f0f0f0",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 10,
|
||
}}
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={onBack}
|
||
style={{
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
gap: 4,
|
||
border: 0,
|
||
background: "transparent",
|
||
color: "#1474d4",
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
cursor: "pointer",
|
||
padding: 0,
|
||
width: "fit-content",
|
||
}}
|
||
>
|
||
<ArrowLeft size={14} /> 返回成员列表
|
||
</button>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
border: "1px solid #d9e2eb",
|
||
borderRadius: 6,
|
||
background: "#f8fafb",
|
||
padding: "8px 10px",
|
||
}}
|
||
>
|
||
<Search size={14} color="#8a99a8" />
|
||
<input
|
||
value={keyword}
|
||
onChange={(event) => setKeyword(event.target.value)}
|
||
placeholder="搜索姓名 / 账号"
|
||
style={{
|
||
flex: 1,
|
||
border: 0,
|
||
outline: "none",
|
||
background: "transparent",
|
||
fontSize: 12,
|
||
color: "#20364c",
|
||
}}
|
||
/>
|
||
</div>
|
||
{selectedUsers.length > 0 && (
|
||
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
|
||
{selectedUsers.map((user) => (
|
||
<span
|
||
key={user.user_id}
|
||
style={{
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
gap: 4,
|
||
backgroundColor: "#e6f7ff",
|
||
color: "#1890ff",
|
||
border: "1px solid #91d5ff",
|
||
borderRadius: 4,
|
||
padding: "2px 8px",
|
||
fontSize: 12,
|
||
}}
|
||
>
|
||
{user.display_name}
|
||
<button
|
||
type="button"
|
||
onClick={() => removeSelected(user.user_id)}
|
||
style={{
|
||
border: 0,
|
||
background: "transparent",
|
||
color: "#1890ff",
|
||
cursor: "pointer",
|
||
fontWeight: 700,
|
||
padding: 0,
|
||
lineHeight: 1,
|
||
}}
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div style={{ flex: 1, overflow: "auto", padding: "6px 10px" }}>
|
||
{loading && users.length === 0 ? (
|
||
<p style={{ textAlign: "center", color: "#999", padding: "24px 0", fontSize: 12 }}>
|
||
加载中…
|
||
</p>
|
||
) : users.length === 0 ? (
|
||
<p style={{ textAlign: "center", color: "#999", padding: "24px 0", fontSize: 12 }}>
|
||
{debouncedKeyword.trim() ? "未找到匹配用户" : "暂无可选用户"}
|
||
</p>
|
||
) : (
|
||
users.map((user) => {
|
||
const isExisting = existingMemberIds.includes(user.user_id);
|
||
const isSelected = selectedIds.includes(user.user_id);
|
||
return (
|
||
<button
|
||
key={user.user_id}
|
||
type="button"
|
||
disabled={isExisting}
|
||
onClick={() => toggleUser(user)}
|
||
style={{
|
||
display: "flex",
|
||
width: "100%",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
padding: "5px 6px",
|
||
minHeight: 28,
|
||
border: 0,
|
||
borderBottom: "1px solid #f3f5f7",
|
||
background: isSelected ? "#f0f7ff" : "transparent",
|
||
cursor: isExisting ? "not-allowed" : "pointer",
|
||
opacity: isExisting ? 0.55 : 1,
|
||
textAlign: "left",
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
width: 14,
|
||
height: 14,
|
||
borderRadius: 3,
|
||
border: `1px solid ${isSelected || isExisting ? "#1890ff" : "#c5d0db"}`,
|
||
background: isSelected ? "#1890ff" : "#fff",
|
||
color: "#fff",
|
||
fontSize: 10,
|
||
display: "grid",
|
||
placeItems: "center",
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
{isSelected ? "✓" : ""}
|
||
</span>
|
||
<span
|
||
style={{
|
||
flex: 1,
|
||
minWidth: 0,
|
||
display: "flex",
|
||
alignItems: "baseline",
|
||
gap: 6,
|
||
overflow: "hidden",
|
||
}}
|
||
>
|
||
<strong
|
||
style={{
|
||
fontSize: 14,
|
||
color: "#23384e",
|
||
fontWeight: 600,
|
||
whiteSpace: "nowrap",
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
}}
|
||
>
|
||
{user.display_name}
|
||
</strong>
|
||
<small
|
||
style={{
|
||
fontSize: 13,
|
||
color: "#8896a6",
|
||
whiteSpace: "nowrap",
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
}}
|
||
>
|
||
{user.username}
|
||
</small>
|
||
</span>
|
||
{isExisting && (
|
||
<span
|
||
style={{
|
||
fontSize: 12,
|
||
color: "#999",
|
||
background: "#f0f0f0",
|
||
padding: "0 5px",
|
||
borderRadius: 2,
|
||
lineHeight: "16px",
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
已在项目中
|
||
</span>
|
||
)}
|
||
</button>
|
||
);
|
||
})
|
||
)}
|
||
{hasMore && (
|
||
<div style={{ display: "flex", justifyContent: "center", padding: "10px 0 6px" }}>
|
||
<button
|
||
type="button"
|
||
onClick={loadMore}
|
||
disabled={loadingMore || loading}
|
||
style={{
|
||
border: "1px solid #d9e3ec",
|
||
background: "#fff",
|
||
color: "#4c6c88",
|
||
borderRadius: 4,
|
||
padding: "4px 12px",
|
||
fontSize: 11,
|
||
cursor: loadingMore || loading ? "wait" : "pointer",
|
||
opacity: loadingMore || loading ? 0.65 : 1,
|
||
}}
|
||
>
|
||
{loadingMore ? "加载中…" : "加载更多"}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div
|
||
style={{
|
||
padding: "12px 20px",
|
||
borderTop: "1px solid #e8e8e8",
|
||
display: "flex",
|
||
justifyContent: "flex-end",
|
||
gap: 8,
|
||
}}
|
||
>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={onBack}
|
||
disabled={saving}
|
||
className={`${dialogSecondaryButtonClass} !rounded-[4px] min-w-[72px]`}
|
||
>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
className={`${primaryGradientButtonClass} !rounded-[4px]`}
|
||
disabled={saving || selectedIds.length === 0}
|
||
onClick={() => void submitAdd()}
|
||
>
|
||
{saving ? "添加中…" : `添加 (${selectedIds.length})`}
|
||
</Button>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|