update:添加分页、成员添加优化

This commit is contained in:
xiaozhu
2026-09-02 10:10:41 +08:00
committed by tao.chen
parent 5a90f7db36
commit b7b8e28b52
20 changed files with 1729 additions and 920 deletions
@@ -0,0 +1,359 @@
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>
</>
);
}