Merge origin/develop into feature/a-card-operations
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Workspace } from "../../services/api";
|
||||
import { Checkbox } from "~/components/ui/checkbox";
|
||||
import { formFieldClass } from "../platform/modalUi";
|
||||
|
||||
const CHECKBOX_CLASS =
|
||||
"size-[16px] shrink-0 rounded-[3px] border-[#cdd9e4] bg-white data-checked:border-[#1277d3] data-checked:bg-[#1277d3] data-checked:text-white";
|
||||
|
||||
/** 新建用户时可选加入的项目列表(多选,非必填)。 */
|
||||
export function CreateUserProjectField({
|
||||
projects,
|
||||
loading,
|
||||
selectedIds,
|
||||
onToggle,
|
||||
}: {
|
||||
projects: Workspace[];
|
||||
loading: boolean;
|
||||
selectedIds: string[];
|
||||
onToggle: (workspaceId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={formFieldClass}>
|
||||
<span>
|
||||
加入项目
|
||||
<span className="ml-1 font-normal text-[#8a9aab]">(可选)</span>
|
||||
</span>
|
||||
<div className="max-h-[140px] overflow-auto rounded-md border border-[#d6e0e9] bg-white px-2.5 py-2">
|
||||
{loading ? (
|
||||
<p className="m-0 py-2 text-center text-[12px] text-[#8a9aab]">加载项目中…</p>
|
||||
) : projects.length === 0 ? (
|
||||
<p className="m-0 py-2 text-center text-[12px] text-[#8a9aab]">暂无可用项目</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{projects.map((project) => {
|
||||
const checked = selectedIds.includes(project.workspace_id);
|
||||
return (
|
||||
<label
|
||||
key={project.workspace_id}
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-1 py-1 hover:bg-[#f5f8fb]"
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={() => onToggle(project.workspace_id)}
|
||||
className={CHECKBOX_CLASS}
|
||||
/>
|
||||
<span className="min-w-0 truncate text-[12px] text-[#283a4e]">
|
||||
{project.workspace_name}
|
||||
<span className="ml-1.5 text-[11px] text-[#8a9aab]">
|
||||
{project.workspace_code}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="m-0 text-[11px] leading-snug text-[#8a9aab]">
|
||||
未加入任何项目时,该用户将无法登录。
|
||||
</p>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
AppFormDialog,
|
||||
dialogPrimaryButtonClass,
|
||||
dialogSecondaryButtonClass,
|
||||
} from "~/components/common/AppFormDialog";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import {
|
||||
formFieldClass,
|
||||
formHintClass,
|
||||
formInputClass,
|
||||
modalFormClass,
|
||||
} from "../platform/modalUi";
|
||||
|
||||
type ResetPasswordDialogProps = {
|
||||
open: boolean;
|
||||
displayName: string;
|
||||
username: string;
|
||||
saving: boolean;
|
||||
onSubmit: (newPassword: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function ResetPasswordDialog({
|
||||
open,
|
||||
displayName,
|
||||
username,
|
||||
saving,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: ResetPasswordDialogProps) {
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setPassword("");
|
||||
setConfirm("");
|
||||
setError(null);
|
||||
}, [open]);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (password.length < 8 || password.length > 72) {
|
||||
setError("密码长度需为 8~72 字符");
|
||||
return;
|
||||
}
|
||||
if (password !== confirm) {
|
||||
setError("两次输入的密码不一致");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
await onSubmit(password);
|
||||
};
|
||||
|
||||
return (
|
||||
<AppFormDialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) onClose();
|
||||
}}
|
||||
eyebrow="SECURITY"
|
||||
title="重置密码"
|
||||
>
|
||||
<form
|
||||
className={`${modalFormClass} pb-5`}
|
||||
onSubmit={(event) => void handleSubmit(event)}
|
||||
>
|
||||
<p className="mb-4 text-[12px] text-[#587087]">
|
||||
正在为 <strong className="text-[#23384e]">{displayName}</strong>
|
||||
({username})设置新密码。对方需使用新密码重新登录。
|
||||
</p>
|
||||
<label className={formFieldClass}>
|
||||
<span>
|
||||
新密码<span className="text-danger">*</span>
|
||||
</span>
|
||||
<Input
|
||||
className={formInputClass}
|
||||
type="password"
|
||||
autoFocus
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
placeholder="8~72 字符"
|
||||
/>
|
||||
</label>
|
||||
<label className={formFieldClass}>
|
||||
<span>
|
||||
确认新密码<span className="text-danger">*</span>
|
||||
</span>
|
||||
<Input
|
||||
className={formInputClass}
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={(event) => setConfirm(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<span className={formHintClass}>重置后不会强制踢下线已有会话</span>
|
||||
</label>
|
||||
{error ? (
|
||||
<p className="mb-3 text-[11px] text-danger">{error}</p>
|
||||
) : null}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={dialogSecondaryButtonClass}
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className={dialogPrimaryButtonClass}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? "重置中…" : "确认重置"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</AppFormDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import type { Employee, Role, Workspace } from "../../services/api";
|
||||
import {
|
||||
AppFormDialog,
|
||||
dialogPrimaryButtonClass,
|
||||
dialogSecondaryButtonClass,
|
||||
} from "~/components/common/AppFormDialog";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { CreateUserProjectField } from "./CreateUserProjectField";
|
||||
import {
|
||||
formFieldClass,
|
||||
formHintClass,
|
||||
formInputClass,
|
||||
modalFormClass,
|
||||
} from "../platform/modalUi";
|
||||
|
||||
export type UserFormState = {
|
||||
username: string;
|
||||
display_name: string;
|
||||
email: string;
|
||||
role_code: "admin" | "developer";
|
||||
password: string;
|
||||
status: "active" | "disabled" | "locked";
|
||||
};
|
||||
|
||||
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 UserFormDialog({
|
||||
open,
|
||||
editing,
|
||||
form,
|
||||
saving,
|
||||
currentUserId,
|
||||
roles,
|
||||
createRoleOptions,
|
||||
createProjects,
|
||||
createProjectsLoading,
|
||||
selectedWorkspaceIds,
|
||||
onChangeForm,
|
||||
onToggleWorkspace,
|
||||
onSubmit,
|
||||
onClose,
|
||||
onResetPassword,
|
||||
}: {
|
||||
open: boolean;
|
||||
editing: Employee | null;
|
||||
form: UserFormState;
|
||||
saving: boolean;
|
||||
currentUserId: string | undefined;
|
||||
roles: Role[];
|
||||
createRoleOptions: Role[];
|
||||
createProjects: Workspace[];
|
||||
createProjectsLoading: boolean;
|
||||
selectedWorkspaceIds: string[];
|
||||
onChangeForm: (next: UserFormState) => void;
|
||||
onToggleWorkspace: (workspaceId: string) => void;
|
||||
onSubmit: (event: React.FormEvent) => void;
|
||||
onClose: () => void;
|
||||
onResetPassword?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<AppFormDialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) onClose();
|
||||
}}
|
||||
eyebrow="EMPLOYEE"
|
||||
title={editing ? "编辑用户" : "添加用户"}
|
||||
>
|
||||
<form className={modalFormClass} onSubmit={onSubmit}>
|
||||
<label className={formFieldClass}>
|
||||
<span>
|
||||
姓名<span className="text-danger">*</span>
|
||||
</span>
|
||||
<Input
|
||||
className={FORM_INPUT_CLASS}
|
||||
autoFocus={!editing}
|
||||
value={form.display_name}
|
||||
onChange={(event) => onChangeForm({ ...form, display_name: event.target.value })}
|
||||
placeholder="请输入姓名"
|
||||
/>
|
||||
</label>
|
||||
<label className={formFieldClass}>
|
||||
<span>
|
||||
登录账号<span className="text-danger">*</span>
|
||||
</span>
|
||||
<Input
|
||||
className={FORM_INPUT_CLASS}
|
||||
disabled={Boolean(editing)}
|
||||
value={form.username}
|
||||
onChange={(event) => onChangeForm({ ...form, username: event.target.value })}
|
||||
placeholder="请输入登录账号"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</label>
|
||||
{!editing && (
|
||||
<label className={formFieldClass}>
|
||||
<span>
|
||||
密码<span className="text-danger">*</span>
|
||||
</span>
|
||||
<Input
|
||||
className={FORM_INPUT_CLASS}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={form.password}
|
||||
onChange={(event) => onChangeForm({ ...form, password: event.target.value })}
|
||||
placeholder="请输入密码(8~72 字符)"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label className={formFieldClass}>
|
||||
<span>邮箱</span>
|
||||
<Input
|
||||
className={FORM_INPUT_CLASS}
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={form.email}
|
||||
onChange={(event) => onChangeForm({ ...form, email: event.target.value })}
|
||||
placeholder="请输入邮箱(可选)"
|
||||
/>
|
||||
</label>
|
||||
<label className={formFieldClass}>
|
||||
<span>角色</span>
|
||||
<select
|
||||
className={formInputClass}
|
||||
value={form.role_code}
|
||||
disabled={
|
||||
editing
|
||||
? editing.user_id === currentUserId || roles.length === 0
|
||||
: createRoleOptions.length === 0
|
||||
}
|
||||
onChange={(event) =>
|
||||
onChangeForm({
|
||||
...form,
|
||||
role_code: event.target.value as UserFormState["role_code"],
|
||||
})
|
||||
}
|
||||
>
|
||||
{editing ? (
|
||||
roles.length === 0 ? (
|
||||
<option value="" disabled>
|
||||
加载中…
|
||||
</option>
|
||||
) : (
|
||||
roles.map((role) => (
|
||||
<option key={role.role_code} value={role.role_code}>
|
||||
{role.role_name}
|
||||
</option>
|
||||
))
|
||||
)
|
||||
) : createRoleOptions.length === 0 ? (
|
||||
<option value="" disabled>
|
||||
加载中…
|
||||
</option>
|
||||
) : (
|
||||
createRoleOptions.map((role) => (
|
||||
<option key={role.role_code} value={role.role_code}>
|
||||
{role.role_name}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
{!editing && (
|
||||
<CreateUserProjectField
|
||||
projects={createProjects}
|
||||
loading={createProjectsLoading}
|
||||
selectedIds={selectedWorkspaceIds}
|
||||
onToggle={onToggleWorkspace}
|
||||
/>
|
||||
)}
|
||||
{editing && (
|
||||
<label className={formFieldClass}>
|
||||
<span>状态</span>
|
||||
<select
|
||||
className={formInputClass}
|
||||
value={form.status}
|
||||
disabled={
|
||||
editing.user_id === currentUserId || editing.role_code === "admin"
|
||||
}
|
||||
onChange={(event) =>
|
||||
onChangeForm({
|
||||
...form,
|
||||
status: event.target.value as UserFormState["status"],
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="active">正常</option>
|
||||
<option value="disabled">停用</option>
|
||||
<option value="locked">锁定</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{editing && onResetPassword ? (
|
||||
<div className="mb-4 rounded-md border border-[#e6edf3] bg-[#f8fafb] px-3.5 py-3">
|
||||
<div className="text-[12px] font-semibold text-[#3c4e62]">安全</div>
|
||||
<p className={`${formHintClass} mt-1.5 mb-2.5 leading-relaxed`}>
|
||||
该用户忘记密码时,可为其设置临时密码。对方需使用新密码重新登录。
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={dialogSecondaryButtonClass}
|
||||
onClick={onResetPassword}
|
||||
>
|
||||
重置密码
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-[3px] flex shrink-0 justify-end gap-2 border-t border-line bg-white -mx-[22px] px-[22px] py-3.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={dialogSecondaryButtonClass}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className={dialogPrimaryButtonClass}
|
||||
>
|
||||
{saving ? "保存中…" : "保存"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</AppFormDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,10 @@
|
||||
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";
|
||||
import { ApiRequestError, type Employee, type Role, type Workspace } from "../../services/api";
|
||||
import { useApi, useAuth } from "../../context/AuthContext";
|
||||
import {
|
||||
AppFormDialog,
|
||||
dialogPrimaryButtonClass,
|
||||
dialogSecondaryButtonClass,
|
||||
} from "~/components/common/AppFormDialog";
|
||||
import { ConfirmDialog } from "~/components/common/ConfirmDialog";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -20,11 +14,10 @@ import {
|
||||
TableRow,
|
||||
} from "~/components/ui/table";
|
||||
import { AdminColgroup, USER_PROJECT_COL_WIDTHS } from "./AdminTable";
|
||||
import {
|
||||
formFieldClass,
|
||||
formInputClass,
|
||||
modalFormClass,
|
||||
} from "../platform/modalUi";
|
||||
import { AdminPagination } from "./AdminPagination";
|
||||
import { UserFormDialog, type UserFormState } from "./UserFormDialog";
|
||||
import { ResetPasswordDialog } from "./ResetPasswordDialog";
|
||||
import { useCursorPage } from "./useCursorPage";
|
||||
import {
|
||||
adminEmptyClass,
|
||||
adminPageClass,
|
||||
@@ -42,18 +35,17 @@ import {
|
||||
} from "./adminUi";
|
||||
|
||||
import { primaryGradientButtonClass } from "~/components/common/buttonClasses";
|
||||
import { useDebouncedValue } from "./useDebouncedValue";
|
||||
|
||||
const EMPTY_FORM = {
|
||||
const EMPTY_FORM: UserFormState = {
|
||||
username: "",
|
||||
display_name: "",
|
||||
email: "",
|
||||
role_code: "developer" as "admin" | "developer",
|
||||
role_code: "developer",
|
||||
password: "",
|
||||
status: "active" as "active" | "disabled" | "locked",
|
||||
status: "active",
|
||||
};
|
||||
|
||||
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,47 +55,85 @@ 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 [form, setForm] = useState<UserFormState>(EMPTY_FORM);
|
||||
const [userSearchTerm, setUserSearchTerm] = useState("");
|
||||
const [deleteTarget, setDeleteTarget] = useState<Employee | null>(null);
|
||||
const [resetTarget, setResetTarget] = useState<Employee | null>(null);
|
||||
const [resetSaving, setResetSaving] = useState(false);
|
||||
const [createProjects, setCreateProjects] = useState<Workspace[]>([]);
|
||||
const [createProjectsLoading, setCreateProjectsLoading] = useState(false);
|
||||
const [selectedWorkspaceIds, setSelectedWorkspaceIds] = useState<string[]>([]);
|
||||
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 dialog */
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
const createRoleOptions = useMemo(
|
||||
() => roles.filter((role) => role.role_code !== "admin"),
|
||||
[roles],
|
||||
);
|
||||
|
||||
const loadCreateProjects = useCallback(async (): Promise<void> => {
|
||||
setCreateProjectsLoading(true);
|
||||
try {
|
||||
const page = await api.listWorkspaces({ limit: 200, cursor: null, q: "" });
|
||||
setCreateProjects(page.items.filter((item) => item.status === "active"));
|
||||
} catch {
|
||||
setCreateProjects([]);
|
||||
onNotify({ tone: "error", message: "项目列表加载失败" });
|
||||
} finally {
|
||||
setCreateProjectsLoading(false);
|
||||
}
|
||||
}, [api, onNotify]);
|
||||
|
||||
const openCreate = (): void => {
|
||||
setEditing(null);
|
||||
setForm(EMPTY_FORM);
|
||||
setSelectedWorkspaceIds([]);
|
||||
setDialogOpen(true);
|
||||
void loadCreateProjects();
|
||||
};
|
||||
|
||||
const openEdit = (employee: Employee): void => {
|
||||
@@ -116,18 +146,25 @@ export function UserManagementPage({
|
||||
password: "",
|
||||
status: employee.status,
|
||||
});
|
||||
setSelectedWorkspaceIds([]);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const toggleCreateWorkspace = (workspaceId: string): void => {
|
||||
setSelectedWorkspaceIds((current) =>
|
||||
current.includes(workspaceId)
|
||||
? current.filter((id) => id !== workspaceId)
|
||||
: [...current, workspaceId],
|
||||
);
|
||||
};
|
||||
|
||||
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,11 +181,14 @@ export function UserManagementPage({
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 密码长度校验 8~72 字符
|
||||
if (form.password && (form.password.length < 8 || form.password.length > 72)) {
|
||||
onNotify({ tone: "error", message: "密码长度必须在 8~72 字符之间" });
|
||||
return;
|
||||
}
|
||||
if (!editing && form.role_code === "admin") {
|
||||
onNotify({ tone: "error", message: "新建用户不能指定管理员角色" });
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) {
|
||||
@@ -158,9 +198,9 @@ 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({
|
||||
@@ -168,10 +208,34 @@ export function UserManagementPage({
|
||||
display_name: form.display_name.trim(),
|
||||
email: form.email.trim() || undefined,
|
||||
password: form.password,
|
||||
role_code: "developer",
|
||||
role_code: form.role_code,
|
||||
});
|
||||
setEmployees((current) => [...current, created]);
|
||||
onNotify({ tone: "success", message: "用户已添加" });
|
||||
if (selectedWorkspaceIds.length > 0) {
|
||||
const results = await Promise.allSettled(
|
||||
selectedWorkspaceIds.map((workspaceId) =>
|
||||
api.addWorkspaceMember(workspaceId, { user_id: created.user_id }),
|
||||
),
|
||||
);
|
||||
const failed = results.filter((item) => item.status === "rejected").length;
|
||||
const joined = results.length - failed;
|
||||
if (failed === 0) {
|
||||
onNotify({
|
||||
tone: "success",
|
||||
message: `用户已添加,并加入 ${joined} 个项目`,
|
||||
});
|
||||
} else {
|
||||
onNotify({
|
||||
tone: "error",
|
||||
message: `用户已创建,但有 ${failed} 个项目加入失败`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
onNotify({
|
||||
tone: "info",
|
||||
message: "用户已添加",
|
||||
});
|
||||
}
|
||||
await refreshFromStart();
|
||||
}
|
||||
setDialogOpen(false);
|
||||
} catch (error) {
|
||||
@@ -187,8 +251,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 +265,29 @@ export function UserManagementPage({
|
||||
}
|
||||
};
|
||||
|
||||
const executeResetPassword = async (newPassword: string): Promise<void> => {
|
||||
if (!resetTarget) return;
|
||||
setResetSaving(true);
|
||||
try {
|
||||
await api.resetPlatformEmployeePassword(resetTarget.user_id, newPassword);
|
||||
onNotify({ tone: "success", message: "密码已重置" });
|
||||
setResetTarget(null);
|
||||
} catch (error) {
|
||||
onNotify({
|
||||
tone: "error",
|
||||
message: error instanceof Error ? error.message : "重置密码失败",
|
||||
});
|
||||
} finally {
|
||||
setResetSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const emptyMessage = useMemo(() => {
|
||||
if (loading) return "正在加载用户…";
|
||||
if (debouncedSearch.trim()) return "未找到匹配的用户";
|
||||
return "暂无用户";
|
||||
}, [loading, debouncedSearch]);
|
||||
|
||||
return (
|
||||
<section className={adminPageClass}>
|
||||
<div className={adminToolbarClass}>
|
||||
@@ -236,173 +327,87 @@ 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>
|
||||
|
||||
<AppFormDialog
|
||||
<AdminPagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
totalCount={meta.total_count}
|
||||
loading={loading}
|
||||
hasMore={meta.has_more}
|
||||
canGoToPage={canGoToPage}
|
||||
onPrev={goPrev}
|
||||
onNext={goNext}
|
||||
onGoToPage={goToPage}
|
||||
/>
|
||||
|
||||
<UserFormDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) setDialogOpen(false);
|
||||
}}
|
||||
eyebrow="EMPLOYEE"
|
||||
title={editing ? "编辑用户" : "添加用户"}
|
||||
>
|
||||
<form className={modalFormClass} onSubmit={(event) => void submit(event)}>
|
||||
<label className={formFieldClass}>
|
||||
<span>姓名<span className="text-danger">*</span></span>
|
||||
<Input
|
||||
className={FORM_INPUT_CLASS}
|
||||
autoFocus={!editing}
|
||||
value={form.display_name}
|
||||
onChange={(event) => setForm({ ...form, display_name: event.target.value })}
|
||||
placeholder="请输入姓名"
|
||||
/>
|
||||
</label>
|
||||
<label className={formFieldClass}>
|
||||
<span>登录账号<span className="text-danger">*</span></span>
|
||||
<Input
|
||||
className={FORM_INPUT_CLASS}
|
||||
disabled={Boolean(editing)}
|
||||
value={form.username}
|
||||
onChange={(event) => setForm({ ...form, username: event.target.value })}
|
||||
placeholder="请输入登录账号"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</label>
|
||||
{!editing && (
|
||||
<label className={formFieldClass}>
|
||||
<span>密码<span className="text-danger">*</span></span>
|
||||
<Input
|
||||
className={FORM_INPUT_CLASS}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={form.password}
|
||||
onChange={(event) => setForm({ ...form, password: event.target.value })}
|
||||
placeholder="请输入密码(8~72 字符)"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label className={formFieldClass}>
|
||||
<span>邮箱</span>
|
||||
<Input
|
||||
className={FORM_INPUT_CLASS}
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={form.email}
|
||||
onChange={(event) => setForm({ ...form, email: event.target.value })}
|
||||
placeholder="请输入邮箱(可选)"
|
||||
/>
|
||||
</label>
|
||||
{editing && (
|
||||
<label className={formFieldClass}>
|
||||
<span>角色</span>
|
||||
<select
|
||||
className={formInputClass}
|
||||
value={form.role_code}
|
||||
disabled={editing.user_id === user?.user_id || roles.length === 0}
|
||||
onChange={(event) => setForm({ ...form, role_code: event.target.value as typeof form.role_code })}
|
||||
>
|
||||
{roles.length === 0 ? (
|
||||
<option value="" disabled>加载中…</option>
|
||||
) : (
|
||||
roles.map((role) => (
|
||||
<option key={role.role_code} value={role.role_code}>
|
||||
{role.role_name}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{editing && (
|
||||
<label className={formFieldClass}>
|
||||
<span>状态</span>
|
||||
<select
|
||||
className={formInputClass}
|
||||
value={form.status}
|
||||
disabled={Boolean(editing) && (editing.user_id === user?.user_id || editing.role_code === "admin")}
|
||||
onChange={(event) => setForm({ ...form, status: event.target.value as typeof form.status })}
|
||||
>
|
||||
<option value="active">正常</option>
|
||||
<option value="disabled">停用</option>
|
||||
<option value="locked">锁定</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<div className="mt-[3px] flex shrink-0 justify-end gap-2 border-t border-line bg-white -mx-[22px] px-[22px] py-3.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
className={dialogSecondaryButtonClass}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className={dialogPrimaryButtonClass}
|
||||
>
|
||||
{saving ? "保存中…" : "保存"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</AppFormDialog>
|
||||
editing={editing}
|
||||
form={form}
|
||||
saving={saving}
|
||||
currentUserId={user?.user_id}
|
||||
roles={roles}
|
||||
createRoleOptions={createRoleOptions}
|
||||
createProjects={createProjects}
|
||||
createProjectsLoading={createProjectsLoading}
|
||||
selectedWorkspaceIds={selectedWorkspaceIds}
|
||||
onChangeForm={setForm}
|
||||
onToggleWorkspace={toggleCreateWorkspace}
|
||||
onSubmit={(event) => void submit(event)}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
onResetPassword={
|
||||
editing
|
||||
? () => {
|
||||
setResetTarget(editing);
|
||||
setDialogOpen(false);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteTarget !== null}
|
||||
@@ -423,6 +428,15 @@ export function UserManagementPage({
|
||||
setDeleteTarget(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ResetPasswordDialog
|
||||
open={resetTarget !== null}
|
||||
displayName={resetTarget?.display_name ?? ""}
|
||||
username={resetTarget?.username ?? ""}
|
||||
saving={resetSaving}
|
||||
onSubmit={executeResetPassword}
|
||||
onClose={() => setResetTarget(null)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import FileViewer from "@file-viewer/react";
|
||||
import officePreset from "@file-viewer/preset-office";
|
||||
import Editor from "@monaco-editor/react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { ApiRequestError, type ResourcePreviewPayload } from "~/services/api";
|
||||
import { useApi, useAuth } from "~/context/AuthContext";
|
||||
import {
|
||||
EXCEL_PREVIEW_MAX_BYTES,
|
||||
TEXT_PREVIEW_MAX_BYTES,
|
||||
monacoLanguageFromFileName,
|
||||
resourceDisplayName,
|
||||
type DataResourcePreviewTarget,
|
||||
} from "./dataResourcePreview";
|
||||
|
||||
const EYEBROW: Record<DataResourcePreviewTarget["kind"], string> = {
|
||||
excel: "EXCEL PREVIEW",
|
||||
text: "TEXT PREVIEW",
|
||||
table: "TABLE PREVIEW",
|
||||
};
|
||||
|
||||
export function DataResourcePreviewDialog({
|
||||
target,
|
||||
onClose,
|
||||
}: {
|
||||
target: DataResourcePreviewTarget | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const title = target
|
||||
? resourceDisplayName(target.resourceName, target.fileExtension)
|
||||
: "";
|
||||
|
||||
if (!target) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/35 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`预览 ${title}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="flex h-[min(90vh,880px)] w-[min(96vw,1100px)] flex-col overflow-hidden rounded-[11px] border border-[#dce4eb] bg-white shadow-xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-[#edf1f5] px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[9px] font-extrabold tracking-[0.12em] text-[#2d82d4]">
|
||||
{EYEBROW[target.kind]}
|
||||
</div>
|
||||
<h3 className="mt-0.5 truncate text-[16px] font-medium text-[#1c2d42]">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button grid size-8 place-items-center rounded-md text-[#66788a] hover:bg-[#f3f6f9]"
|
||||
onClick={onClose}
|
||||
aria-label="关闭预览"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative min-h-0 flex-1 bg-[#f7f9fb]">
|
||||
{target.kind === "excel" && (
|
||||
<ExcelPreviewBody target={target} title={title} />
|
||||
)}
|
||||
{target.kind === "text" && (
|
||||
<TextPreviewBody target={target} title={title} />
|
||||
)}
|
||||
{target.kind === "table" && <TablePreviewBody target={target} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusOverlay({
|
||||
loading,
|
||||
error,
|
||||
}: {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}) {
|
||||
if (loading) {
|
||||
return (
|
||||
<p className="absolute inset-0 grid place-items-center text-[13px] text-[#7a8b9c]">
|
||||
正在加载预览…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<p className="absolute inset-0 grid place-items-center px-6 text-center text-[13px] text-[#c74848]">
|
||||
{error}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function ExcelPreviewBody({
|
||||
target,
|
||||
title,
|
||||
}: {
|
||||
target: DataResourcePreviewTarget;
|
||||
title: string;
|
||||
}) {
|
||||
const api = useApi();
|
||||
const { currentWorkspace } = useAuth();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentWorkspace?.workspace_id) return;
|
||||
if (target.sizeBytes > EXCEL_PREVIEW_MAX_BYTES) {
|
||||
setFile(null);
|
||||
setError(
|
||||
`文件过大(${Math.ceil(target.sizeBytes / (1024 * 1024))} MB),暂不支持在线预览`,
|
||||
);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setFile(null);
|
||||
void api
|
||||
.fetchResourceContentFile(target.resourceId, title, controller.signal)
|
||||
.then((loaded) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setFile(loaded);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setFile(null);
|
||||
setLoading(false);
|
||||
setError(errorMessage(err));
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [api, currentWorkspace?.workspace_id, target, title]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusOverlay loading={loading} error={error} />
|
||||
{!loading && !error && file && (
|
||||
<FileViewer
|
||||
className="h-full w-full"
|
||||
file={file}
|
||||
filename={title}
|
||||
options={{
|
||||
preset: officePreset,
|
||||
theme: "light",
|
||||
toolbar: { position: "bottom-right" },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TextPreviewBody({
|
||||
target,
|
||||
title,
|
||||
}: {
|
||||
target: DataResourcePreviewTarget;
|
||||
title: string;
|
||||
}) {
|
||||
const api = useApi();
|
||||
const { currentWorkspace } = useAuth();
|
||||
const [content, setContent] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentWorkspace?.workspace_id) return;
|
||||
if (target.sizeBytes > TEXT_PREVIEW_MAX_BYTES) {
|
||||
setContent(null);
|
||||
setError(
|
||||
`文件过大(${Math.ceil(target.sizeBytes / (1024 * 1024))} MB),暂不支持文本预览`,
|
||||
);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setContent(null);
|
||||
void api
|
||||
.fetchResourceContentFile(target.resourceId, title, controller.signal)
|
||||
.then(async (loaded) => {
|
||||
if (controller.signal.aborted) return;
|
||||
const text = await loaded.text();
|
||||
if (controller.signal.aborted) return;
|
||||
setContent(text);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setContent(null);
|
||||
setLoading(false);
|
||||
setError(errorMessage(err));
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [api, currentWorkspace?.workspace_id, target, title]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusOverlay loading={loading} error={error} />
|
||||
{!loading && !error && content !== null && (
|
||||
<Editor
|
||||
height="100%"
|
||||
language={monacoLanguageFromFileName(title)}
|
||||
theme="vs"
|
||||
value={content}
|
||||
options={{
|
||||
readOnly: true,
|
||||
minimap: { enabled: content.length > 5000 },
|
||||
wordWrap: "on",
|
||||
fontSize: 13,
|
||||
automaticLayout: true,
|
||||
renderLineHighlight: "gutter",
|
||||
contextmenu: false,
|
||||
scrollBeyondLastLine: false,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TablePreviewBody({ target }: { target: DataResourcePreviewTarget }) {
|
||||
const api = useApi();
|
||||
const { currentWorkspace } = useAuth();
|
||||
const [payload, setPayload] = useState<ResourcePreviewPayload | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentWorkspace?.workspace_id) return;
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setPayload(null);
|
||||
void api
|
||||
.fetchResourcePreview(target.resourceId, { limit: 100 }, controller.signal)
|
||||
.then((data) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setPayload(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setPayload(null);
|
||||
setLoading(false);
|
||||
setError(errorMessage(err));
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [api, currentWorkspace?.workspace_id, target.resourceId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusOverlay loading={loading} error={error} />
|
||||
{!loading && !error && payload && (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<p className="shrink-0 border-b border-[#edf1f5] bg-white px-4 py-2 text-[11px] text-[#7a8b9c]">
|
||||
{payload.truncated
|
||||
? `仅预览前 ${payload.row_count} 行(已截断)`
|
||||
: `共 ${payload.row_count} 行`}
|
||||
{payload.delimiter === "\t" ? " · TSV" : " · CSV"}
|
||||
</p>
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<table className="w-max min-w-full border-collapse text-left text-[12px] text-[#34475d]">
|
||||
<thead className="sticky top-0 bg-[#f5f8fb]">
|
||||
<tr>
|
||||
<th className="border-b border-[#edf1f5] px-3 py-2 font-semibold text-[#8a9aab]">
|
||||
#
|
||||
</th>
|
||||
{payload.columns.map((column) => (
|
||||
<th
|
||||
key={column}
|
||||
className="border-b border-[#edf1f5] px-3 py-2 font-semibold whitespace-nowrap"
|
||||
>
|
||||
{column}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{payload.rows.map((row, index) => (
|
||||
<tr key={index} className="odd:bg-white even:bg-[#fbfcfd]">
|
||||
<td className="border-b border-[#f0f3f6] px-3 py-1.5 text-[#9ba8b7]">
|
||||
{index + 1}
|
||||
</td>
|
||||
{payload.columns.map((_, colIndex) => (
|
||||
<td
|
||||
key={colIndex}
|
||||
className="border-b border-[#f0f3f6] px-3 py-1.5 whitespace-nowrap"
|
||||
>
|
||||
{row[colIndex] ?? ""}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{payload.rows.length === 0 && (
|
||||
<p className="px-4 py-8 text-center text-[13px] text-[#8a9aab]">
|
||||
文件没有可预览的数据行
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
if (err instanceof ApiRequestError) return err.message;
|
||||
if (err instanceof Error) return err.message;
|
||||
return "加载预览失败";
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
dialogSecondaryButtonClass,
|
||||
} from "~/components/common/AppFormDialog";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { fileVisualFromName } from "./WorkspaceTree";
|
||||
import {
|
||||
formFieldClass,
|
||||
formHintClass,
|
||||
@@ -52,6 +53,8 @@ export function DataResourceUploadModal({
|
||||
const finalPath = finalTarget
|
||||
? `${finalTarget}/${file?.name ?? ""}`
|
||||
: file?.name ?? "";
|
||||
const fileVisual = fileVisualFromName(file?.name ?? resourceName);
|
||||
const FileIcon = fileVisual.Icon;
|
||||
|
||||
return (
|
||||
<AppFormDialog
|
||||
@@ -65,8 +68,13 @@ export function DataResourceUploadModal({
|
||||
<form className={modalFormClass} onSubmit={onSubmit}>
|
||||
<label className={formFieldClass}>
|
||||
<span>已选文件</span>
|
||||
<div className="font-normal text-[#283a4e]">
|
||||
{file ? file.name : "未选择文件"}
|
||||
<div className="flex items-center gap-2.5 font-normal text-[#283a4e]">
|
||||
<span
|
||||
className={`grid size-[25px] shrink-0 place-items-center rounded-[5px] ${fileVisual.tone}`}
|
||||
>
|
||||
<FileIcon size={17} />
|
||||
</span>
|
||||
<span className="min-w-0 truncate">{file ? file.name : "未选择文件"}</span>
|
||||
</div>
|
||||
</label>
|
||||
<label className={formFieldClass}>
|
||||
@@ -75,7 +83,7 @@ export function DataResourceUploadModal({
|
||||
className={formInputClass}
|
||||
autoFocus
|
||||
maxLength={255}
|
||||
placeholder="例如:训练数据"
|
||||
placeholder="例如:训练数据.csv"
|
||||
value={resourceName}
|
||||
onChange={(event) => onNameChange(event.target.value)}
|
||||
required
|
||||
|
||||
@@ -29,6 +29,7 @@ type ScriptExplorerProps = {
|
||||
) => void;
|
||||
onSelect: (scriptId: string) => void;
|
||||
onCopyResourcePath: (jupyterPath: string) => void;
|
||||
onPreviewResource: (script: ScriptItem) => void;
|
||||
uploadInputRef: RefObject<HTMLInputElement | null>;
|
||||
onHandleUpload: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
};
|
||||
@@ -53,6 +54,7 @@ export function ScriptExplorer({
|
||||
onContextMenu,
|
||||
onSelect,
|
||||
onCopyResourcePath,
|
||||
onPreviewResource,
|
||||
uploadInputRef,
|
||||
onHandleUpload,
|
||||
}: ScriptExplorerProps) {
|
||||
@@ -251,6 +253,7 @@ export function ScriptExplorer({
|
||||
onToggle={onToggle}
|
||||
loadingChildrenPaths={loadingChildrenPaths}
|
||||
onCopyResourcePath={onCopyResourcePath}
|
||||
onPreviewResource={onPreviewResource}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { type FormEvent, useEffect, useMemo, useRef } from "react";
|
||||
import { type FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { useAuth } from "../../context/AuthContext";
|
||||
import type { ScriptItem } from "../../services/api";
|
||||
import { CreateFolderModal } from "./CreateFolderModal";
|
||||
import { CreateScriptModal } from "./CreateScriptModal";
|
||||
import { DataResourcePreviewDialog } from "./DataResourcePreviewDialog";
|
||||
import { DataResourceUploadModal } from "./DataResourceUploadModal";
|
||||
import {
|
||||
previewKindFromFileName,
|
||||
type DataResourcePreviewTarget,
|
||||
} from "./dataResourcePreview";
|
||||
import { WelcomePanel } from "./WelcomePanel";
|
||||
import { PublishModal } from "./PublishModal";
|
||||
import { ScriptExplorer } from "./ScriptExplorer";
|
||||
import { ScriptsPendingConfirmDialog } from "./ScriptsPendingConfirm";
|
||||
import { TreeContextMenu } from "./TreeContextMenu";
|
||||
import { VersionReceiptModal } from "./VersionReceiptModal";
|
||||
|
||||
import { useScriptsPendingConfirm } from "./useScriptsPendingConfirm";
|
||||
import { getSessionCache, useScriptWorkspaceStore } from "./state/scriptWorkspaceStore";
|
||||
import { useUiStore } from "./state/uiStore";
|
||||
import { toast } from "sonner";
|
||||
@@ -19,6 +26,8 @@ import { copyToClipboard } from "../../lib/clipboard";
|
||||
export default function ScriptsPage() {
|
||||
const { currentWorkspace, user } = useAuth();
|
||||
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [resourcePreview, setResourcePreview] =
|
||||
useState<DataResourcePreviewTarget | null>(null);
|
||||
|
||||
// store state
|
||||
const scripts = useScriptWorkspaceStore((s) => s.scripts);
|
||||
@@ -67,6 +76,18 @@ export default function ScriptsPage() {
|
||||
const submitPublish = useScriptWorkspaceStore((s) => s.submitPublish);
|
||||
const refreshReadOnlyContent = useScriptWorkspaceStore((s) => s.refreshReadOnlyContent);
|
||||
|
||||
const {
|
||||
pendingConfirm,
|
||||
setPendingConfirm,
|
||||
requestCloseTab,
|
||||
handleConfirm,
|
||||
} = useScriptsPendingConfirm(scripts, {
|
||||
deleteScript,
|
||||
deleteDataResource,
|
||||
deleteDirectory,
|
||||
closeTab,
|
||||
});
|
||||
|
||||
// ui store
|
||||
const pushToast = (notice: { tone: "success" | "error" | "info"; message: string }) => {
|
||||
if (notice.tone === "error") toast.error(notice.message);
|
||||
@@ -198,27 +219,11 @@ export default function ScriptsPage() {
|
||||
};
|
||||
}, [contextMenu, closeContextMenu]);
|
||||
|
||||
// python editor handlers with scriptId forwarding
|
||||
const handleSetPythonEditorContent = (scriptId: string, value: string) => {
|
||||
setPythonEditorContent(scriptId, value);
|
||||
};
|
||||
const handleSavePythonEditor = (scriptId: string) => {
|
||||
void savePythonEditor(scriptId);
|
||||
};
|
||||
const handleExitPythonEditor = (scriptId: string) => {
|
||||
exitPythonEditor(scriptId);
|
||||
};
|
||||
const handleClosePythonTab = (scriptId: string) => {
|
||||
void closeTab(scriptId);
|
||||
};
|
||||
|
||||
// 5) handlers
|
||||
// 5) handlers
|
||||
const SCRIPT_EXTS = [".py", ".ipynb"];
|
||||
const DATA_EXTS = [".csv", ".xlsx", ".xls", ".tsv", ".json", ".parquet", ".txt"];
|
||||
|
||||
const matchesExt = (name: string, exts: string[]) =>
|
||||
exts.some((ext) => name.toLowerCase().endsWith(ext));
|
||||
|
||||
const handleUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files ?? []);
|
||||
event.target.value = "";
|
||||
@@ -256,8 +261,6 @@ export default function ScriptsPage() {
|
||||
visibility,
|
||||
description: description.trim(),
|
||||
targetPath: targetPath.trim(),
|
||||
}).then((resource) => {
|
||||
if (resource) void loadDataResources();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -276,6 +279,20 @@ export default function ScriptsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const openResourcePreview = (script: ScriptItem): void => {
|
||||
const kind = previewKindFromFileName(script.script_name);
|
||||
if (!kind) return;
|
||||
const resourceId = script.script_id.replace(/^data:/, "");
|
||||
const resource = dataResources.find((item) => item.resource_id === resourceId);
|
||||
setResourcePreview({
|
||||
resourceId,
|
||||
resourceName: resource?.resource_name ?? script.script_name,
|
||||
fileExtension: resource?.file.file_extension ?? null,
|
||||
sizeBytes: resource?.file.size_bytes ?? script.size_bytes,
|
||||
kind,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateSubmit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
void createScript(createDialog.form);
|
||||
@@ -319,6 +336,7 @@ export default function ScriptsPage() {
|
||||
onContextMenu={showContextMenu}
|
||||
onSelect={openTab}
|
||||
onCopyResourcePath={(p) => void handleCopyResourcePath(p)}
|
||||
onPreviewResource={openResourcePreview}
|
||||
uploadInputRef={uploadInputRef}
|
||||
onHandleUpload={handleUpload}
|
||||
/>
|
||||
@@ -360,17 +378,17 @@ export default function ScriptsPage() {
|
||||
void endEditing();
|
||||
}
|
||||
}}
|
||||
onClose={(scriptId, event) => void closeTab(scriptId, event)}
|
||||
onClose={(scriptId, event) => void requestCloseTab(scriptId, event)}
|
||||
onSwitchTab={switchTab}
|
||||
onNewTab={() => openCreateDialog("")}
|
||||
onPublish={() => openPublishDialog(selected)}
|
||||
scripts={scripts}
|
||||
pythonEditorBuffers={pythonEditorBuffers}
|
||||
onOpenPythonEditor={() => void openPythonEditor(selected)}
|
||||
onSetPythonEditorContent={handleSetPythonEditorContent}
|
||||
onSavePythonEditor={handleSavePythonEditor}
|
||||
onExitPythonEditor={handleExitPythonEditor}
|
||||
onClosePythonTab={handleClosePythonTab}
|
||||
onSetPythonEditorContent={setPythonEditorContent}
|
||||
onSavePythonEditor={(scriptId) => void savePythonEditor(scriptId)}
|
||||
onExitPythonEditor={exitPythonEditor}
|
||||
onClosePythonTab={(scriptId) => void requestCloseTab(scriptId)}
|
||||
onInfo={(t) => pushToast(t)}
|
||||
/>
|
||||
) : (
|
||||
@@ -408,18 +426,40 @@ export default function ScriptsPage() {
|
||||
selectScript(scriptId);
|
||||
closeContextMenu();
|
||||
}}
|
||||
onRemoveScript={(s) => void deleteScript(s)}
|
||||
onRemoveScript={(s) => setPendingConfirm({ kind: "script", script: s })}
|
||||
onToggleLock={(s) => void toggleScriptLock(s)}
|
||||
onOpenCreateDialog={(parentPath, scriptType) =>
|
||||
openCreateDialog(parentPath, scriptType)}
|
||||
onOpenFolderDialog={(parentPath) => openFolderDialog(parentPath)}
|
||||
onChooseUpload={(parentPath) => triggerUpload(parentPath ?? "")}
|
||||
onRemoveDirectory={(p) => void deleteDirectory(p)}
|
||||
onRemoveDirectory={(p) =>
|
||||
setPendingConfirm({ kind: "directory", path: p })}
|
||||
onCopyResourcePath={(p) => void handleCopyResourcePath(p)}
|
||||
onRemoveResource={(id) => void deleteDataResource(id)}
|
||||
onPreviewResource={openResourcePreview}
|
||||
onRemoveResource={(id) => {
|
||||
const resource = dataResources.find((item) => item.resource_id === id);
|
||||
setPendingConfirm({
|
||||
kind: "resource",
|
||||
id,
|
||||
name: resource?.resource_name ?? id,
|
||||
});
|
||||
}}
|
||||
onClose={closeContextMenu}
|
||||
/>
|
||||
|
||||
<ScriptsPendingConfirmDialog
|
||||
pending={pendingConfirm}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPendingConfirm(null);
|
||||
}}
|
||||
onConfirm={() => void handleConfirm()}
|
||||
/>
|
||||
|
||||
<DataResourcePreviewDialog
|
||||
target={resourcePreview}
|
||||
onClose={() => setResourcePreview(null)}
|
||||
/>
|
||||
|
||||
<PublishModal
|
||||
publishTarget={publish.target}
|
||||
releaseNote={publish.releaseNote}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { ConfirmDialog } from "~/components/common/ConfirmDialog";
|
||||
import type { ScriptItem } from "~/services/api";
|
||||
|
||||
export type ScriptsPendingConfirm =
|
||||
| { kind: "script"; script: ScriptItem }
|
||||
| { kind: "resource"; id: string; name: string }
|
||||
| { kind: "directory"; path: string }
|
||||
| { kind: "close-tab"; id: string; name: string };
|
||||
|
||||
type ScriptsPendingConfirmDialogProps = {
|
||||
pending: ScriptsPendingConfirm | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
function copyFor(pending: ScriptsPendingConfirm) {
|
||||
switch (pending.kind) {
|
||||
case "script":
|
||||
return {
|
||||
title: "确定删除文件?",
|
||||
description: `确定删除文件"${pending.script.script_name}"吗?稳定版本会保留。`,
|
||||
confirmLabel: "删除",
|
||||
destructive: true,
|
||||
};
|
||||
case "resource":
|
||||
return {
|
||||
title: "确定删除数据资源?",
|
||||
description: `确定删除数据资源"${pending.name}"吗?稳定版本会保留。`,
|
||||
confirmLabel: "删除",
|
||||
destructive: true,
|
||||
};
|
||||
case "directory":
|
||||
return {
|
||||
title: "确定删除文件夹?",
|
||||
description: `确定递归删除文件夹"${pending.path}"及其内容吗?稳定版本会保留。`,
|
||||
confirmLabel: "删除",
|
||||
destructive: true,
|
||||
};
|
||||
case "close-tab":
|
||||
return {
|
||||
title: "确定关闭标签?",
|
||||
description: `当前脚本有未保存修改,确定关闭 "${pending.name}" 吗?`,
|
||||
confirmLabel: "关闭",
|
||||
destructive: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function ScriptsPendingConfirmDialog({
|
||||
pending,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: ScriptsPendingConfirmDialogProps) {
|
||||
const copy = pending ? copyFor(pending) : null;
|
||||
return (
|
||||
<ConfirmDialog
|
||||
open={pending !== null}
|
||||
onOpenChange={onOpenChange}
|
||||
title={copy?.title ?? ""}
|
||||
description={copy?.description ?? ""}
|
||||
confirmLabel={copy?.confirmLabel}
|
||||
destructive={copy?.destructive}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
BookOpen,
|
||||
Database,
|
||||
Eye,
|
||||
FileCode,
|
||||
FileText,
|
||||
Folder,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { ScriptItem, ScriptType } from "../../services/api";
|
||||
import { canPreviewDataResource } from "./dataResourcePreview";
|
||||
|
||||
type ContextMenuState = {
|
||||
x: number;
|
||||
@@ -29,6 +31,7 @@ type TreeContextMenuProps = {
|
||||
onChooseUpload: (parentPath: string) => void;
|
||||
onRemoveDirectory: (path: string) => void;
|
||||
onCopyResourcePath?: (jupyterPath: string) => void;
|
||||
onPreviewResource?: (script: ScriptItem) => void;
|
||||
onRemoveResource?: (resourceId: string) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
@@ -49,12 +52,15 @@ export function TreeContextMenu({
|
||||
onChooseUpload,
|
||||
onRemoveDirectory,
|
||||
onCopyResourcePath,
|
||||
onPreviewResource,
|
||||
onRemoveResource,
|
||||
onClose,
|
||||
}: TreeContextMenuProps) {
|
||||
if (!contextMenu) return null;
|
||||
|
||||
const isDataResource = !!contextMenu.script?.script_id.startsWith("data:");
|
||||
const canPreview =
|
||||
isDataResource && canPreviewDataResource(contextMenu.script?.script_name);
|
||||
const LockToggleIcon = contextMenu.script?.is_locked ? Unlock : Lock;
|
||||
|
||||
return (
|
||||
@@ -71,6 +77,20 @@ export function TreeContextMenu({
|
||||
<>
|
||||
{isDataResource ? (
|
||||
<>
|
||||
{canPreview && onPreviewResource && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={menuItemClass}
|
||||
onClick={() => {
|
||||
onPreviewResource(contextMenu.script!);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Eye size={16} />
|
||||
预览
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@@ -131,7 +151,10 @@ export function TreeContextMenu({
|
||||
className={dangerItemClass}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => onRemoveScript(contextMenu.script!)}
|
||||
onClick={() => {
|
||||
onRemoveScript(contextMenu.script!);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<X size={16} />
|
||||
删除文件
|
||||
@@ -184,7 +207,10 @@ export function TreeContextMenu({
|
||||
className={dangerItemClass}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => onRemoveDirectory(contextMenu.path)}
|
||||
onClick={() => {
|
||||
onRemoveDirectory(contextMenu.path);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<X size={16} />
|
||||
删除文件夹
|
||||
|
||||
@@ -5,8 +5,13 @@ import {
|
||||
ChevronRight,
|
||||
Database,
|
||||
FileCode,
|
||||
FileJson,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
FileType,
|
||||
Folder,
|
||||
Lock,
|
||||
Table2,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
@@ -14,6 +19,7 @@ import type {
|
||||
WorkspaceDirectory,
|
||||
ResourceItem,
|
||||
} from "~/services/api";
|
||||
import { canPreviewDataResource } from "./dataResourcePreview";
|
||||
|
||||
export type WorkspaceTreeTarget = {
|
||||
kind: "root" | "directory" | "file";
|
||||
@@ -34,6 +40,7 @@ type WorkspaceTreeProps = {
|
||||
readOnly?: boolean;
|
||||
dataResources?: ResourceItem[];
|
||||
onCopyResourcePath?: (jupyterPath: string) => void;
|
||||
onPreviewResource?: (script: ScriptItem) => void;
|
||||
// 唯一标识此 group(通常 `__group__<owner_user_id>`),让多个 owner 的 group 各自独立展开。
|
||||
groupKey: string;
|
||||
// 该 group 所属 owner 的 user_id —— 透传给 store.toggleExpanded,使他人
|
||||
@@ -50,6 +57,11 @@ type WorkspaceTreeItemsProps = Omit<WorkspaceTreeProps, "title" | "readOnly"> &
|
||||
onCopyResourcePath?: (jupyterPath: string) => void;
|
||||
};
|
||||
|
||||
export type FileVisual = {
|
||||
Icon: LucideIcon;
|
||||
tone: string;
|
||||
};
|
||||
|
||||
function formatTime(value: string) {
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "2-digit",
|
||||
@@ -64,6 +76,36 @@ export function scriptIcon(item: Pick<ScriptItem, "script_type">): LucideIcon {
|
||||
return item.script_type === "notebook" ? BookOpen : FileCode;
|
||||
}
|
||||
|
||||
/** 按文件名后缀返回树节点图标与配色(脚本 + 数据资源共用)。 */
|
||||
export function fileVisualFromName(
|
||||
name: string,
|
||||
scriptType?: ScriptItem["script_type"],
|
||||
): FileVisual {
|
||||
const lower = name.toLowerCase();
|
||||
if (scriptType === "notebook" || lower.endsWith(".ipynb")) {
|
||||
return { Icon: BookOpen, tone: "text-[#e15e50] bg-[#fff0ed]" };
|
||||
}
|
||||
if (scriptType === "python" || lower.endsWith(".py")) {
|
||||
return { Icon: FileCode, tone: "text-[#2e73c6] bg-[#eaf3ff]" };
|
||||
}
|
||||
if (lower.endsWith(".csv") || lower.endsWith(".tsv")) {
|
||||
return { Icon: Table2, tone: "text-[#2f8f7b] bg-[#eaf8f4]" };
|
||||
}
|
||||
if (lower.endsWith(".xlsx") || lower.endsWith(".xls")) {
|
||||
return { Icon: FileSpreadsheet, tone: "text-[#3d8f5a] bg-[#eef8f1]" };
|
||||
}
|
||||
if (lower.endsWith(".json")) {
|
||||
return { Icon: FileJson, tone: "text-[#b07a1a] bg-[#fff8e8]" };
|
||||
}
|
||||
if (lower.endsWith(".parquet")) {
|
||||
return { Icon: Database, tone: "text-[#4a6fa5] bg-[#eef3fa]" };
|
||||
}
|
||||
if (lower.endsWith(".txt")) {
|
||||
return { Icon: FileText, tone: "text-[#6b7c8f] bg-[#f2f5f8]" };
|
||||
}
|
||||
return { Icon: FileType, tone: "text-[#5a8f6a] bg-[#eef8f1]" };
|
||||
}
|
||||
|
||||
function ownedScriptPath(item: ScriptItem) {
|
||||
// 数据资源的 relative_path 已经是 jupyter 路径(无 ULID 前缀);脚本相对路径形如
|
||||
// `{ulid}/{ws_id}/{user_id}/...`,需要剥前两层。
|
||||
@@ -89,6 +131,7 @@ export function WorkspaceTreeGroup({
|
||||
readOnly = false,
|
||||
dataResources,
|
||||
onCopyResourcePath,
|
||||
onPreviewResource,
|
||||
groupKey,
|
||||
ownerUserId,
|
||||
expandedPaths,
|
||||
@@ -98,25 +141,33 @@ export function WorkspaceTreeGroup({
|
||||
const open = expandedPaths.has(groupKey);
|
||||
const dataResourceScripts = useMemo(() => {
|
||||
if (!dataResources) return [];
|
||||
return dataResources.map((r) => ({
|
||||
script_id: `data:${r.resource_id}`,
|
||||
workspace_id: r.workspace_id,
|
||||
current_object_id: r.storage_object_id,
|
||||
owner_user_id: r.owner_user_id,
|
||||
owner_display_name: null,
|
||||
script_name: r.resource_name,
|
||||
script_type: "python" as const,
|
||||
visibility: r.visibility,
|
||||
status: r.status,
|
||||
is_locked: false,
|
||||
// relative_path 直接是 jupyter 路径(与 Python/Notebook 同层渲染,不再带虚拟前缀)
|
||||
relative_path: r.jupyter_accessible_path,
|
||||
jupyter_path: r.jupyter_accessible_path,
|
||||
content_hash: r.file.content_hash ?? "",
|
||||
size_bytes: r.file.size_bytes,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
} as unknown as ScriptItem));
|
||||
return dataResources.map((r) => {
|
||||
const ext = r.file.file_extension;
|
||||
const name = r.resource_name;
|
||||
const displayName =
|
||||
ext && !name.toLowerCase().endsWith(ext.toLowerCase())
|
||||
? `${name}${ext}`
|
||||
: name;
|
||||
return {
|
||||
script_id: `data:${r.resource_id}`,
|
||||
workspace_id: r.workspace_id,
|
||||
current_object_id: r.storage_object_id,
|
||||
owner_user_id: r.owner_user_id,
|
||||
owner_display_name: null,
|
||||
script_name: displayName,
|
||||
script_type: "python" as const,
|
||||
visibility: r.visibility,
|
||||
status: r.status,
|
||||
is_locked: false,
|
||||
// relative_path 直接是 jupyter 路径(与 Python/Notebook 同层渲染,不再带虚拟前缀)
|
||||
relative_path: r.jupyter_accessible_path,
|
||||
jupyter_path: r.jupyter_accessible_path,
|
||||
content_hash: r.file.content_hash ?? "",
|
||||
size_bytes: r.file.size_bytes,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
} as unknown as ScriptItem;
|
||||
});
|
||||
}, [dataResources]);
|
||||
|
||||
const dataResourceDirectories = useMemo(() => {
|
||||
@@ -194,6 +245,7 @@ export function WorkspaceTreeGroup({
|
||||
onToggle={onToggle}
|
||||
loadingChildrenPaths={loadingChildrenPaths}
|
||||
onCopyResourcePath={onCopyResourcePath}
|
||||
onPreviewResource={onPreviewResource}
|
||||
/>
|
||||
{scripts.length === 0 && directories.length === 0 && dataResourceScripts.length === 0 && (
|
||||
<p className="mb-[7px] ml-[35px] mt-0.5 text-[11px] text-[#a5b0bc]">
|
||||
@@ -217,6 +269,7 @@ function WorkspaceTreeItems({
|
||||
onSelect,
|
||||
onContextMenu,
|
||||
onCopyResourcePath,
|
||||
onPreviewResource,
|
||||
expandedPaths,
|
||||
onToggle,
|
||||
loadingChildrenPaths,
|
||||
@@ -245,17 +298,16 @@ function WorkspaceTreeItems({
|
||||
onToggle={onToggle}
|
||||
loadingChildrenPaths={loadingChildrenPaths}
|
||||
onCopyResourcePath={onCopyResourcePath}
|
||||
onPreviewResource={onPreviewResource}
|
||||
/>
|
||||
))}
|
||||
{childScripts.map((item) => {
|
||||
const isActive = selectedId === item.script_id;
|
||||
const isData = item.script_id.startsWith("data:");
|
||||
const ItemIcon = isData ? Database : scriptIcon(item);
|
||||
const iconTone = isData
|
||||
? "text-[#5a8f6a] bg-[#eef8f1]"
|
||||
: item.script_type === "notebook"
|
||||
? "text-[#e15e50] bg-[#fff0ed]"
|
||||
: "text-[#2e73c6] bg-[#eaf3ff]";
|
||||
const { Icon: ItemIcon, tone: iconTone } = fileVisualFromName(
|
||||
item.script_name,
|
||||
isData ? undefined : item.script_type,
|
||||
);
|
||||
return (
|
||||
<button
|
||||
className={`flex h-[47px] w-full cursor-pointer items-center gap-2 rounded-md border pr-[9px] text-left ${
|
||||
@@ -267,8 +319,12 @@ function WorkspaceTreeItems({
|
||||
key={item.script_id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isData && onCopyResourcePath) {
|
||||
onCopyResourcePath(item.relative_path);
|
||||
if (isData) {
|
||||
if (canPreviewDataResource(item.script_name) && onPreviewResource) {
|
||||
onPreviewResource(item);
|
||||
} else if (onCopyResourcePath) {
|
||||
onCopyResourcePath(item.relative_path);
|
||||
}
|
||||
} else {
|
||||
onSelect(item.script_id);
|
||||
}
|
||||
@@ -324,6 +380,7 @@ function DirectoryBranch({
|
||||
onSelect,
|
||||
onContextMenu,
|
||||
onCopyResourcePath,
|
||||
onPreviewResource,
|
||||
expandedPaths,
|
||||
onToggle,
|
||||
loadingChildrenPaths,
|
||||
@@ -382,6 +439,7 @@ function DirectoryBranch({
|
||||
onToggle={onToggle}
|
||||
loadingChildrenPaths={loadingChildrenPaths}
|
||||
onCopyResourcePath={onCopyResourcePath}
|
||||
onPreviewResource={onPreviewResource}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/** 数据资源预览:扩展名分流与展示名。 */
|
||||
|
||||
export type DataResourcePreviewKind = "excel" | "text" | "table";
|
||||
|
||||
export const EXCEL_EXTENSIONS = [".xlsx", ".xls"] as const;
|
||||
export const TEXT_EXTENSIONS = [".txt", ".json"] as const;
|
||||
export const TABLE_EXTENSIONS = [".csv", ".tsv"] as const;
|
||||
|
||||
/** Excel 整文件拉取上限。 */
|
||||
export const EXCEL_PREVIEW_MAX_BYTES = 80 * 1024 * 1024;
|
||||
/** 文本预览拉取上限。 */
|
||||
export const TEXT_PREVIEW_MAX_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
export type DataResourcePreviewTarget = {
|
||||
resourceId: string;
|
||||
resourceName: string;
|
||||
fileExtension: string | null;
|
||||
sizeBytes: number;
|
||||
kind: DataResourcePreviewKind;
|
||||
};
|
||||
|
||||
function lowerName(name: string | null | undefined): string {
|
||||
return (name ?? "").toLowerCase();
|
||||
}
|
||||
|
||||
function matchesExt(name: string, exts: readonly string[]): boolean {
|
||||
return exts.some((ext) => name.endsWith(ext));
|
||||
}
|
||||
|
||||
export function isExcelFileName(name: string | null | undefined): boolean {
|
||||
return matchesExt(lowerName(name), EXCEL_EXTENSIONS);
|
||||
}
|
||||
|
||||
export function isTextPreviewFileName(name: string | null | undefined): boolean {
|
||||
return matchesExt(lowerName(name), TEXT_EXTENSIONS);
|
||||
}
|
||||
|
||||
export function isTablePreviewFileName(name: string | null | undefined): boolean {
|
||||
return matchesExt(lowerName(name), TABLE_EXTENSIONS);
|
||||
}
|
||||
|
||||
export function previewKindFromFileName(
|
||||
name: string | null | undefined,
|
||||
): DataResourcePreviewKind | null {
|
||||
const lower = lowerName(name);
|
||||
if (matchesExt(lower, EXCEL_EXTENSIONS)) return "excel";
|
||||
if (matchesExt(lower, TEXT_EXTENSIONS)) return "text";
|
||||
if (matchesExt(lower, TABLE_EXTENSIONS)) return "table";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function canPreviewDataResource(name: string | null | undefined): boolean {
|
||||
return previewKindFromFileName(name) !== null;
|
||||
}
|
||||
|
||||
export function resourceDisplayName(
|
||||
resourceName: string,
|
||||
fileExtension: string | null | undefined,
|
||||
): string {
|
||||
if (!fileExtension) return resourceName;
|
||||
const ext = fileExtension.startsWith(".")
|
||||
? fileExtension
|
||||
: `.${fileExtension}`;
|
||||
if (resourceName.toLowerCase().endsWith(ext.toLowerCase())) {
|
||||
return resourceName;
|
||||
}
|
||||
return `${resourceName}${ext}`;
|
||||
}
|
||||
|
||||
/** @deprecated use resourceDisplayName */
|
||||
export const excelDisplayName = resourceDisplayName;
|
||||
|
||||
export function monacoLanguageFromFileName(name: string): string {
|
||||
const lower = lowerName(name);
|
||||
if (lower.endsWith(".json")) return "json";
|
||||
return "plaintext";
|
||||
}
|
||||
@@ -164,7 +164,7 @@ export const createEditSessionSlice: StateCreator<
|
||||
const scriptId = active?.script_id;
|
||||
if (!active) {
|
||||
if (closeTabFlag && scriptId) {
|
||||
await get().closeTab(scriptId);
|
||||
await get().closeTab(scriptId, undefined, { discardDirty: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -174,7 +174,7 @@ export const createEditSessionSlice: StateCreator<
|
||||
applyEditSessionState((p) => set(p), null, null);
|
||||
if (scriptId) sessionCache.delete(scriptId);
|
||||
if (closeTabFlag && scriptId) {
|
||||
await get().closeTab(scriptId);
|
||||
await get().closeTab(scriptId, undefined, { discardDirty: true });
|
||||
}
|
||||
if (showToast) {
|
||||
pushToast("success", `${active.script_name} 的编辑锁已释放`);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// - createScript 写 scripts (scriptsSlice) + 调 openTab (selectionSlice)
|
||||
// - uploadScripts 写 scripts (scriptsSlice) + 调 openTab (selectionSlice) +
|
||||
// 调 load (scriptsSlice)
|
||||
// - uploadDataResource 不写 store state (只走 uiStore)
|
||||
// - uploadDataResource 写 dataResources (scriptsSlice) + 失效对应路径缓存
|
||||
// - createFolder 写 loadedChildPaths/directories/expandedPaths (treeSlice) +
|
||||
// 调 loadChildren / load (treeSlice/scriptsSlice)
|
||||
// - deleteScript 写 openTabIds/selectedId (selectionSlice) + 调
|
||||
@@ -175,6 +175,26 @@ export const createMutationsSlice: StateCreator<
|
||||
description: meta.description,
|
||||
visibility: meta.visibility,
|
||||
});
|
||||
// 与 uploadScripts 一致:直接写入 store。loadDataResources 有路径缓存,
|
||||
// 上传后再调会命中已加载路径直接 return,列表不会更新。
|
||||
const parentPath = parentPathOfResource(resource.jupyter_accessible_path);
|
||||
set((state) => {
|
||||
const nextLoaded = new Set(state.loadedDataResourcePaths);
|
||||
nextLoaded.delete(ownerCacheKey(resource.owner_user_id, parentPath));
|
||||
// targetPath 与 jupyter 父路径不一致时一并失效(例如带前缀差异)。
|
||||
if (meta.targetPath !== parentPath) {
|
||||
nextLoaded.delete(
|
||||
ownerCacheKey(resource.owner_user_id, meta.targetPath),
|
||||
);
|
||||
}
|
||||
const withoutDup = state.dataResources.filter(
|
||||
(r) => r.resource_id !== resource.resource_id,
|
||||
);
|
||||
return {
|
||||
dataResources: [resource, ...withoutDup],
|
||||
loadedDataResourcePaths: nextLoaded,
|
||||
};
|
||||
});
|
||||
ui.closeDataResourceDialog();
|
||||
pushToast(
|
||||
"success",
|
||||
@@ -195,9 +215,6 @@ export const createMutationsSlice: StateCreator<
|
||||
deleteDataResource: async (resourceId) => {
|
||||
const api = requireApi();
|
||||
useUiStore.getState().closeContextMenu();
|
||||
if (!window.confirm(`确定删除数据资源吗?稳定版本会保留。`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.deleteResource(resourceId);
|
||||
set((state) => ({
|
||||
@@ -267,11 +284,6 @@ export const createMutationsSlice: StateCreator<
|
||||
deleteScript: async (script: ScriptItem) => {
|
||||
const api = requireApi();
|
||||
useUiStore.getState().closeContextMenu();
|
||||
if (
|
||||
!window.confirm(`确定删除文件"${script.script_name}"吗?稳定版本会保留。`)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const editSession = getEditSession();
|
||||
if (editSession?.script_id === script.script_id) {
|
||||
await get().endEditing(false, false);
|
||||
@@ -303,11 +315,6 @@ export const createMutationsSlice: StateCreator<
|
||||
deleteDirectory: async (path) => {
|
||||
const api = requireApi();
|
||||
useUiStore.getState().closeContextMenu();
|
||||
if (
|
||||
!window.confirm(`确定递归删除文件夹"${path}"及其内容吗?稳定版本会保留。`)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const activeScript = get().scripts.find(
|
||||
(item) => item.script_id === getEditSession()?.script_id,
|
||||
);
|
||||
@@ -426,4 +433,10 @@ export const createMutationsSlice: StateCreator<
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
function parentPathOfResource(path: string): string {
|
||||
const parts = path.split("/");
|
||||
parts.pop();
|
||||
return parts.join("/");
|
||||
}
|
||||
@@ -283,28 +283,41 @@ export const createScriptsSlice: StateCreator<
|
||||
|
||||
loadDataResources: async (parentPath = "", ownerUserId) => {
|
||||
const api = requireApi();
|
||||
const cacheKey = ownerCacheKey(ownerUserId, parentPath);
|
||||
// 命中缓存:listResources 是非递归的,同一 (owner, parent_path) 拉过的
|
||||
// 内容不会自己变化;省去 toggleExpanded 重复展开同一目录时的网络往返。
|
||||
if (get().loadedDataResourcePaths.has(cacheKey)) return;
|
||||
set({ dataResourcesLoading: true });
|
||||
try {
|
||||
const list = await api.listResources(parentPath, { ownerUserId });
|
||||
const fresh = Array.isArray(list) ? list : [];
|
||||
set((state) => {
|
||||
// 按 owner 范围合并:丢弃该 owner 的旧资源再并入 fresh(fresh 覆盖
|
||||
// 同 id)。owner 缺省=我,故根加载/刷新我的数据时替换我的一级资源。
|
||||
// 按 (owner, parent_path) 局部替换:丢掉该 owner 在 parentPath 下的
|
||||
// 旧条目,保留该 owner 在其它路径下的条目,再并入 fresh。
|
||||
// 这样 toggleExpanded 在子目录展开时按需拉取不会把根已加载的数据
|
||||
// 资源擦掉(修"根加载后子目录展开丢数据 / 子目录数据本来不显示")。
|
||||
const targetOwner = ownerUserId ?? getCurrentUserId() ?? null;
|
||||
const kept = state.dataResources.filter(
|
||||
(r) => r.owner_user_id !== targetOwner,
|
||||
);
|
||||
const kept = state.dataResources.filter((r) => {
|
||||
if (r.owner_user_id !== targetOwner) return true;
|
||||
return parentPathOf(r.jupyter_accessible_path) !== parentPath;
|
||||
});
|
||||
const byId = new Map(kept.map((r) => [r.resource_id, r]));
|
||||
for (const item of fresh) byId.set(item.resource_id, item);
|
||||
return { dataResources: Array.from(byId.values()) };
|
||||
const nextLoaded = new Set(state.loadedDataResourcePaths);
|
||||
nextLoaded.add(cacheKey);
|
||||
return {
|
||||
dataResources: Array.from(byId.values()),
|
||||
loadedDataResourcePaths: nextLoaded,
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
set((state) => {
|
||||
const targetOwner = ownerUserId ?? getCurrentUserId() ?? null;
|
||||
return {
|
||||
dataResources: state.dataResources.filter(
|
||||
(r) => r.owner_user_id !== targetOwner,
|
||||
),
|
||||
dataResources: state.dataResources.filter((r) => {
|
||||
if (r.owner_user_id !== targetOwner) return true;
|
||||
return parentPathOf(r.jupyter_accessible_path) !== parentPath;
|
||||
}),
|
||||
};
|
||||
});
|
||||
} finally {
|
||||
@@ -336,4 +349,13 @@ export const createScriptsSlice: StateCreator<
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
// 提取 jupyter-accessible 路径的父目录;用于 `loadDataResources` 局部替换时
|
||||
// 判断一条缓存资源是否落在目标 parent_path 下(list-resources 按 parent_path
|
||||
// 精确匹配,非递归)。
|
||||
function parentPathOf(path: string): string {
|
||||
const parts = path.split("/");
|
||||
parts.pop();
|
||||
return parts.join("/");
|
||||
}
|
||||
@@ -62,14 +62,11 @@ export const createSelectionSlice: StateCreator<
|
||||
}));
|
||||
},
|
||||
|
||||
closeTab: async (id, event) => {
|
||||
closeTab: async (id, event, options) => {
|
||||
event?.stopPropagation();
|
||||
const buffer = get().pythonEditorBuffers[id];
|
||||
if (buffer?.dirty && !buffer.saving) {
|
||||
const name =
|
||||
get().scripts.find((s) => s.script_id === id)?.script_name ?? "该脚本";
|
||||
const ok = window.confirm(`当前脚本有未保存修改,确定关闭 "${name}" 吗?`);
|
||||
if (!ok) return;
|
||||
if (buffer?.dirty && !buffer.saving && !options?.discardDirty) {
|
||||
return false;
|
||||
}
|
||||
if (buffer) {
|
||||
get().exitPythonEditor(id);
|
||||
@@ -91,6 +88,7 @@ export const createSelectionSlice: StateCreator<
|
||||
}
|
||||
return { openTabIds: newTabs, selectedId: nextSelected };
|
||||
});
|
||||
return true;
|
||||
},
|
||||
|
||||
switchTab: (id) => {
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
// ---- treeSlice ----
|
||||
//
|
||||
// 拥有 expandedPaths / loadingChildrenPaths / loadedChildPaths /
|
||||
// loadedScriptPaths / loadingScriptPaths (5 个 directory-tree 缓存集合)。
|
||||
// 负责 toggleExpanded 和 loadChildren。
|
||||
// loadedScriptPaths / loadingScriptPaths / loadedDataResourcePaths
|
||||
// (6 个 directory-tree 缓存集合)。负责 toggleExpanded 和 loadChildren。
|
||||
//
|
||||
// 注意:
|
||||
// - loadedScriptPaths/loadingScriptPaths 也由 scriptsSlice 写 (loadScripts /
|
||||
// loadOwnerGroup),但 ownership 在 treeSlice 里 (因为是 cache set,不是数据)
|
||||
// - scriptsSlice.load 也会写这俩,所以这里只保留这两个 setter (toggleExpanded
|
||||
// 也要写 expandedPaths)。
|
||||
// - loadedDataResourcePaths 由 scriptsSlice.loadDataResources 写(同样的 cache
|
||||
// 不放数据原则),toggleExpanded 在真实目录分支按需触发。
|
||||
// - scriptsSlice.load 也会写 cached script paths,所以这里只保留 toggleExpanded
|
||||
// (写 expandedPaths) 和 loadChildren (写目录缓存)。
|
||||
|
||||
import type { StateCreator } from "zustand";
|
||||
|
||||
@@ -34,6 +36,7 @@ export const createTreeSlice: StateCreator<
|
||||
loadedChildPaths: new Set<string>(),
|
||||
loadedScriptPaths: new Set<string>(),
|
||||
loadingScriptPaths: new Set<string>(),
|
||||
loadedDataResourcePaths: new Set<string>(),
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -110,15 +113,24 @@ export const createTreeSlice: StateCreator<
|
||||
}
|
||||
} else {
|
||||
// 真实目录展开:loadChildren(owner 限定的显式目录行)+ loadScripts
|
||||
// 并行。两者都 idempotent + 缓存;ownerUserId 缺省=我。他人目录同样
|
||||
// 调 loadChildren(owner) 拉取其目录结构,否则嵌套子目录无法被发现
|
||||
// (list_scripts 非递归,只能看到直接子脚本)。
|
||||
// + loadDataResources 并行。三者都 idempotent + 缓存;ownerUserId
|
||||
// 缺省=我。他人目录同样调 loadChildren(owner) 拉取其目录结构,否则
|
||||
// 嵌套子目录无法被发现(list_scripts 非递归,只能看到直接子脚本)。
|
||||
// 数据资源也是非递归的——不按需拉取,子目录里的 csv/xlsx/json 等
|
||||
// 都不会出现(修"目录树子目录里的数据文件不显示")。
|
||||
if (!state.loadedChildPaths.has(ownerCacheKey(ownerUserId, loadPath))) {
|
||||
void get().loadChildren(loadPath, ownerUserId);
|
||||
}
|
||||
if (!state.loadedScriptPaths.has(ownerCacheKey(ownerUserId, loadPath))) {
|
||||
void get().loadScripts(loadPath, ownerUserId);
|
||||
}
|
||||
if (
|
||||
!state.loadedDataResourcePaths.has(
|
||||
ownerCacheKey(ownerUserId, loadPath),
|
||||
)
|
||||
) {
|
||||
void get().loadDataResources(loadPath, ownerUserId);
|
||||
}
|
||||
}
|
||||
}
|
||||
set({ expandedPaths: next });
|
||||
|
||||
@@ -62,6 +62,10 @@ export type TreeSliceState = {
|
||||
// `${owner_user_id}:${parent_path}` (see ownerCacheKey).
|
||||
loadedScriptPaths: Set<string>;
|
||||
loadingScriptPaths: Set<string>;
|
||||
// 与 loadedScriptPaths 同形:按 (owner, parent_path) 缓存已拉取的数据资源,
|
||||
// 让 toggleExpanded 在子目录展开时也按需请求 listDataResources,而不是
|
||||
// 只在根加载一次(修"子目录下的数据文件不展示")。
|
||||
loadedDataResourcePaths: Set<string>;
|
||||
};
|
||||
|
||||
export type TreeSliceActions = {
|
||||
@@ -88,7 +92,8 @@ export type SelectionSliceActions = {
|
||||
closeTab: (
|
||||
id: string,
|
||||
event?: { stopPropagation: () => void },
|
||||
) => Promise<void>;
|
||||
options?: { discardDirty?: boolean },
|
||||
) => Promise<boolean>;
|
||||
switchTab: (id: string) => void;
|
||||
openPublishDialog: (script: ScriptItem) => void;
|
||||
};
|
||||
|
||||
@@ -251,7 +251,7 @@ export const useUiStore = create<UiState>((set) => ({
|
||||
open: true,
|
||||
file,
|
||||
parentPath,
|
||||
resourceName: file.name.replace(/\.[^/.]+$/, ""),
|
||||
resourceName: file.name,
|
||||
visibility: "workspace",
|
||||
description: "",
|
||||
uploading: false,
|
||||
|
||||
@@ -112,6 +112,7 @@ const INITIAL: ScriptWorkspaceState = {
|
||||
loadedChildPaths: new Set<string>(),
|
||||
loadedScriptPaths: new Set<string>(),
|
||||
loadingScriptPaths: new Set<string>(),
|
||||
loadedDataResourcePaths: new Set<string>(),
|
||||
// selectionSlice
|
||||
selectedId: null,
|
||||
openTabIds: [],
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useState } from "react";
|
||||
import type { ScriptItem } from "~/services/api";
|
||||
import type { ScriptsPendingConfirm } from "./ScriptsPendingConfirm";
|
||||
|
||||
type DeleteActions = {
|
||||
deleteScript: (script: ScriptItem) => Promise<void>;
|
||||
deleteDataResource: (resourceId: string) => Promise<void>;
|
||||
deleteDirectory: (path: string) => Promise<void>;
|
||||
closeTab: (
|
||||
id: string,
|
||||
event?: { stopPropagation: () => void },
|
||||
options?: { discardDirty?: boolean },
|
||||
) => Promise<boolean>;
|
||||
};
|
||||
|
||||
export function useScriptsPendingConfirm(
|
||||
scripts: ScriptItem[],
|
||||
actions: DeleteActions,
|
||||
) {
|
||||
const [pendingConfirm, setPendingConfirm] =
|
||||
useState<ScriptsPendingConfirm | null>(null);
|
||||
|
||||
const requestCloseTab = async (
|
||||
scriptId: string,
|
||||
event?: { stopPropagation: () => void },
|
||||
) => {
|
||||
const closed = await actions.closeTab(scriptId, event);
|
||||
if (closed) return;
|
||||
const name =
|
||||
scripts.find((s) => s.script_id === scriptId)?.script_name ?? "该脚本";
|
||||
setPendingConfirm({ kind: "close-tab", id: scriptId, name });
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!pendingConfirm) return;
|
||||
const target = pendingConfirm;
|
||||
setPendingConfirm(null);
|
||||
if (target.kind === "script") await actions.deleteScript(target.script);
|
||||
else if (target.kind === "resource") {
|
||||
await actions.deleteDataResource(target.id);
|
||||
} else if (target.kind === "directory") {
|
||||
await actions.deleteDirectory(target.path);
|
||||
} else {
|
||||
await actions.closeTab(target.id, undefined, { discardDirty: true });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
pendingConfirm,
|
||||
setPendingConfirm,
|
||||
requestCloseTab,
|
||||
handleConfirm,
|
||||
};
|
||||
}
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
type ScheduleNode,
|
||||
} from "~/services/api";
|
||||
|
||||
import { SchedulePendingConfirmDialog, type SchedulePendingConfirm } from "./SchedulePendingConfirm";
|
||||
import { ScheduleRenameDialog } from "./ScheduleRenameDialog";
|
||||
import { useApi, useAuth } from "~/context/AuthContext";
|
||||
import {
|
||||
formFieldClass,
|
||||
@@ -236,6 +238,8 @@ export default function SchedulePage({
|
||||
const setRuns = useSchedulesStore((s) => s.setRuns);
|
||||
const setRunsLoading = useSchedulesStore((s) => s.setRunsLoading);
|
||||
const canvasRef = useRef<HTMLDivElement | null>(null);
|
||||
const [pendingConfirm, setPendingConfirm] =
|
||||
useState<SchedulePendingConfirm | null>(null);
|
||||
|
||||
const selectedNode = schedule?.nodes.find(
|
||||
(item) => item.node_id === selectedNodeId,
|
||||
@@ -366,6 +370,30 @@ export default function SchedulePage({
|
||||
[],
|
||||
);
|
||||
|
||||
const requestRemoveNode = async (node: ScheduleNode) => {
|
||||
const result = await useSchedulesStore.getState().removeNode(node);
|
||||
if (result === "needs_history_confirm") {
|
||||
setPendingConfirm({ kind: "node-history", node });
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!pendingConfirm) return;
|
||||
const target = pendingConfirm;
|
||||
setPendingConfirm(null);
|
||||
if (target.kind === "schedule") {
|
||||
await useSchedulesStore.getState().removeSchedule(target.schedule);
|
||||
} else if (target.kind === "artifact") {
|
||||
await useSchedulesStore.getState().removeArtifact(target.artifact);
|
||||
} else if (target.kind === "node") {
|
||||
await requestRemoveNode(target.node);
|
||||
} else {
|
||||
await useSchedulesStore
|
||||
.getState()
|
||||
.removeNode(target.node, { deleteExecutionHistory: true });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="relative flex min-h-0 flex-1 flex-col gap-[10px] overflow-hidden p-[10px]">
|
||||
<header className="flex min-h-[56px] items-center justify-between rounded-[7px] border border-line bg-white px-[14px] shadow-md">
|
||||
@@ -416,7 +444,9 @@ export default function SchedulePage({
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={!schedule || Boolean(busy)}
|
||||
onClick={() => useSchedulesStore.getState().removeSchedule(schedule ?? undefined)}
|
||||
onClick={() => {
|
||||
if (schedule) setPendingConfirm({ kind: "schedule", schedule });
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
@@ -449,37 +479,38 @@ export default function SchedulePage({
|
||||
linkSourceId={linkSourceId}
|
||||
onCancelLink={() => setLinkSourceId(null)}
|
||||
/>
|
||||
<div
|
||||
className="relative min-h-0 flex-1 overflow-auto bg-bg-canvas data-[state=linking]:cursor-crosshair"
|
||||
data-state={linkSourceId ? "linking" : "idle"}
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(#e7edf3 1px, transparent 1px), linear-gradient(90deg, #e7edf3 1px, transparent 1px), linear-gradient(#f1f4f7 1px, transparent 1px), linear-gradient(90deg, #f1f4f7 1px, transparent 1px)",
|
||||
backgroundSize: "80px 80px, 80px 80px, 16px 16px, 16px 16px",
|
||||
backgroundPosition: "-1px -1px",
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
}}
|
||||
onDrop={onCanvasDrop}
|
||||
onClick={(event) => {
|
||||
// 画布实际内容位于 surface/SVG 子元素中,不能只比较 currentTarget;
|
||||
// 点击节点或连线时保留选择,点击其余空白位置则返回调度方案基本信息。
|
||||
const target = event.target as Element;
|
||||
if (
|
||||
target.closest(".schedule-node") ||
|
||||
target.matches(".schedule-edge-line, .schedule-edge-hit")
|
||||
) return;
|
||||
setSelectedNodeId(null);
|
||||
setSelectedEdgeId(null);
|
||||
}}
|
||||
>
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div
|
||||
className="relative"
|
||||
style={{ width: CANVAS_WIDTH, height: CANVAS_HEIGHT }}
|
||||
className="absolute inset-0 overflow-auto bg-bg-canvas data-[state=linking]:cursor-crosshair"
|
||||
data-state={linkSourceId ? "linking" : "idle"}
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(#e7edf3 1px, transparent 1px), linear-gradient(90deg, #e7edf3 1px, transparent 1px), linear-gradient(#f1f4f7 1px, transparent 1px), linear-gradient(90deg, #f1f4f7 1px, transparent 1px)",
|
||||
backgroundSize: "80px 80px, 80px 80px, 16px 16px, 16px 16px",
|
||||
backgroundPosition: "-1px -1px",
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
}}
|
||||
onDrop={onCanvasDrop}
|
||||
onClick={(event) => {
|
||||
// 画布实际内容位于 surface/SVG 子元素中,不能只比较 currentTarget;
|
||||
// 点击节点或连线时保留选择,点击其余空白位置则返回调度方案基本信息。
|
||||
const target = event.target as Element;
|
||||
if (
|
||||
target.closest(".schedule-node") ||
|
||||
target.matches(".schedule-edge-line, .schedule-edge-hit")
|
||||
) return;
|
||||
setSelectedNodeId(null);
|
||||
setSelectedEdgeId(null);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative"
|
||||
style={{ width: CANVAS_WIDTH, height: CANVAS_HEIGHT }}
|
||||
>
|
||||
{schedule && (
|
||||
<svg
|
||||
className="absolute inset-0 z-[1] h-full w-full overflow-visible"
|
||||
@@ -600,9 +631,11 @@ export default function SchedulePage({
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!schedule ? (
|
||||
<div className="sticky left-1/2 top-[46%] z-0 mx-auto mt-[190px] flex w-[350px] flex-col items-center text-center text-[#9aa8b6] [&>svg]:mb-3 [&>svg]:text-[#8cb4de]">
|
||||
</div>
|
||||
</div>
|
||||
{!schedule ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-[2] flex items-center justify-center">
|
||||
<div className="pointer-events-auto flex w-[350px] flex-col items-center text-center text-[#9aa8b6] [&>svg]:mb-3 [&>svg]:text-[#8cb4de]">
|
||||
<CalendarClock size={34} />
|
||||
<strong className="text-[14px] text-[#567089]">还没有选中调度方案</strong>
|
||||
<p className="my-[7px_13px] text-[11px]">点击"新建"创建一个调度,然后拖入稳定版本脚本。</p>
|
||||
@@ -616,14 +649,16 @@ export default function SchedulePage({
|
||||
<Plus size={15} />新建调度
|
||||
</Button>
|
||||
</div>
|
||||
) : schedule.nodes.length === 0 ? (
|
||||
<div className="sticky left-1/2 top-[46%] z-0 mx-auto mt-[190px] flex w-[350px] flex-col items-center text-center text-[#9aa8b6] [&>svg]:mb-3 [&>svg]:text-[#8cb4de]">
|
||||
</div>
|
||||
) : schedule.nodes.length === 0 ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-[2] flex items-center justify-center">
|
||||
<div className="pointer-events-auto flex w-[350px] flex-col items-center text-center text-[#9aa8b6] [&>svg]:mb-3 [&>svg]:text-[#8cb4de]">
|
||||
<PackageOpen size={34} />
|
||||
<strong className="text-[14px] text-[#567089]">从稳定版本开始编排</strong>
|
||||
<p className="my-[7px_13px] text-[11px]">把左侧脚本卡片拖到这里,或双击卡片快速加入。</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<footer className="flex min-h-[35px] items-center gap-[14px] border-t border-[#e4eaf0] bg-white px-3 text-[10px] text-[#8b99a9]">
|
||||
<span
|
||||
@@ -657,7 +692,8 @@ export default function SchedulePage({
|
||||
busy={Boolean(busy)}
|
||||
onChange={setNodeForm}
|
||||
onSave={() => useSchedulesStore.getState().saveNode(selectedNode)}
|
||||
onDelete={() => useSchedulesStore.getState().removeNode(selectedNode)}
|
||||
onDelete={() =>
|
||||
setPendingConfirm({ kind: "node", node: selectedNode })}
|
||||
/>
|
||||
) : (
|
||||
<ScheduleInspector
|
||||
@@ -717,7 +753,8 @@ export default function SchedulePage({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
role="menuitem"
|
||||
onClick={() => useSchedulesStore.getState().renameSchedule(contextMenu.schedule)}
|
||||
onClick={() =>
|
||||
useSchedulesStore.getState().openRenameDialog(contextMenu.schedule)}
|
||||
>
|
||||
<Settings size={14} />
|
||||
改名
|
||||
@@ -727,21 +764,47 @@ export default function SchedulePage({
|
||||
type="button"
|
||||
variant="destructive"
|
||||
role="menuitem"
|
||||
onClick={() => useSchedulesStore.getState().removeSchedule(contextMenu.schedule)}
|
||||
onClick={() => {
|
||||
useSchedulesStore.getState().setContextMenu(null);
|
||||
setPendingConfirm({
|
||||
kind: "schedule",
|
||||
schedule: contextMenu.schedule,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
删除调度方案
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{contextMenu.kind === "artifact" && null}
|
||||
{contextMenu.kind === "artifact" && (
|
||||
<Button
|
||||
className="w-full justify-start gap-[9px] rounded-[5px] p-[9px_10px] text-[12px] text-[#c23b3b] hover:bg-[#fff0f0]"
|
||||
type="button"
|
||||
variant="destructive"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
useSchedulesStore.getState().setContextMenu(null);
|
||||
setPendingConfirm({
|
||||
kind: "artifact",
|
||||
artifact: contextMenu.artifact,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
移出调度列表
|
||||
</Button>
|
||||
)}
|
||||
{contextMenu.kind === "node" && (
|
||||
<Button
|
||||
className="w-full justify-start gap-[9px] rounded-[5px] p-[9px_10px] text-[12px] text-[#c23b3b] hover:bg-[#fff0f0]"
|
||||
type="button"
|
||||
variant="destructive"
|
||||
role="menuitem"
|
||||
onClick={() => useSchedulesStore.getState().removeNode(contextMenu.node)}
|
||||
onClick={() => {
|
||||
useSchedulesStore.getState().setContextMenu(null);
|
||||
setPendingConfirm({ kind: "node", node: contextMenu.node });
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
删除画布节点
|
||||
@@ -811,6 +874,16 @@ export default function SchedulePage({
|
||||
</form>
|
||||
</AppFormDialog>
|
||||
|
||||
<ScheduleRenameDialog />
|
||||
|
||||
<SchedulePendingConfirmDialog
|
||||
pending={pendingConfirm}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPendingConfirm(null);
|
||||
}}
|
||||
onConfirm={() => void handleConfirm()}
|
||||
/>
|
||||
|
||||
{busy && (
|
||||
<div
|
||||
className="absolute bottom-[22px] right-[22px] z-[20] flex min-h-[38px] items-center gap-2 rounded-[7px] border border-[#c9dcee] bg-white px-[13px] text-[11px] text-[#4e657d] shadow-lg"
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ConfirmDialog } from "~/components/common/ConfirmDialog";
|
||||
import type {
|
||||
Schedule,
|
||||
ScheduleArtifact,
|
||||
ScheduleNode,
|
||||
} from "~/services/api";
|
||||
|
||||
export type SchedulePendingConfirm =
|
||||
| { kind: "schedule"; schedule: Schedule }
|
||||
| { kind: "artifact"; artifact: ScheduleArtifact }
|
||||
| { kind: "node"; node: ScheduleNode }
|
||||
| { kind: "node-history"; node: ScheduleNode };
|
||||
|
||||
type SchedulePendingConfirmDialogProps = {
|
||||
pending: SchedulePendingConfirm | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
function copyFor(pending: SchedulePendingConfirm) {
|
||||
switch (pending.kind) {
|
||||
case "schedule":
|
||||
return {
|
||||
title: "确定删除调度?",
|
||||
description: `确定删除调度"${pending.schedule.schedule_name}"吗?`,
|
||||
confirmLabel: "删除",
|
||||
};
|
||||
case "artifact":
|
||||
return {
|
||||
title: "确定移出调度列表?",
|
||||
description:
|
||||
`确定将"${pending.artifact.script_name} ${pending.artifact.version_label}"移出调度列表吗?稳定版本本身和历史运行记录不会被删除。`,
|
||||
confirmLabel: "移出",
|
||||
};
|
||||
case "node":
|
||||
return {
|
||||
title: "确定删除节点?",
|
||||
description: `确定删除节点"${pending.node.node_name}"吗?`,
|
||||
confirmLabel: "删除",
|
||||
};
|
||||
case "node-history":
|
||||
return {
|
||||
title: "一并删除运行日志?",
|
||||
description: "该节点有运行日志,是否一并删除?",
|
||||
confirmLabel: "删除",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function SchedulePendingConfirmDialog({
|
||||
pending,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: SchedulePendingConfirmDialogProps) {
|
||||
const copy = pending ? copyFor(pending) : null;
|
||||
return (
|
||||
<ConfirmDialog
|
||||
open={pending !== null}
|
||||
onOpenChange={onOpenChange}
|
||||
title={copy?.title ?? ""}
|
||||
description={copy?.description ?? ""}
|
||||
confirmLabel={copy?.confirmLabel}
|
||||
destructive
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { type FormEvent } from "react";
|
||||
import { Check, Loader2Icon } from "lucide-react";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
AppFormDialog,
|
||||
dialogPrimaryButtonClass,
|
||||
dialogSecondaryButtonClass,
|
||||
} from "~/components/common/AppFormDialog";
|
||||
import {
|
||||
formFieldClass,
|
||||
formInputClass,
|
||||
modalFormClass,
|
||||
} from "~/features/platform/modalUi";
|
||||
|
||||
import { useSchedulesStore } from "./state/useSchedulesStore";
|
||||
|
||||
export function ScheduleRenameDialog() {
|
||||
const renameTarget = useSchedulesStore((s) => s.renameTarget);
|
||||
const renameName = useSchedulesStore((s) => s.renameName);
|
||||
const busy = useSchedulesStore((s) => s.busy);
|
||||
const setRenameName = useSchedulesStore((s) => s.setRenameName);
|
||||
const closeRenameDialog = useSchedulesStore((s) => s.closeRenameDialog);
|
||||
|
||||
const renaming = busy === "rename-schedule";
|
||||
const trimmed = renameName.trim();
|
||||
const unchanged = Boolean(
|
||||
renameTarget && trimmed === renameTarget.schedule_name,
|
||||
);
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!renameTarget || !trimmed || unchanged) return;
|
||||
void useSchedulesStore.getState().renameSchedule(renameTarget, trimmed);
|
||||
};
|
||||
|
||||
return (
|
||||
<AppFormDialog
|
||||
open={renameTarget !== null}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) closeRenameDialog();
|
||||
}}
|
||||
eyebrow="SCHEDULE"
|
||||
title="改名调度方案"
|
||||
titleId="rename-schedule-title"
|
||||
>
|
||||
<form className={modalFormClass} onSubmit={handleSubmit}>
|
||||
<label className={formFieldClass}>
|
||||
<span>调度方案名称</span>
|
||||
<input
|
||||
className={formInputClass}
|
||||
autoFocus
|
||||
maxLength={255}
|
||||
placeholder="请输入新的调度方案名称"
|
||||
value={renameName}
|
||||
onChange={(event) => setRenameName(event.target.value)}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
/>
|
||||
</label>
|
||||
<div className="mt-[3px] flex shrink-0 justify-end gap-2 border-t border-line bg-white -mx-[22px] px-[22px] py-3.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={renaming}
|
||||
onClick={closeRenameDialog}
|
||||
className={dialogSecondaryButtonClass}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={renaming || !trimmed || unchanged}
|
||||
className={dialogPrimaryButtonClass}
|
||||
>
|
||||
{renaming
|
||||
? <Loader2Icon className="size-4 animate-spin" />
|
||||
: <Check size={15} />}
|
||||
{renaming ? "正在保存…" : "保存"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</AppFormDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,4 @@
|
||||
// Dialog slice: create-schedule dialog state + cron preview.
|
||||
// Pure setters + small mutations that read/write only dialog-owned fields.
|
||||
|
||||
import type { CronPreview } from "../../../services/api";
|
||||
import type { CronPreview, Schedule } from "../../../services/api";
|
||||
import type { StateCreator } from "zustand";
|
||||
|
||||
import { handleError, notify, requireApi } from "./helpers";
|
||||
@@ -12,6 +9,8 @@ import type { SchedulesStore } from "./useSchedulesStore";
|
||||
export type DialogSliceState = {
|
||||
createDialogOpen: boolean;
|
||||
newScheduleName: string;
|
||||
renameTarget: Schedule | null;
|
||||
renameName: string;
|
||||
cronResult: CronPreview | null;
|
||||
};
|
||||
|
||||
@@ -21,10 +20,13 @@ export type DialogSliceActions = {
|
||||
// pure setters
|
||||
setCreateDialogOpen: (open: boolean) => void;
|
||||
setNewScheduleName: (name: string) => void;
|
||||
setRenameName: (name: string) => void;
|
||||
setCronResult: (result: CronPreview | null) => void;
|
||||
|
||||
// mutations
|
||||
openCreateDialog: () => void;
|
||||
openRenameDialog: (target: Schedule) => void;
|
||||
closeRenameDialog: () => void;
|
||||
runCronPreview: () => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -35,12 +37,15 @@ export const createDialogSlice: StateCreator<SchedulesStore, [], [], DialogSlice
|
||||
// ---- initial state ----
|
||||
createDialogOpen: false,
|
||||
newScheduleName: "",
|
||||
renameTarget: null,
|
||||
renameName: "",
|
||||
cronResult: null,
|
||||
|
||||
// ---- pure setters ----
|
||||
|
||||
setCreateDialogOpen: (open) => set({ createDialogOpen: open }),
|
||||
setNewScheduleName: (name) => set({ newScheduleName: name }),
|
||||
setRenameName: (name) => set({ renameName: name }),
|
||||
setCronResult: (result) => set({ cronResult: result }),
|
||||
|
||||
// ---- mutations ----
|
||||
@@ -54,6 +59,20 @@ export const createDialogSlice: StateCreator<SchedulesStore, [], [], DialogSlice
|
||||
}));
|
||||
},
|
||||
|
||||
openRenameDialog: (target) => {
|
||||
if (get().busy) return;
|
||||
set({
|
||||
contextMenu: null,
|
||||
renameTarget: target,
|
||||
renameName: target.schedule_name,
|
||||
});
|
||||
},
|
||||
|
||||
closeRenameDialog: () => {
|
||||
if (get().busy === "rename-schedule") return;
|
||||
set({ renameTarget: null, renameName: "" });
|
||||
},
|
||||
|
||||
runCronPreview: async () => {
|
||||
const api = requireApi();
|
||||
const { scheduleForm } = get();
|
||||
@@ -75,4 +94,4 @@ export const createDialogSlice: StateCreator<SchedulesStore, [], [], DialogSlice
|
||||
set({ busy: null });
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,7 +60,7 @@ export type ListSliceActions = {
|
||||
// mutations
|
||||
addSchedule: (name: string) => Promise<void>;
|
||||
removeSchedule: (target?: Schedule) => Promise<void>;
|
||||
renameSchedule: (target: Schedule) => Promise<void>;
|
||||
renameSchedule: (target: Schedule, scheduleName: string) => Promise<void>;
|
||||
removeArtifact: (artifact: ScheduleArtifact) => Promise<void>;
|
||||
saveSchedule: () => Promise<void>;
|
||||
runNow: () => Promise<void>;
|
||||
@@ -70,7 +70,10 @@ export type ListSliceActions = {
|
||||
positionY: number,
|
||||
) => Promise<void>;
|
||||
saveNode: (selectedNode: ScheduleNode | null) => Promise<void>;
|
||||
removeNode: (target?: ScheduleNode) => Promise<void>;
|
||||
removeNode: (
|
||||
target?: ScheduleNode,
|
||||
options?: { deleteExecutionHistory?: boolean },
|
||||
) => Promise<"ok" | "needs_history_confirm" | "noop">;
|
||||
removeEdge: (target?: ScheduleEdge) => Promise<void>;
|
||||
checkDag: () => Promise<void>;
|
||||
connectTo: (targetNodeId: string) => Promise<void>;
|
||||
@@ -234,7 +237,6 @@ export const createListSlice: StateCreator<SchedulesStore, [], [], ListSliceStat
|
||||
const target_ = target ?? get().schedule;
|
||||
if (!target_ || get().busy) return;
|
||||
set({ contextMenu: null });
|
||||
if (!window.confirm(`确定删除调度"${target_.schedule_name}"吗?`)) return;
|
||||
set({ busy: "delete-schedule" });
|
||||
try {
|
||||
await api.deleteSchedule(target_.schedule_id, target_.workflow_version);
|
||||
@@ -263,19 +265,15 @@ export const createListSlice: StateCreator<SchedulesStore, [], [], ListSliceStat
|
||||
}
|
||||
},
|
||||
|
||||
renameSchedule: async (target) => {
|
||||
renameSchedule: async (target, scheduleName) => {
|
||||
const api = requireApi();
|
||||
if (get().busy) return;
|
||||
set({ contextMenu: null });
|
||||
const scheduleName = window
|
||||
.prompt("请输入新的调度方案名称", target.schedule_name)
|
||||
?.trim();
|
||||
if (!scheduleName || scheduleName === target.schedule_name) return;
|
||||
const trimmed = scheduleName.trim();
|
||||
if (get().busy || !trimmed || trimmed === target.schedule_name) return;
|
||||
set({ busy: "rename-schedule" });
|
||||
try {
|
||||
const updated = await api.updateSchedule(target.schedule_id, {
|
||||
workflow_version: target.workflow_version,
|
||||
schedule_name: scheduleName,
|
||||
schedule_name: trimmed,
|
||||
});
|
||||
set((s: any) => ({
|
||||
schedules: s.schedules.map((item: Schedule) =>
|
||||
@@ -287,6 +285,8 @@ export const createListSlice: StateCreator<SchedulesStore, [], [], ListSliceStat
|
||||
s.schedule?.schedule_id === updated.schedule_id
|
||||
? updated
|
||||
: s.schedule,
|
||||
renameTarget: null,
|
||||
renameName: "",
|
||||
}));
|
||||
notify({ tone: "success", message: "调度方案已改名" });
|
||||
} catch (error) {
|
||||
@@ -301,12 +301,6 @@ export const createListSlice: StateCreator<SchedulesStore, [], [], ListSliceStat
|
||||
const api = requireApi();
|
||||
if (get().busy) return;
|
||||
set({ contextMenu: null });
|
||||
if (
|
||||
!window.confirm(
|
||||
`确定将"${artifact.script_name} ${artifact.version_label}"移出调度列表吗?\n`
|
||||
+ "稳定版本本身和历史运行记录不会被删除。",
|
||||
)
|
||||
) return;
|
||||
set({ busy: "delete-artifact" });
|
||||
try {
|
||||
await api.hideScheduleArtifact(artifact.versions_id);
|
||||
@@ -531,16 +525,15 @@ export const createListSlice: StateCreator<SchedulesStore, [], [], ListSliceStat
|
||||
}
|
||||
},
|
||||
|
||||
removeNode: async (target) => {
|
||||
removeNode: async (target, options) => {
|
||||
const api = requireApi();
|
||||
const state_ = get();
|
||||
const { schedule, selectedNodeId } = state_;
|
||||
const node = target
|
||||
?? schedule?.nodes.find((item) => item.node_id === selectedNodeId)
|
||||
?? null;
|
||||
if (!schedule || !node || state_.busy) return;
|
||||
if (!schedule || !node || state_.busy) return "noop";
|
||||
set({ contextMenu: null });
|
||||
if (!window.confirm(`确定删除节点"${node.node_name}"吗?`)) return;
|
||||
set({ busy: "delete-node" });
|
||||
try {
|
||||
let updated: Schedule;
|
||||
@@ -550,6 +543,9 @@ export const createListSlice: StateCreator<SchedulesStore, [], [], ListSliceStat
|
||||
schedule.schedule_id,
|
||||
node.node_id,
|
||||
schedule.workflow_version,
|
||||
options?.deleteExecutionHistory
|
||||
? { delete_execution_history: true }
|
||||
: undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
const requiresHistoryConfirmation =
|
||||
@@ -557,7 +553,10 @@ export const createListSlice: StateCreator<SchedulesStore, [], [], ListSliceStat
|
||||
&& error.status === 409
|
||||
&& error.code === "node_execution_history_exists";
|
||||
if (!requiresHistoryConfirmation) throw error;
|
||||
if (!window.confirm("该节点有运行日志,是否一并删除?")) return;
|
||||
// 交给 UI 弹二次确认;已确认则带 delete_execution_history 重试。
|
||||
if (!options?.deleteExecutionHistory) {
|
||||
return "needs_history_confirm";
|
||||
}
|
||||
updated = await api.deleteScheduleNode(
|
||||
schedule.schedule_id,
|
||||
node.node_id,
|
||||
@@ -568,9 +567,11 @@ export const createListSlice: StateCreator<SchedulesStore, [], [], ListSliceStat
|
||||
applyServerUpdatedSchedule(set, updated);
|
||||
if (selectedNodeId === node.node_id) set({ selectedNodeId: null });
|
||||
notify({ tone: "success", message: "节点及其运行日志已删除" });
|
||||
return "ok";
|
||||
} catch (error) {
|
||||
const { handleError } = await import("./helpers");
|
||||
await handleError(get, error, "删除节点失败");
|
||||
return "noop";
|
||||
} finally {
|
||||
set({ busy: null });
|
||||
}
|
||||
|
||||
@@ -64,6 +64,8 @@ const INITIAL: Omit<SchedulesState, "schedule" | "busy"> = {
|
||||
// Dialog
|
||||
createDialogOpen: false,
|
||||
newScheduleName: "",
|
||||
renameTarget: null,
|
||||
renameName: "",
|
||||
cronResult: null,
|
||||
// Runs
|
||||
runs: [],
|
||||
|
||||
Reference in New Issue
Block a user