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

This commit is contained in:
xiaozhu
2026-08-31 15:41:06 +08:00
parent cc38bb1575
commit a4dd317828
20 changed files with 1729 additions and 920 deletions
+1
View File
@@ -5,6 +5,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
theme="light"
position="bottom-right"
className="toaster group"
icons={{
success: (
+2 -2
View File
@@ -270,7 +270,7 @@ export function useApi(): WorkspaceBoundApi {
hideScheduleArtifact: (versionsId) =>
rawApi.hideScheduleArtifact(workspaceId, versionsId),
listEmployees: () => rawApi.listEmployees(workspaceId),
listPlatformEmployees: () => rawApi.listPlatformEmployees(),
listPlatformEmployees: (input) => rawApi.listPlatformEmployees(input),
createEmployee: (input) => rawApi.createEmployee(workspaceId, input),
createPlatformEmployee: (input) => rawApi.createPlatformEmployee(input),
updateEmployee: (userId, input) =>
@@ -312,7 +312,7 @@ export function useApi(): WorkspaceBoundApi {
getScheduleNodeRunArtifacts: (runId, nodeRunId) =>
rawApi.getScheduleNodeRunArtifacts(workspaceId, runId, nodeRunId),
// Workspace (Project) Management - 系统管理接口(跨 workspace,不需要传入 workspaceId
listWorkspaces: () => rawApi.listWorkspaces(),
listWorkspaces: (input) => rawApi.listWorkspaces(input),
createWorkspace: (input) => rawApi.createWorkspace(input),
updateWorkspace: (workspaceId, input) => rawApi.updateWorkspace(workspaceId, input),
deleteWorkspace: (workspaceId) => rawApi.deleteWorkspace(workspaceId),
@@ -0,0 +1,117 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "~/components/ui/button";
/** Build a compact page number list with ellipsis, e.g. 1 … 4 5 6 … 20 */
function visiblePages(current: number, total: number): Array<number | "ellipsis"> {
if (total <= 7) {
return Array.from({ length: total }, (_, index) => index + 1);
}
const pages = new Set<number>([1, total, current, current - 1, current + 1]);
if (current <= 3) {
pages.add(2);
pages.add(3);
pages.add(4);
}
if (current >= total - 2) {
pages.add(total - 1);
pages.add(total - 2);
pages.add(total - 3);
}
const sorted = [...pages].filter((p) => p >= 1 && p <= total).sort((a, b) => a - b);
const result: Array<number | "ellipsis"> = [];
for (const page of sorted) {
const prev = result[result.length - 1];
if (typeof prev === "number" && page - prev > 1) {
result.push("ellipsis");
}
result.push(page);
}
return result;
}
export function AdminPagination({
page,
totalPages,
totalCount,
loading,
hasMore,
canGoToPage,
onPrev,
onNext,
onGoToPage,
}: {
page: number;
totalPages: number;
totalCount: number;
loading?: boolean;
hasMore: boolean;
canGoToPage: (page: number) => boolean;
onPrev: () => void;
onNext: () => void;
onGoToPage: (page: number) => void;
}) {
if (totalCount === 0) return null;
const pages = visiblePages(page, totalPages);
return (
<div className="mt-3 flex flex-wrap items-center justify-between gap-2 text-[11px] text-[#5d7186]">
<span>
<strong className="text-[#27394d]">{totalCount}</strong> {page}/{totalPages}
</span>
<div className="flex items-center gap-1">
<Button
type="button"
variant="outline"
size="xs"
disabled={loading || page <= 1}
onClick={onPrev}
className="h-7 gap-0.5 rounded-[5px] border-[#d9e3ec] bg-white px-2 text-[11px] text-[#4c6c88]"
>
<ChevronLeft size={14} />
</Button>
{pages.map((item, index) =>
item === "ellipsis" ? (
<span key={`e-${index}`} className="px-1 text-[#94a2b3]">
</span>
) : (
<Button
key={item}
type="button"
variant="outline"
size="xs"
disabled={loading || !canGoToPage(item)}
onClick={() => onGoToPage(item)}
className={`h-7 min-w-7 rounded-[5px] px-2 text-[11px] ${
item === page
? "border-brand bg-brand-soft font-semibold text-brand"
: "border-[#d9e3ec] bg-white text-[#4c6c88] disabled:opacity-40"
}`}
title={
canGoToPage(item)
? undefined
: "请先通过上一页/下一页依次访问该页"
}
>
{item}
</Button>
),
)}
<Button
type="button"
variant="outline"
size="xs"
disabled={loading || !hasMore}
onClick={onNext}
className="h-7 gap-0.5 rounded-[5px] border-[#d9e3ec] bg-white px-2 text-[11px] text-[#4c6c88]"
>
<ChevronRight size={14} />
</Button>
</div>
</div>
);
}
@@ -1,81 +0,0 @@
import { type Employee, type Workspace } from "../../services/api";
import {
AppFormDialog,
dialogPrimaryButtonClass,
dialogSecondaryButtonClass,
} from "~/components/common/AppFormDialog";
import { Button } from "~/components/ui/button";
import {
formFieldClass,
modalFormClass,
} from "../platform/modalUi";
import { UserMultiSelect } from "./UserMultiSelect";
export function ImportMemberDialog({
open,
selectedProject,
availableUsers,
selectedUserIds,
existingMemberIds,
saving,
onChangeSelectedUserIds,
onSubmit,
onClose,
}: {
open: boolean;
selectedProject: Workspace | null;
availableUsers: Employee[];
selectedUserIds: string[];
existingMemberIds: string[];
saving: boolean;
onNotify: (notice: { tone: "success" | "error" | "info"; message: string }) => void;
onChangeSelectedUserIds: (ids: string[]) => void;
onSubmit: () => Promise<void> | void;
onClose: () => void;
}) {
return (
<AppFormDialog
open={open && selectedProject !== null}
onOpenChange={(nextOpen) => {
if (!nextOpen) onClose();
}}
eyebrow="IMPORT MEMBER"
title={`导入成员到 ${selectedProject?.workspace_name ?? "项目"}`}
footer={
<>
<Button
variant="outline"
size="sm"
type="button"
onClick={() => onClose()}
className={dialogSecondaryButtonClass}
>
</Button>
<Button
variant="default"
size="sm"
type="button"
disabled={saving || selectedUserIds.length === 0}
onClick={() => void onSubmit()}
className={dialogPrimaryButtonClass}
>
{saving ? "添加中…" : `添加 (${selectedUserIds.length})`}
</Button>
</>
}
>
<div className={modalFormClass}>
<label className={`${formFieldClass} pb-[120px]`}>
<span><span className="text-danger">*</span></span>
<UserMultiSelect
users={availableUsers}
selectedUserIds={selectedUserIds}
onChange={onChangeSelectedUserIds}
existingMemberIds={existingMemberIds}
/>
</label>
</div>
</AppFormDialog>
);
}
@@ -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>
</>
);
}
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { Plus, Search } from "lucide-react";
import { ApiRequestError, type Employee, type Workspace, type WorkspaceMember } from "../../services/api";
import { ApiRequestError, type Workspace, type WorkspaceMember } from "../../services/api";
import { useApi, useAuth } from "../../context/AuthContext";
import { Button } from "~/components/ui/button";
import { ConfirmDialog } from "~/components/common/ConfirmDialog";
@@ -14,9 +14,11 @@ import {
TableRow,
} from "~/components/ui/table";
import { AdminColgroup, USER_PROJECT_COL_WIDTHS } from "./AdminTable";
import { AdminPagination } from "./AdminPagination";
import { ProjectEditDialog } from "./ProjectEditDialog";
import { ImportMemberDialog } from "./ImportMemberDialog";
import { ProjectMembersDrawer } from "./ProjectMembersDrawer";
import { useCursorPage } from "./useCursorPage";
import { useDebouncedValue } from "./useDebouncedValue";
import {
adminEmptyClass,
adminPageClass,
@@ -50,44 +52,53 @@ export function ProjectManagementPage({
}) {
const api = useApi();
const { user, refreshWorkspaces } = useAuth();
const [projects, setProjects] = useState<Workspace[]>([]);
const [projectLoading, setProjectLoading] = useState(true);
const [projectDialogOpen, setProjectDialogOpen] = useState(false);
const [projectForm, setProjectForm] = useState(EMPTY_PROJECT_FORM);
const [editingProject, setEditingProject] = useState<Workspace | null>(null);
const [importMemberDialogOpen, setImportMemberDialogOpen] = useState(false);
const [selectedProject, setSelectedProject] = useState<Workspace | null>(null);
const [availableUsers, setAvailableUsers] = useState<Employee[]>([]);
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
const [projectSearchTerm, setProjectSearchTerm] = useState("");
const [saving, setSaving] = useState(false);
const [membersDrawerOpen, setMembersDrawerOpen] = useState(false);
const [currentProjectMembers, setCurrentProjectMembers] = useState<WorkspaceMember[]>([]);
const [membersLoading, setMembersLoading] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Workspace | null>(null);
const [removeMemberTarget, setRemoveMemberTarget] = useState<WorkspaceMember | null>(null);
const debouncedSearch = useDebouncedValue(projectSearchTerm, 300);
const canManage = user?.role_code === "admin";
const loadProjects = async (): Promise<void> => {
setProjectLoading(true);
try {
const workspaceList = await api.listWorkspaces();
setProjects(workspaceList);
onConnectionChange(true);
} catch (error) {
onConnectionChange(false);
onNotify({
tone: "error",
message: error instanceof Error ? error.message : "项目列表加载失败",
});
} finally {
setProjectLoading(false);
}
};
const fetchProjects = useCallback(
async (input: { limit: number; cursor: string | null; q: string }) => {
try {
const page = await api.listWorkspaces(input);
onConnectionChange(true);
return page;
} catch (error) {
onConnectionChange(false);
onNotify({
tone: "error",
message: error instanceof Error ? error.message : "项目列表加载失败",
});
throw error;
}
},
[api, onConnectionChange, onNotify],
);
useEffect(() => {
void loadProjects();
}, []);
const {
items: projects,
setItems: setProjects,
page,
loading: projectLoading,
meta,
totalPages,
goNext,
goPrev,
goToPage,
canGoToPage,
reload,
refreshFromStart,
} = useCursorPage(fetchProjects, debouncedSearch);
const openCreateProject = (): void => {
setEditingProject(null);
@@ -125,26 +136,36 @@ export function ProjectManagementPage({
description: projectForm.description.trim() || undefined,
});
setProjects((current) =>
current.map((p) => (p.workspace_id === updated.workspace_id ? updated : p))
current.map((p) => (p.workspace_id === updated.workspace_id ? updated : p)),
);
onNotify({ tone: "success", message: "项目信息已更新" });
} else {
const generatedCode = projectForm.workspace_code.trim() || projectForm.workspace_name.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").slice(0, 32);
const created = await api.createWorkspace({
const generatedCode =
projectForm.workspace_code.trim() ||
projectForm.workspace_name
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]/g, "-")
.slice(0, 32);
await api.createWorkspace({
workspace_code: generatedCode,
workspace_name: projectForm.workspace_name.trim(),
quota_bytes: projectForm.quota_bytes,
description: projectForm.description.trim() || undefined,
});
setProjects((current) => [...current, created]);
void refreshWorkspaces();
onNotify({ tone: "success", message: "项目已创建" });
await refreshFromStart();
}
setProjectDialogOpen(false);
} catch (error) {
onNotify({
tone: "error",
message: error instanceof ApiRequestError ? error.message : (editingProject ? "更新项目失败" : "创建项目失败"),
message: error instanceof ApiRequestError
? error.message
: editingProject
? "更新项目失败"
: "创建项目失败",
});
} finally {
setSaving(false);
@@ -154,8 +175,12 @@ export function ProjectManagementPage({
const executeDeleteProject = async (project: Workspace): Promise<void> => {
try {
await api.deleteWorkspace(project.workspace_id);
setProjects((current) => current.filter((p) => p.workspace_id !== project.workspace_id));
onNotify({ tone: "success", message: "项目已删除" });
if (projects.length <= 1 && page > 1) {
goPrev();
} else {
reload();
}
} catch (error) {
onNotify({
tone: "error",
@@ -164,26 +189,21 @@ export function ProjectManagementPage({
}
};
const openImportMemberDialog = async (project: Workspace): Promise<void> => {
setSelectedProject(project);
setSelectedUserIds([]);
setImportMemberDialogOpen(true);
// 加载可用用户和项目成员
try {
const [allUsers, currentMembers] = await Promise.all([
api.listPlatformEmployees(),
api.listWorkspaceMembers(project.workspace_id),
]);
setAvailableUsers(allUsers);
setCurrentProjectMembers(currentMembers);
} catch (error) {
// 如果加载失败,仍显示所有用户
const allUsers = await api.listPlatformEmployees();
setAvailableUsers(allUsers);
setCurrentProjectMembers([]);
}
};
const loadImportUsers = useCallback(
async (input: { q: string; cursor: string | null; limit: number }) => {
const page = await api.listPlatformEmployees({
limit: input.limit,
cursor: input.cursor,
q: input.q,
});
return {
items: page.items,
hasMore: page.meta.has_more,
nextCursor: page.meta.next_cursor,
};
},
[api],
);
const openMembersDrawer = (project: Workspace): void => {
setSelectedProject(project);
@@ -206,17 +226,23 @@ export function ProjectManagementPage({
}
};
const removeMember = async (userId: string): Promise<void> => {
if (!selectedProject) return;
// 管理员不能被移除
const requestRemoveMember = (userId: string): void => {
const targetMember = currentProjectMembers.find((m) => m.user_id === userId);
if (targetMember?.role_code === "admin") {
if (!targetMember) return;
if (targetMember.role_code === "admin") {
onNotify({ tone: "error", message: "管理员不能被移除" });
return;
}
setRemoveMemberTarget(targetMember);
};
const executeRemoveMember = async (member: WorkspaceMember): Promise<void> => {
if (!selectedProject) return;
try {
await api.deleteWorkspaceMember(selectedProject.workspace_id, userId);
setCurrentProjectMembers((current) => current.filter((m) => m.user_id !== userId));
await api.deleteWorkspaceMember(selectedProject.workspace_id, member.user_id);
setCurrentProjectMembers((current) =>
current.filter((m) => m.user_id !== member.user_id),
);
onNotify({ tone: "success", message: "成员已移除" });
} catch (error) {
onNotify({
@@ -226,32 +252,39 @@ export function ProjectManagementPage({
}
};
const importMember = async (): Promise<void> => {
if (!selectedProject || selectedUserIds.length === 0) {
const importMembers = async (userIds: string[]): Promise<void> => {
if (!selectedProject || userIds.length === 0) {
onNotify({ tone: "error", message: "请选择要添加的用户" });
return;
}
setSaving(true);
try {
await Promise.all(
selectedUserIds.map((userId) =>
userIds.map((userId) =>
api.addWorkspaceMember(selectedProject.workspace_id, {
user_id: userId,
})
)
}),
),
);
setImportMemberDialogOpen(false);
onNotify({ tone: "success", message: `已添加 ${selectedUserIds.length} 名成员` });
onNotify({ tone: "success", message: `已添加 ${userIds.length} 名成员` });
await loadProjectMembers(selectedProject.workspace_id);
} catch (error) {
onNotify({
tone: "error",
message: error instanceof ApiRequestError ? error.message : "添加成员失败",
});
throw error;
} finally {
setSaving(false);
}
};
const emptyMessage = useMemo(() => {
if (projectLoading) return "正在加载项目…";
if (debouncedSearch.trim()) return "未找到匹配的项目";
return "暂无项目";
}, [projectLoading, debouncedSearch]);
return (
<section className={adminPageClass}>
<div className={adminToolbarClass}>
@@ -291,90 +324,90 @@ export function ProjectManagementPage({
</TableRow>
</TableHeader>
<TableBody>
{projectLoading ? (
<TableRow className="min-h-[64px] border-t border-[#edf1f5] hover:bg-[#f7fafc] data-[state=selected]:bg-transparent">
{projectLoading || projects.length === 0 ? (
<TableRow className="min-h-[64px] border-t border-[#edf1f5] hover:bg-transparent data-[state=selected]:bg-transparent">
<TableCell colSpan={5} className="p-0">
<p className={adminEmptyClass}></p>
</TableCell>
</TableRow>
) : projects.length === 0 ? (
<TableRow className="min-h-[64px] border-t border-[#edf1f5] hover:bg-[#f7fafc] data-[state=selected]:bg-transparent">
<TableCell colSpan={5} className="p-0">
<p className={adminEmptyClass}></p>
<p className={adminEmptyClass}>{emptyMessage}</p>
</TableCell>
</TableRow>
) : (
projects
.filter((project) => {
const term = projectSearchTerm.toLowerCase().trim();
if (!term) return true;
return (
project.workspace_name.toLowerCase().includes(term) ||
project.workspace_code.toLowerCase().includes(term) ||
(project.description && project.description.toLowerCase().includes(term))
);
})
.map((project) => (
<TableRow key={project.workspace_id} className="min-h-[64px] border-t border-[#edf1f5] hover:bg-[#f7fafc] data-[state=selected]:bg-transparent">
<TableCell className="p-3 px-4 align-middle">
<span className={employeeNameClass}>
<b className={employeeAvatarClass}>{project.workspace_name.slice(0, 1)}</b>
<span>
<strong>{project.workspace_name}</strong>
<small>{project.description ?? "无描述"}</small>
</span>
</span>
</TableCell>
<TableCell className="p-3 px-4 align-middle"><code>{project.workspace_code}</code></TableCell>
<TableCell className="p-3 px-4 align-middle">
projects.map((project) => (
<TableRow key={project.workspace_id} className="min-h-[64px] border-t border-[#edf1f5] hover:bg-[#f7fafc] data-[state=selected]:bg-transparent">
<TableCell className="p-3 px-4 align-middle">
<span className={employeeNameClass}>
<b className={employeeAvatarClass}>{project.workspace_name.slice(0, 1)}</b>
<span>
<span className={statusPillClass(project.status)}>
{project.status === "active" ? "正常" : project.status === "archived" ? "已归档" : "已删除"}
</span>
<strong>{project.workspace_name}</strong>
<small>{project.description ?? "无描述"}</small>
</span>
</TableCell>
<TableCell className="p-3 px-4 align-middle"><span>{project.quota_bytes > 0 ? `${(project.quota_bytes / 1024 / 1024 / 1024).toFixed(1)} GB` : "无限制"}</span></TableCell>
<TableCell className="p-3 px-4 align-middle">
<span className={employeeActionsClass}>
<Button
variant="outline"
size="xs"
type="button"
disabled={!canManage}
onClick={() => openMembersDrawer(project)}
className={rowButtonClass}
>
</Button>
<Button
variant="outline"
size="xs"
type="button"
disabled={!canManage}
onClick={() => openEditProject(project)}
className={rowButtonClass}
>
</Button>
<Button
variant="destructive"
size="xs"
type="button"
disabled={!canManage || project.status === "disabled"}
title={project.status === "disabled" ? "已删除的项目不能操作" : "删除项目"}
onClick={() => setDeleteTarget(project)}
className={rowDangerButtonClass}
>
</Button>
</span>
</TableCell>
</TableRow>
))
</span>
</TableCell>
<TableCell className="p-3 px-4 align-middle"><code>{project.workspace_code}</code></TableCell>
<TableCell className="p-3 px-4 align-middle">
<span className={statusPillClass(project.status)}>
{project.status === "active" ? "正常" : project.status === "archived" ? "已归档" : "已删除"}
</span>
</TableCell>
<TableCell className="p-3 px-4 align-middle">
<span>
{project.quota_bytes > 0
? `${(project.quota_bytes / 1024 / 1024 / 1024).toFixed(1)} GB`
: "无限制"}
</span>
</TableCell>
<TableCell className="p-3 px-4 align-middle">
<span className={employeeActionsClass}>
<Button
variant="outline"
size="xs"
type="button"
disabled={!canManage}
onClick={() => openMembersDrawer(project)}
className={rowButtonClass}
>
</Button>
<Button
variant="outline"
size="xs"
type="button"
disabled={!canManage}
onClick={() => openEditProject(project)}
className={rowButtonClass}
>
</Button>
<Button
variant="destructive"
size="xs"
type="button"
disabled={!canManage || project.status === "disabled"}
title={project.status === "disabled" ? "已删除的项目不能操作" : "删除项目"}
onClick={() => setDeleteTarget(project)}
className={rowDangerButtonClass}
>
</Button>
</span>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
<AdminPagination
page={page}
totalPages={totalPages}
totalCount={meta.total_count}
loading={projectLoading}
hasMore={meta.has_more}
canGoToPage={canGoToPage}
onPrev={goPrev}
onNext={goNext}
onGoToPage={goToPage}
/>
<ProjectEditDialog
open={projectDialogOpen}
editingProject={editingProject}
@@ -386,32 +419,20 @@ export function ProjectManagementPage({
onClose={() => setProjectDialogOpen(false)}
/>
<ImportMemberDialog
open={importMemberDialogOpen}
selectedProject={selectedProject}
availableUsers={availableUsers}
selectedUserIds={selectedUserIds}
existingMemberIds={currentProjectMembers.map((m) => m.user_id)}
saving={saving}
onNotify={onNotify}
onChangeSelectedUserIds={setSelectedUserIds}
onSubmit={() => void importMember()}
onClose={() => setImportMemberDialogOpen(false)}
/>
<ProjectMembersDrawer
open={membersDrawerOpen}
selectedProject={selectedProject}
members={currentProjectMembers}
membersLoading={membersLoading}
canManage={canManage}
onClose={() => setMembersDrawerOpen(false)}
onAddMembers={() => {
saving={saving}
onClose={() => {
setMembersDrawerOpen(false);
if (selectedProject) void openImportMemberDialog(selectedProject);
setRemoveMemberTarget(null);
}}
onRemoveMember={(userId) => void removeMember(userId)}
onNotify={onNotify}
onRemoveMember={requestRemoveMember}
onAddMembers={importMembers}
loadUsers={loadImportUsers}
/>
<ConfirmDialog
@@ -433,6 +454,26 @@ export function ProjectManagementPage({
setDeleteTarget(null);
}}
/>
<ConfirmDialog
open={removeMemberTarget !== null}
onOpenChange={(nextOpen) => {
if (!nextOpen) setRemoveMemberTarget(null);
}}
title="确定移除成员?"
description={
removeMemberTarget
? `确定将"${removeMemberTarget.display_name}"从项目中移除吗?`
: ""
}
confirmLabel="移除"
destructive
onConfirm={async () => {
if (!removeMemberTarget) return;
await executeRemoveMember(removeMemberTarget);
setRemoveMemberTarget(null);
}}
/>
</section>
);
}
@@ -1,6 +1,12 @@
import { type Workspace, type WorkspaceMember } from "../../services/api";
import { useEffect, useState } from "react";
import { Plus, X } from "lucide-react";
import { type Workspace, type WorkspaceMember } from "../../services/api";
import { Button } from "~/components/ui/button";
import { primaryGradientButtonClass } from "~/components/common/buttonClasses";
import { MemberAddPanel, type LoadUsersFn } from "./MemberAddPanel";
type DrawerPanel = "members" | "add";
export function ProjectMembersDrawer({
open,
@@ -8,151 +14,186 @@ export function ProjectMembersDrawer({
members,
membersLoading,
canManage,
saving,
onClose,
onAddMembers,
onRemoveMember,
onNotify,
onAddMembers,
loadUsers,
}: {
open: boolean;
selectedProject: Workspace | null;
members: WorkspaceMember[];
membersLoading: boolean;
canManage: boolean;
saving: boolean;
onClose: () => void;
onAddMembers: () => void;
onRemoveMember: (userId: string) => void;
onNotify: (notice: { tone: "success" | "error" | "info"; message: string }) => void;
onAddMembers: (userIds: string[]) => Promise<void> | void;
loadUsers: LoadUsersFn;
}) {
const [panel, setPanel] = useState<DrawerPanel>("members");
useEffect(() => {
if (!open) setPanel("members");
}, [open]);
if (!open || !selectedProject) return null;
return (
<>
<div className="drawer-overlay" onClick={() => onClose()} style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0,0,0,0.3)",
zIndex: 99,
}} />
<aside className="drawer" style={{
position: "fixed",
right: 0,
top: 0,
bottom: 0,
width: 480,
backgroundColor: "#fff",
boxShadow: "-2px 0 8px rgba(0,0,0,0.1)",
display: "flex",
flexDirection: "column",
zIndex: 100,
}}>
<div className="drawer__header" style={{
<div
className="drawer-overlay"
onClick={() => onClose()}
style={{
position: "fixed",
inset: 0,
backgroundColor: "rgba(0,0,0,0.3)",
zIndex: 40,
}}
/>
<aside
className="drawer"
style={{
position: "fixed",
right: 0,
top: 0,
bottom: 0,
width: 480,
backgroundColor: "#fff",
boxShadow: "-2px 0 8px rgba(0,0,0,0.1)",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "16px 20px",
borderBottom: "1px solid #e8e8e8",
}}>
flexDirection: "column",
zIndex: 41,
}}
>
<div
className="drawer__header"
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "16px 20px",
borderBottom: "1px solid #e8e8e8",
}}
>
<div>
<span className="modal__eyebrow" style={{ fontSize: 12, color: "#666" }}>MEMBERS</span>
<h3 style={{ margin: 0, fontSize: 18 }}></h3>
<p style={{ margin: "4px 0 0", fontSize: 13, color: "#888" }}>{selectedProject.workspace_name}</p>
{/* <span style={{ fontSize: 12, color: "#666" }}>
{panel === "members" ? "MEMBERS" : "ADD MEMBER"}
</span> */}
<h3 style={{ margin: 0, fontSize: 16 }}>
{panel === "members" ? "项目成员" : "添加成员"}
</h3>
<p style={{ margin: "4px 0 0", fontSize: 12, color: "#888" }}>
{selectedProject.workspace_name}
</p>
</div>
<button className="icon-button" type="button" onClick={() => onClose()}>
<X />
</button>
</div>
<div style={{
padding: "12px 20px",
borderBottom: "1px solid #f0f0f0",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}>
<span style={{ fontSize: 14, color: "#666" }}>
{members.length}
</span>
<button
className={primaryGradientButtonClass}
type="button"
onClick={() => onAddMembers()}
style={{ fontSize: 13, padding: "6px 12px" }}
>
<Plus size={14} />
</button>
</div>
<div style={{ flex: 1, overflow: "auto", padding: "12px 20px" }}>
{membersLoading ? (
<p style={{ textAlign: "center", color: "#999", padding: "40px 0" }}></p>
) : members.length === 0 ? (
<p style={{ textAlign: "center", color: "#999", padding: "40px 0" }}></p>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{members.map((member) => {
const isAdmin = member.role_code === "admin";
return (
<div
key={member.user_id}
style={{
display: "flex",
alignItems: "center",
padding: "6px",
border: "1px solid #e8e8e8",
borderRadius: 6,
backgroundColor: "#fafafa",
}}
>
<div
className="avatar"
style={{
width: 32,
height: 32,
borderRadius: "50%",
backgroundColor: "#1890ff",
color: "#fff",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 14,
fontWeight: 600,
marginRight: 10,
}}
>
{member.display_name.slice(0, 1)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 500, marginBottom: 2, fontSize: 13 }}>{member.display_name}</div>
<div style={{ fontSize: 11, color: "#888" }}>{member.username}</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<button
type="button"
onClick={() => onRemoveMember(member.user_id)}
style={{
padding: "3px 8px",
fontSize: 11,
color: isAdmin ? "#999" : "#ff4d4f",
border: "1px solid " + (isAdmin ? "#ddd" : "#ff4d4f"),
borderRadius: 4,
backgroundColor: "#fff",
cursor: isAdmin ? "not-allowed" : canManage ? "pointer" : "not-allowed",
}}
disabled={!canManage || isAdmin}
title={isAdmin ? "管理员不能被移除" : canManage ? "移除成员" : "无权限"}
>
</button>
</div>
</div>
);
})}
{panel === "members" ? (
<>
<div
style={{
padding: "12px 20px",
borderBottom: "1px solid #f0f0f0",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span style={{ fontSize: 14, color: "#666" }}> {members.length} </span>
{canManage && (
<Button
type="button"
size="sm"
className={primaryGradientButtonClass}
onClick={() => setPanel("add")}
style={{ fontSize: 13, padding: "6px 12px" }}
>
<Plus size={14} />
</Button>
)}
</div>
)}
</div>
<div style={{ flex: 1, overflow: "auto", padding: "12px 20px" }}>
{membersLoading ? (
<p style={{ textAlign: "center", color: "#999", padding: "40px 0" }}></p>
) : members.length === 0 ? (
<p style={{ textAlign: "center", color: "#999", padding: "40px 0" }}></p>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{members.map((member) => {
const isAdmin = member.role_code === "admin";
return (
<div
key={member.user_id}
style={{
display: "flex",
alignItems: "center",
padding: 6,
border: "1px solid #e8e8e8",
borderRadius: 6,
backgroundColor: "#fafafa",
}}
>
<div
style={{
width: 32,
height: 32,
borderRadius: "50%",
backgroundColor: "#1890ff",
color: "#fff",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 14,
fontWeight: 600,
marginRight: 10,
}}
>
{member.display_name.slice(0, 1)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 500, marginBottom: 2, fontSize: 13 }}>
{member.display_name}
</div>
<div style={{ fontSize: 11, color: "#888" }}>{member.username}</div>
</div>
<button
type="button"
onClick={() => onRemoveMember(member.user_id)}
style={{
padding: "3px 8px",
fontSize: 11,
color: isAdmin ? "#999" : "#ff4d4f",
border: `1px solid ${isAdmin ? "#ddd" : "#ff4d4f"}`,
borderRadius: 4,
backgroundColor: "#fff",
cursor: isAdmin || !canManage ? "not-allowed" : "pointer",
}}
disabled={!canManage || isAdmin}
title={isAdmin ? "管理员不能被移除" : canManage ? "移除成员" : "无权限"}
>
</button>
</div>
);
})}
</div>
)}
</div>
</>
) : (
<MemberAddPanel
existingMemberIds={members.map((m) => m.user_id)}
saving={saving}
loadUsers={loadUsers}
onBack={() => setPanel("members")}
onAddMembers={onAddMembers}
/>
)}
</aside>
</>
);
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Plus, Search } from "lucide-react";
import { ApiRequestError, type Employee, type Role } from "../../services/api";
@@ -20,6 +20,8 @@ import {
TableRow,
} from "~/components/ui/table";
import { AdminColgroup, USER_PROJECT_COL_WIDTHS } from "./AdminTable";
import { AdminPagination } from "./AdminPagination";
import { useCursorPage } from "./useCursorPage";
import {
formFieldClass,
formInputClass,
@@ -42,6 +44,7 @@ import {
} from "./adminUi";
import { primaryGradientButtonClass } from "~/components/common/buttonClasses";
import { useDebouncedValue } from "./useDebouncedValue";
const EMPTY_FORM = {
username: "",
@@ -54,6 +57,7 @@ const EMPTY_FORM = {
const FORM_INPUT_CLASS =
"h-[39px] w-full rounded-md border border-[#d6e0e9] bg-white px-[11px] text-xs text-[#283a4e] outline-none focus:border-[#5b9ddb] focus:ring-3 focus:ring-brand/10 disabled:cursor-not-allowed disabled:opacity-50";
export function UserManagementPage({
onNotify,
onConnectionChange,
@@ -63,42 +67,55 @@ export function UserManagementPage({
}) {
const api = useApi();
const { user } = useAuth();
const [employees, setEmployees] = useState<Employee[]>([]);
const [roles, setRoles] = useState<Role[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [editing, setEditing] = useState<Employee | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState(EMPTY_FORM);
const [userSearchTerm, setUserSearchTerm] = useState("");
const [deleteTarget, setDeleteTarget] = useState<Employee | null>(null);
const debouncedSearch = useDebouncedValue(userSearchTerm, 300);
const canManage = user?.role_code === "admin";
const load = async (): Promise<void> => {
setLoading(true);
try {
const [employeeList, roleList] = await Promise.all([
api.listPlatformEmployees(),
api.listPlatformRoles(),
]);
setEmployees(employeeList);
setRoles(roleList);
onConnectionChange(true);
} catch (error) {
onConnectionChange(false);
onNotify({
tone: "error",
message: error instanceof Error ? error.message : "用户列表加载失败",
});
} finally {
setLoading(false);
}
};
const fetchEmployees = useCallback(
async (input: { limit: number; cursor: string | null; q: string }) => {
try {
const page = await api.listPlatformEmployees(input);
onConnectionChange(true);
return page;
} catch (error) {
onConnectionChange(false);
onNotify({
tone: "error",
message: error instanceof Error ? error.message : "用户列表加载失败",
});
throw error;
}
},
[api, onConnectionChange, onNotify],
);
const {
items: employees,
setItems: setEmployees,
page,
loading,
meta,
totalPages,
goNext,
goPrev,
goToPage,
canGoToPage,
reload,
refreshFromStart,
} = useCursorPage(fetchEmployees, debouncedSearch);
useEffect(() => {
void load();
}, []);
void api.listPlatformRoles().then(setRoles).catch(() => {
/* roles only needed for edit dialog */
});
}, [api]);
const openCreate = (): void => {
setEditing(null);
@@ -122,12 +139,10 @@ export function UserManagementPage({
const submit = async (event: React.FormEvent): Promise<void> => {
event.preventDefault();
if (!form.display_name.trim() || (!editing && !form.username.trim())) return;
// 新建用户时密码必填
if (!editing && !form.password.trim()) {
onNotify({ tone: "error", message: "请输入密码" });
return;
}
// 编辑自身时,前端二次拦截禁用 status / role_code 的修改;管理员账号禁止停用/锁定
if (editing) {
const editingSelf = editing.user_id === user?.user_id;
const targetIsAdmin = editing.role_code === "admin";
@@ -144,7 +159,6 @@ export function UserManagementPage({
return;
}
}
// 密码长度校验 8~72 字符
if (form.password && (form.password.length < 8 || form.password.length > 72)) {
onNotify({ tone: "error", message: "密码长度必须在 8~72 字符之间" });
return;
@@ -158,20 +172,20 @@ export function UserManagementPage({
role_code: form.role_code,
status: form.status,
});
setEmployees((current) => current.map(
(item) => item.user_id === updated.user_id ? updated : item,
));
setEmployees((current) =>
current.map((item) => (item.user_id === updated.user_id ? updated : item)),
);
onNotify({ tone: "success", message: "用户信息已更新" });
} else {
const created = await api.createPlatformEmployee({
await api.createPlatformEmployee({
username: form.username.trim(),
display_name: form.display_name.trim(),
email: form.email.trim() || undefined,
password: form.password,
role_code: "developer",
});
setEmployees((current) => [...current, created]);
onNotify({ tone: "success", message: "用户已添加" });
await refreshFromStart();
}
setDialogOpen(false);
} catch (error) {
@@ -187,8 +201,12 @@ export function UserManagementPage({
const executeRemove = async (employee: Employee): Promise<void> => {
try {
await api.deletePlatformEmployee(employee.user_id);
setEmployees((current) => current.filter((item) => item.user_id !== employee.user_id));
onNotify({ tone: "success", message: "用户已删除" });
if (employees.length <= 1 && page > 1) {
goPrev();
} else {
reload();
}
} catch (error) {
onNotify({
tone: "error",
@@ -197,6 +215,12 @@ export function UserManagementPage({
}
};
const emptyMessage = useMemo(() => {
if (loading) return "正在加载用户…";
if (debouncedSearch.trim()) return "未找到匹配的用户";
return "暂无用户";
}, [loading, debouncedSearch]);
return (
<section className={adminPageClass}>
<div className={adminToolbarClass}>
@@ -236,61 +260,63 @@ export function UserManagementPage({
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow className="min-h-[64px] border-t border-[#edf1f5] hover:bg-[#f7fafc] data-[state=selected]:bg-transparent">
{loading || employees.length === 0 ? (
<TableRow className="min-h-[64px] border-t border-[#edf1f5] hover:bg-transparent data-[state=selected]:bg-transparent">
<TableCell colSpan={5} className="p-0">
<p className={adminEmptyClass}></p>
<p className={adminEmptyClass}>{emptyMessage}</p>
</TableCell>
</TableRow>
) : (
employees
.filter((employee) => {
const term = userSearchTerm.toLowerCase().trim();
if (!term) return true;
return (
employee.display_name.toLowerCase().includes(term) ||
employee.username.toLowerCase().includes(term) ||
(employee.email && employee.email.toLowerCase().includes(term))
);
})
.map((employee) => {
const isProtectedAdmin = employee.role_code === "admin";
return (
<TableRow key={employee.user_id} className="min-h-[64px] border-t border-[#edf1f5] hover:bg-[#f7fafc] data-[state=selected]:bg-transparent">
<TableCell className="p-3 px-4 align-middle"><span className={employeeNameClass}><b className={employeeAvatarClass}>{employee.display_name.slice(0, 1)}</b><span><strong>{employee.display_name}</strong><small>{employee.email ?? "未设置邮箱"}</small></span></span></TableCell>
<TableCell className="p-3 px-4 align-middle"><code>{employee.username}</code></TableCell>
<TableCell className="p-3 px-4 align-middle">{employee.role_code ? (<span className={rolePillClass(employee.role_code)}>{employee.role_name}</span>) : (<span>-</span>)}</TableCell>
<TableCell className="p-3 px-4 align-middle"><span className={statusPillClass(employee.status)}>{employee.status === "active" ? "正常" : employee.status === "disabled" ? "已停用" : "已锁定"}</span></TableCell>
<TableCell className="p-3 px-4 align-middle"><span className={employeeActionsClass}>
<Button
variant="outline"
size="xs"
type="button"
disabled={!canManage}
onClick={() => openEdit(employee)}
className={rowButtonClass}
>
</Button>
<Button
variant="destructive"
size="xs"
type="button"
disabled={!canManage || isProtectedAdmin}
title={isProtectedAdmin ? "管理员账号不能删除" : "删除"}
onClick={() => setDeleteTarget(employee)}
className={rowDangerButtonClass}
>
</Button>
</span></TableCell>
</TableRow>
);
})
employees.map((employee) => {
const isProtectedAdmin = employee.role_code === "admin";
return (
<TableRow key={employee.user_id} className="min-h-[64px] border-t border-[#edf1f5] hover:bg-[#f7fafc] data-[state=selected]:bg-transparent">
<TableCell className="p-3 px-4 align-middle"><span className={employeeNameClass}><b className={employeeAvatarClass}>{employee.display_name.slice(0, 1)}</b><span><strong>{employee.display_name}</strong><small>{employee.email ?? "未设置邮箱"}</small></span></span></TableCell>
<TableCell className="p-3 px-4 align-middle"><code>{employee.username}</code></TableCell>
<TableCell className="p-3 px-4 align-middle">{employee.role_code ? (<span className={rolePillClass(employee.role_code)}>{employee.role_name}</span>) : (<span>-</span>)}</TableCell>
<TableCell className="p-3 px-4 align-middle"><span className={statusPillClass(employee.status)}>{employee.status === "active" ? "正常" : employee.status === "disabled" ? "已停用" : "已锁定"}</span></TableCell>
<TableCell className="p-3 px-4 align-middle"><span className={employeeActionsClass}>
<Button
variant="outline"
size="xs"
type="button"
disabled={!canManage}
onClick={() => openEdit(employee)}
className={rowButtonClass}
>
</Button>
<Button
variant="destructive"
size="xs"
type="button"
disabled={!canManage || isProtectedAdmin}
title={isProtectedAdmin ? "管理员账号不能删除" : "删除"}
onClick={() => setDeleteTarget(employee)}
className={rowDangerButtonClass}
>
</Button>
</span></TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
<AdminPagination
page={page}
totalPages={totalPages}
totalCount={meta.total_count}
loading={loading}
hasMore={meta.has_more}
canGoToPage={canGoToPage}
onPrev={goPrev}
onNext={goNext}
onGoToPage={goToPage}
/>
<AppFormDialog
open={dialogOpen}
onOpenChange={(nextOpen) => {
@@ -1,173 +0,0 @@
import { useEffect, useState, useRef } from "react";
import { type Employee } from "../../services/api";
import { adminToolbarInputClass } from "./adminUi";
// 多选用户下拉框组件 - 带 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={adminToolbarInputClass}
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>
);
}
@@ -367,8 +367,8 @@ export const useAdminStore = create<State & Actions>((set, get) => ({
const api = requireApi();
set({ projectsLoading: true });
try {
const list = await api.listWorkspaces();
set({ projects: list });
const page = await api.listWorkspaces({ limit: 10 });
set({ projects: page.items });
onConnectionChange(true);
} catch (error) {
onConnectionChange(false);
@@ -0,0 +1,128 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { CursorPage, CursorPageMeta } from "~/services/api";
export const ADMIN_PAGE_SIZE = 10;
type Fetcher<T> = (input: {
limit: number;
cursor: string | null;
q: string;
}) => Promise<CursorPage<T>>;
/**
* Cursor-stack pagination for admin tables.
* Supports prev/next and jumping to already-visited pages.
* Changing ``q`` resets to page 1.
*/
export function useCursorPage<T>(fetcher: Fetcher<T>, q: string) {
const [items, setItems] = useState<T[]>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [meta, setMeta] = useState<CursorPageMeta>({
limit: ADMIN_PAGE_SIZE,
page_count: 0,
total_count: 0,
has_more: false,
next_cursor: null,
});
// cursor used to *enter* page N. Page 1 is always null.
const cursorByPageRef = useRef<Record<number, string | null>>({ 1: null });
const nextCursorByPageRef = useRef<Record<number, string | null>>({});
const requestIdRef = useRef(0);
const totalPages = Math.max(1, Math.ceil(meta.total_count / ADMIN_PAGE_SIZE));
const loadPage = useCallback(
async (targetPage: number, options?: { resetStack?: boolean }) => {
const requestId = ++requestIdRef.current;
setLoading(true);
try {
if (options?.resetStack) {
cursorByPageRef.current = { 1: null };
nextCursorByPageRef.current = {};
}
const cursor = cursorByPageRef.current[targetPage] ?? null;
if (targetPage > 1 && cursor === null && targetPage !== 1) {
// Unvisited deep page — refuse rather than invent offset.
return;
}
const result = await fetcher({
limit: ADMIN_PAGE_SIZE,
cursor: targetPage === 1 ? null : cursor,
q,
});
if (requestId !== requestIdRef.current) return;
setItems(result.items);
setMeta(result.meta);
setPage(targetPage);
nextCursorByPageRef.current[targetPage] = result.meta.next_cursor;
if (result.meta.next_cursor) {
cursorByPageRef.current[targetPage + 1] = result.meta.next_cursor;
}
} catch {
// Caller is responsible for notifying; keep previous items.
} finally {
if (requestId === requestIdRef.current) {
setLoading(false);
}
}
},
[fetcher, q],
);
useEffect(() => {
void loadPage(1, { resetStack: true });
}, [loadPage]);
const goNext = useCallback(() => {
if (!meta.has_more) return;
void loadPage(page + 1);
}, [loadPage, meta.has_more, page]);
const goPrev = useCallback(() => {
if (page <= 1) return;
void loadPage(page - 1);
}, [loadPage, page]);
const goToPage = useCallback(
(targetPage: number) => {
if (targetPage < 1 || targetPage > totalPages) return;
if (targetPage === page) return;
// Only allow visited pages (cursor known) or page 1.
if (targetPage !== 1 && cursorByPageRef.current[targetPage] == null) {
return;
}
void loadPage(targetPage);
},
[loadPage, page, totalPages],
);
const canGoToPage = useCallback(
(targetPage: number) => {
if (targetPage < 1 || targetPage > totalPages) return false;
if (targetPage === 1 || targetPage === page) return true;
return cursorByPageRef.current[targetPage] != null;
},
[page, totalPages],
);
const reload = useCallback(() => {
void loadPage(page);
}, [loadPage, page]);
return {
items,
setItems,
page,
loading,
meta,
totalPages,
goNext,
goPrev,
goToPage,
canGoToPage,
reload,
refreshFromStart: () => loadPage(1, { resetStack: true }),
};
}
@@ -0,0 +1,12 @@
import { useEffect, useState } from "react";
export function useDebouncedValue<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = window.setTimeout(() => setDebounced(value), delayMs);
return () => window.clearTimeout(timer);
}, [value, delayMs]);
return debounced;
}
+77 -7
View File
@@ -230,6 +230,25 @@ type ApiEnvelope<T> = {
meta: Record<string, unknown>;
};
export type CursorPageMeta = {
limit: number;
page_count: number;
total_count: number;
has_more: boolean;
next_cursor: string | null;
};
export type CursorPage<T> = {
items: T[];
meta: CursorPageMeta;
};
export type CursorListParams = {
limit?: number;
cursor?: string | null;
q?: string;
};
type ApiErrorEnvelope = {
detail?: string | {
code?: string;
@@ -269,6 +288,15 @@ async function apiRequest<T>(
init: RequestInit = {},
workspaceId?: string,
): Promise<T> {
const result = await apiRequestWithMeta<T>(path, init, workspaceId);
return result.data;
}
async function apiRequestWithMeta<T>(
path: string,
init: RequestInit = {},
workspaceId?: string,
): Promise<{ data: T; meta: Record<string, unknown> }> {
const finalPath = workspaceId ? appendWorkspaceId(path, workspaceId) : path;
const response = await fetch(finalPath, {
...init,
@@ -311,7 +339,33 @@ async function apiRequest<T>(
: error.error?.code,
);
}
return (payload as ApiEnvelope<T>).data;
const envelope = payload as ApiEnvelope<T>;
return { data: envelope.data, meta: envelope.meta ?? {} };
}
function parseCursorPageMeta(meta: Record<string, unknown>): CursorPageMeta {
const totalFromMeta =
typeof meta.total_count === "number"
? meta.total_count
: typeof meta.count === "number"
? meta.count
: 0;
return {
limit: typeof meta.limit === "number" ? meta.limit : 10,
page_count: typeof meta.page_count === "number" ? meta.page_count : 0,
total_count: totalFromMeta,
has_more: Boolean(meta.has_more),
next_cursor: typeof meta.next_cursor === "string" ? meta.next_cursor : null,
};
}
function buildCursorQuery(input: CursorListParams = {}): string {
const parameters = new URLSearchParams();
parameters.set("limit", String(input.limit ?? 10));
if (input.cursor) parameters.set("cursor", input.cursor);
const keyword = input.q?.trim();
if (keyword) parameters.set("q", keyword);
return parameters.toString();
}
export async function listScripts(
@@ -1153,8 +1207,14 @@ export async function hideScheduleArtifact(
}
// 系统管理级别接口 - 不区分 workspace
export async function listPlatformEmployees(): Promise<Employee[]> {
return apiRequest<Employee[]>("/api/v1/platform/employees");
export async function listPlatformEmployees(
input: CursorListParams = {},
): Promise<CursorPage<Employee>> {
const query = buildCursorQuery(input);
const { data, meta } = await apiRequestWithMeta<Employee[]>(
`/api/v1/platform/employees?${query}`,
);
return { items: data, meta: parseCursorPageMeta(meta) };
}
export async function createPlatformEmployee(
@@ -1302,8 +1362,14 @@ export async function deleteEmployee(
// Workspace (Project) Management APIs - 对应 API.md 第七部分系统管理接口
// ----------------------------------------------------------------------------
export async function listWorkspaces(): Promise<Workspace[]> {
return apiRequest<Workspace[]>("/api/v1/platform/workspaces");
export async function listWorkspaces(
input: CursorListParams = {},
): Promise<CursorPage<Workspace>> {
const query = buildCursorQuery(input);
const { data, meta } = await apiRequestWithMeta<Workspace[]>(
`/api/v1/platform/workspaces?${query}`,
);
return { items: data, meta: parseCursorPageMeta(meta) };
}
export async function createWorkspace(
@@ -1670,7 +1736,9 @@ export type WorkspaceBoundApi = {
artifact_preserved: boolean;
}>;
listEmployees: () => Promise<Employee[]>;
listPlatformEmployees: () => Promise<Employee[]>;
listPlatformEmployees: (
input?: CursorListParams,
) => Promise<CursorPage<Employee>>;
createEmployee: (
input: Parameters<typeof createEmployee>[1],
) => Promise<Employee>;
@@ -1757,7 +1825,9 @@ export type WorkspaceBoundApi = {
nodeRunId: string,
) => Promise<ScheduleNodeRunArtifacts>;
// Workspace (Project) Management - 系统管理接口
listWorkspaces: () => Promise<Workspace[]>;
listWorkspaces: (
input?: CursorListParams,
) => Promise<CursorPage<Workspace>>;
createWorkspace: (input: WorkspaceCreatePayload) => Promise<Workspace>;
updateWorkspace: (
workspaceId: string,