Files

360 lines
11 KiB
TypeScript
Raw Permalink 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 { 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>
</>
);
}