Merge origin/develop into feature/a-card-operations

This commit is contained in:
郑龙捷
2026-09-02 10:33:00 +08:00
73 changed files with 7177 additions and 3280 deletions
@@ -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>
);
}
+225 -211
View File
@@ -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}
/>
);
})}
+69 -29
View File
@@ -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 {
// 真实目录展开:loadChildrenowner 限定的显式目录行)+ 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,
};
}
+115 -42
View File
@@ -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: [],