update:添加分页、成员添加优化
This commit is contained in:
@@ -5,6 +5,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
return (
|
||||
<Sonner
|
||||
theme="light"
|
||||
position="bottom-right"
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user