refactor(frontend): consolidate features & components into single tree

把 components/admin/* 和 components/platform/* 共 11 个文件
移到对应 features/<name>/ 下,components/ 只保留跨特性共享的
common/ 子目录(features/admin/* 之前和 components/admin/* 同名
共存,迫使 features/admin/AdminPages.tsx 出现 barrel,顺带消除了
barrel 副作用 CSS 丢失的隐患)。

新的目录约定:
  - features/<name>/  = 特性模块,所有页面 / Modal / state / hooks
                         / routes 都在内,无 components/<name>/ 并列
  - components/common/ = 仅放 ≥2 个特性共用的 widgets
                         (Icon、Sidebar、Toast、Topbar、WelcomePanel)

改动:
  - 11 文件 git mv (components/{admin,platform}/* → features/*)
  - 8 文件的 Icon 相对路径 '../common/Icon' → '../../components/common/Icon'
  - 2 文件补 admin.css side-effect import(UserManagementPage /
    ProjectManagementPage 之前依赖 AdminPages barrel)
  - 3 文件(引用方)更新 import:SystemAdminPage、DashboardRoute、
    ScriptsPage
  - 删除 features/admin/AdminPages.tsx barrel(无消费者)
  - 删除空目录 components/admin、components/platform

验证:
  - pnpm typecheck 通过
  - pnpm build 通过,CSS 体积 35.11 kB 与重构前一致(无样式增减)
  - routes.ts / routes/platform.tsx 路径未动,route id 不变

踩坑:
  第一轮把 '../common/Icon' 错改成 '../../../components/common/Icon',
  typecheck 报 11 个 Cannot find module。原因:features/admin/ 和
  components/admin/ 都是 depth 2,'../../X' 在两边都解析到 app/X,
  只有 '../X' 才需要多一层。正确改法是 '../../components/common/Icon'。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
tao.chen
2026-09-02 10:10:41 +08:00
committed by tao.chen
co-authored by Claude Fable 5
parent e9d9bcfc97
commit c3947d6066
15 changed files with 22 additions and 26 deletions
@@ -1,90 +0,0 @@
import Icon from "../../components/common/Icon";
import { useAuth } from "../../context/AuthContext";
import "../../styles/admin.css";
import "../../styles/dashboard.css";
export function DashboardPage({
scriptCount,
online,
onNavigate,
}: {
scriptCount: number;
online: boolean;
onNavigate: (page: "scripts" | "schedules" | "system") => void;
}) {
const { user, currentWorkspace } = useAuth();
const isSystemAdmin = user?.is_system_admin ?? false;
return (
<section className="dashboard-page">
<div className="dashboard-hero">
<div>
<span>MODEL DEVELOPMENT PLATFORM</span>
<h2>{user?.display_name ?? "用户"}</h2>
<p>
{currentWorkspace?.workspace_name ?? "(未选择 Workspace"}
</p>
</div>
<span className="dashboard-hero__badge">{online ? "服务正常" : "服务连接中"}</span>
</div>
<div className="dashboard-metrics">
<article><Icon name="script" /><span><b>{scriptCount}</b><small></small></span></article>
<article><Icon name="workspace" /><span><b>2</b><small>Workspace</small></span></article>
<article><Icon name="settings" /><span><b>4</b><small></small></span></article>
<article><Icon name="check" /><span><b>{online ? "正常" : "检查中"}</b><small></small></span></article>
</div>
<div className="dashboard-actions">
<button type="button" onClick={() => onNavigate("scripts")}><Icon name="script" /></button>
<button type="button" onClick={() => onNavigate("schedules")}><Icon name="schedule" /></button>
{isSystemAdmin && (
<button type="button" onClick={() => onNavigate("system")}><Icon name="settings" /></button>
)}
</div>
<div className="dashboard-grid">
<section className="dashboard-panel dashboard-panel--trend">
<header><div><span></span><h3> 7 </h3></div><b> 92.6%</b></header>
<div className="trend-chart">
{[38, 55, 44, 73, 61, 86, 78].map((value, index) => (
<div className="trend-column" key={index}>
<span className="trend-column__value">{Math.round(value / 7)}</span>
<i style={{ height: `${value}%` }} />
<small>{["周一", "周二", "周三", "周四", "周五", "周六", "今天"][index]}</small>
</div>
))}
</div>
<footer><span><i className="legend-dot is-success" /> 75</span><span><i className="legend-dot is-failed" /> 6</span></footer>
</section>
<section className="dashboard-panel dashboard-panel--donut">
<header><div><span></span><h3></h3></div></header>
<div className="donut-layout">
<div className="donut-chart"><span><b>{scriptCount}</b><small></small></span></div>
<div className="donut-legend">
<span><i className="legend-dot is-notebook" /><b>Notebook</b><small>{Math.max(1, Math.round(scriptCount * .67))} · 67%</small></span>
<span><i className="legend-dot is-python" /><b>Python</b><small>{Math.max(0, scriptCount - Math.round(scriptCount * .67))} · 33%</small></span>
<span><i className="legend-dot is-version" /><b></b><small>3 </small></span>
</div>
</div>
</section>
<section className="dashboard-panel dashboard-panel--activity">
<header><div><span>ACTIVITY</span><h3></h3></div><button type="button"></button></header>
<div className="activity-table">
<div className="activity-table__head"><span></span><span></span><span></span><span></span></div>
{[
["数据探索.ipynb 发布稳定版本 v3.0", "张三", "成功", "16:42"],
["每日模型训练流程完成调度运行", "Scheduler", "成功", "15:25"],
["批量预测.py 更新工作副本", "王五", "已同步", "14:18"],
["风险验证流程完成 DAG 校验", "李四", "成功", "11:06"],
].map((row) => (
<div className="activity-row" key={row[0]}>
<span><i className="activity-icon"><Icon name="check" size={13} /></i>{row[0]}</span>
<span>{row[1]}</span><span><b>{row[2]}</b></span><span>{row[3]}</span>
</div>
))}
</div>
</section>
</div>
</section>
);
}
@@ -1,553 +0,0 @@
import { useEffect, useState } from "react";
import { ApiRequestError, type Employee, type Workspace, type WorkspaceMember } from "../../services/api";
import { useApi, useAuth } from "../../context/AuthContext";
import Icon from "../common/Icon";
import { UserMultiSelect } from "./UserMultiSelect";
const EMPTY_PROJECT_FORM = {
workspace_code: "",
workspace_name: "",
quota_bytes: 0,
description: "",
};
export function ProjectManagementPage({
onNotify,
onConnectionChange,
}: {
onNotify: (notice: { tone: "success" | "error" | "info"; message: string }) => void;
onConnectionChange: (online: boolean) => void;
}) {
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 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);
}
};
useEffect(() => {
void loadProjects();
}, []);
const openCreateProject = (): void => {
setEditingProject(null);
setProjectForm(EMPTY_PROJECT_FORM);
setProjectDialogOpen(true);
};
const openEditProject = (project: Workspace): void => {
setEditingProject(project);
setProjectForm({
workspace_code: project.workspace_code,
workspace_name: project.workspace_name,
quota_bytes: project.quota_bytes,
description: project.description ?? "",
});
setProjectDialogOpen(true);
};
const submitProject = async (event: React.FormEvent): Promise<void> => {
event.preventDefault();
if (!projectForm.workspace_name.trim()) {
onNotify({ tone: "error", message: "请输入项目名称" });
return;
}
if (!projectForm.workspace_code.trim() && !editingProject) {
onNotify({ tone: "error", message: "请输入项目编码" });
return;
}
setSaving(true);
try {
if (editingProject) {
const updated = await api.updateWorkspace(editingProject.workspace_id, {
workspace_name: projectForm.workspace_name.trim(),
quota_bytes: projectForm.quota_bytes,
description: projectForm.description.trim() || undefined,
});
setProjects((current) =>
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({
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: "项目已创建" });
}
setProjectDialogOpen(false);
} catch (error) {
onNotify({
tone: "error",
message: error instanceof ApiRequestError ? error.message : (editingProject ? "更新项目失败" : "创建项目失败"),
});
} finally {
setSaving(false);
}
};
const deleteProject = async (project: Workspace): Promise<void> => {
if (!window.confirm(`确定要删除项目"${project.workspace_name}"吗?此操作将级联软删所有成员。`)) return;
try {
await api.deleteWorkspace(project.workspace_id);
setProjects((current) => current.filter((p) => p.workspace_id !== project.workspace_id));
onNotify({ tone: "success", message: "项目已删除" });
} catch (error) {
onNotify({
tone: "error",
message: error instanceof Error ? error.message : "删除项目失败",
});
}
};
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 openMembersDrawer = (project: Workspace): void => {
setSelectedProject(project);
setMembersDrawerOpen(true);
void loadProjectMembers(project.workspace_id);
};
const loadProjectMembers = async (workspaceId: string): Promise<void> => {
setMembersLoading(true);
try {
const members = await api.listWorkspaceMembers(workspaceId);
setCurrentProjectMembers(members);
} catch (error) {
onNotify({
tone: "error",
message: error instanceof Error ? error.message : "成员列表加载失败",
});
} finally {
setMembersLoading(false);
}
};
const removeMember = async (userId: string): Promise<void> => {
if (!selectedProject) return;
// 管理员不能被移除
const targetMember = currentProjectMembers.find((m) => m.user_id === userId);
if (targetMember?.role_code === "admin") {
onNotify({ tone: "error", message: "管理员不能被移除" });
return;
}
try {
await api.deleteWorkspaceMember(selectedProject.workspace_id, userId);
setCurrentProjectMembers((current) => current.filter((m) => m.user_id !== userId));
onNotify({ tone: "success", message: "成员已移除" });
} catch (error) {
onNotify({
tone: "error",
message: error instanceof ApiRequestError ? error.message : "移除成员失败",
});
}
};
const updateMemberRole = async (userId: string, roleCode: "admin" | "developer"): Promise<void> => {
if (!selectedProject) return;
try {
const updated = await api.updateWorkspaceMember(selectedProject.workspace_id, userId, {
role_code: roleCode,
});
setCurrentProjectMembers((current) =>
current.map((m) => (m.user_id === userId ? { ...m, role_code: roleCode, role_name: roleCode === "admin" ? "管理员" : "开发人员" } : m))
);
onNotify({ tone: "success", message: "成员角色已更新" });
} catch (error) {
onNotify({
tone: "error",
message: error instanceof ApiRequestError ? error.message : "更新成员角色失败",
});
}
};
const importMember = async (): Promise<void> => {
if (!selectedProject || selectedUserIds.length === 0) {
onNotify({ tone: "error", message: "请选择要添加的用户" });
return;
}
setSaving(true);
try {
await Promise.all(
selectedUserIds.map((userId) =>
api.addWorkspaceMember(selectedProject.workspace_id, {
user_id: userId,
})
)
);
setImportMemberDialogOpen(false);
onNotify({ tone: "success", message: `已添加 ${selectedUserIds.length} 名成员` });
} catch (error) {
onNotify({
tone: "error",
message: error instanceof ApiRequestError ? error.message : "添加成员失败",
});
} finally {
setSaving(false);
}
};
return (
<section className="admin-page">
<div className="admin-toolbar">
<div className="admin-toolbar__search">
<Icon name="search" size={14} />
<input
type="text"
placeholder="请输入项目名称"
className="admin-toolbar__input"
value={projectSearchTerm}
onChange={(event) => setProjectSearchTerm(event.target.value)}
/>
</div>
<button
className="primary-button admin-toolbar__button"
type="button"
disabled={!canManage}
onClick={openCreateProject}
>
<Icon name="plus" size={15} />
</button>
</div>
{!canManage && <div className="admin-readonly"></div>}
<div className="employee-table">
<div className="employee-table__head">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{projectLoading ? (
<p className="admin-empty"></p>
) : projects.length === 0 ? (
<p className="admin-empty"></p>
) : (
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) => (
<div className="employee-row" key={project.workspace_id}>
<span className="employee-name">
<b className="avatar">{project.workspace_name.slice(0, 1)}</b>
<span>
<strong>{project.workspace_name}</strong>
<small>{project.description ?? "无描述"}</small>
</span>
</span>
<code>{project.workspace_code}</code>
<span>
<span className={`status-pill is-${project.status}`}>
{project.status === "active" ? "正常" : project.status === "archived" ? "已归档" : "已删除"}
</span>
</span>
<span>{project.quota_bytes > 0 ? `${(project.quota_bytes / 1024 / 1024 / 1024).toFixed(1)} GB` : "无限制"}</span>
<span className="employee-actions">
<button type="button" disabled={!canManage} onClick={() => openMembersDrawer(project)}></button>
<button type="button" disabled={!canManage} onClick={() => openEditProject(project)}></button>
{/* <button type="button" disabled={!canManage} onClick={() => openImportMemberDialog(project)}>导入成员</button> */}
<button
type="button"
className="is-danger"
disabled={!canManage || project.status === "disabled"}
title={project.status === "disabled" ? "已删除的项目不能操作" : "删除项目"}
onClick={() => void deleteProject(project)}
>
</button>
</span>
</div>
))
)}
</div>
{projectDialogOpen && (
<div className="modal-backdrop">
<section className="modal modal--compact" role="dialog" aria-modal="true">
<div className="modal__header">
<div>
<span className="modal__eyebrow">PROJECT</span>
<h2>{editingProject ? "编辑项目" : "新建项目"}</h2>
</div>
<button className="icon-button" type="button" onClick={() => setProjectDialogOpen(false)}><Icon name="close" /></button>
</div>
<form onSubmit={(event) => void submitProject(event)}>
{!editingProject && (
<label className="form-field">
<span><span className="required">*</span></span>
<input
autoFocus
value={projectForm.workspace_code}
onChange={(event) => setProjectForm({ ...projectForm, workspace_code: event.target.value })}
placeholder="例如:model-development(小写字母、数字、连字符,3-32 字符)"
/>
</label>
)}
<label className="form-field">
<span><span className="required">*</span></span>
<input
value={projectForm.workspace_name}
onChange={(event) => setProjectForm({ ...projectForm, workspace_name: event.target.value })}
placeholder="请输入项目名称"
/>
</label>
<label className="form-field">
<span></span>
<input
type="number"
value={projectForm.quota_bytes}
onChange={(event) => setProjectForm({ ...projectForm, quota_bytes: parseInt(event.target.value) || 0 })}
placeholder="0 表示无配额限制"
/>
</label>
<label className="form-field">
<span></span>
<textarea
value={projectForm.description}
onChange={(event) => setProjectForm({ ...projectForm, description: event.target.value })}
placeholder="请输入项目描述(可选)"
rows={4}
/>
</label>
<div className="modal__footer">
<button className="secondary-button" type="button" onClick={() => setProjectDialogOpen(false)}></button>
<button className="primary-button" type="submit" disabled={saving}>{saving ? "保存中…" : "保存"}</button>
</div>
</form>
</section>
</div>
)}
{importMemberDialogOpen && (
<div className="modal-backdrop">
<section className="modal modal--compact" role="dialog" aria-modal="true">
<div className="modal__header">
<div>
<span className="modal__eyebrow">IMPORT MEMBER</span>
<h2> {selectedProject?.workspace_name ?? "项目"}</h2>
</div>
<button className="icon-button" type="button" onClick={() => setImportMemberDialogOpen(false)}><Icon name="close" /></button>
</div>
<form >
<label className="form-field" style={{ paddingBottom: 120 }}>
<span><span className="required">*</span></span>
<UserMultiSelect
users={availableUsers}
selectedUserIds={selectedUserIds}
onChange={setSelectedUserIds}
existingMemberIds={currentProjectMembers.map((m) => m.user_id)}
/>
</label>
<div className="modal__footer">
<button className="secondary-button" type="button" onClick={() => setImportMemberDialogOpen(false)}></button>
<button className="primary-button" type="button" disabled={saving || selectedUserIds.length === 0} onClick={() => void importMember()}>
{saving ? "添加中…" : `添加 (${selectedUserIds.length})`}
</button>
</div>
</form>
</section>
</div>
)}
{membersDrawerOpen && selectedProject && (
<>
<div className="drawer-overlay" onClick={() => setMembersDrawerOpen(false)} 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={{
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>
</div>
<button className="icon-button" type="button" onClick={() => setMembersDrawerOpen(false)}>
<Icon name="close" />
</button>
</div>
<div style={{
padding: "12px 20px",
borderBottom: "1px solid #f0f0f0",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}>
<span style={{ fontSize: 14, color: "#666" }}>
{currentProjectMembers.length}
</span>
<button
className="primary-button"
type="button"
onClick={() => {
setMembersDrawerOpen(false);
openImportMemberDialog(selectedProject);
}}
style={{ fontSize: 13, padding: "6px 12px" }}
>
<Icon name="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>
) : currentProjectMembers.length === 0 ? (
<p style={{ textAlign: "center", color: "#999", padding: "40px 0" }}></p>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{currentProjectMembers.map((member) => (
<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 }}>
{(() => {
const isAdmin = member.role_code === "admin";
return (
<button
type="button"
onClick={() => void removeMember(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>
))}
</div>
)}
</div>
</aside>
</>
)}
</section>
);
}
@@ -1,294 +0,0 @@
import { useEffect, useState } from "react";
import { ApiRequestError, type Employee } from "../../services/api";
import { useApi, useAuth } from "../../context/AuthContext";
import Icon from "../../components/common/Icon";
const EMPTY_FORM = {
username: "",
display_name: "",
email: "",
role_code: "developer" as "admin" | "developer",
password: "",
status: "active" as "active" | "disabled" | "locked",
};
export function UserManagementPage({
onNotify,
onConnectionChange,
}: {
onNotify: (notice: { tone: "success" | "error" | "info"; message: string }) => void;
onConnectionChange: (online: boolean) => void;
}) {
const api = useApi();
const { user } = useAuth();
const [employees, setEmployees] = useState<Employee[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [editing, setEditing] = useState<Employee | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState(EMPTY_FORM);
const [userSearchTerm, setUserSearchTerm] = useState("");
const canManage = user?.role_code === "admin";
const load = async (): Promise<void> => {
setLoading(true);
try {
setEmployees(await api.listPlatformEmployees());
onConnectionChange(true);
} catch (error) {
onConnectionChange(false);
onNotify({
tone: "error",
message: error instanceof Error ? error.message : "用户列表加载失败",
});
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
}, []);
const openCreate = (): void => {
setEditing(null);
setForm(EMPTY_FORM);
setDialogOpen(true);
};
const openEdit = (employee: Employee): void => {
setEditing(employee);
setForm({
username: employee.username,
display_name: employee.display_name,
email: employee.email ?? "",
role_code: employee.role_code,
password: "",
status: employee.status,
});
setDialogOpen(true);
};
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";
if (editingSelf && form.status !== editing.status) {
onNotify({ tone: "error", message: "不能停用当前登录账号" });
return;
}
if (targetIsAdmin && form.status !== editing.status) {
onNotify({ tone: "error", message: "不能停用或锁定管理员账号" });
return;
}
if (targetIsAdmin && form.role_code !== editing.role_code) {
onNotify({ tone: "error", message: "不能降级管理员账号" });
return;
}
if (editingSelf && form.role_code !== editing.role_code) {
onNotify({ tone: "error", message: "不能降级自身管理员角色" });
return;
}
}
// 密码长度校验 8~72 字符
if (form.password && (form.password.length < 8 || form.password.length > 72)) {
onNotify({ tone: "error", message: "密码长度必须在 8~72 字符之间" });
return;
}
setSaving(true);
try {
if (editing) {
const updated = await api.updatePlatformEmployee(editing.user_id, {
display_name: form.display_name.trim(),
email: form.email.trim() || null,
role_code: form.role_code,
status: form.status,
});
setEmployees((current) => current.map(
(item) => item.user_id === updated.user_id ? updated : item,
));
onNotify({ tone: "success", message: "用户信息已更新" });
} else {
const created = await api.createPlatformEmployee({
username: form.username.trim(),
display_name: form.display_name.trim(),
email: form.email.trim() || undefined,
password: form.password,
role_code: "developer",
});
setEmployees((current) => [...current, created]);
onNotify({ tone: "success", message: "用户已添加" });
}
setDialogOpen(false);
} catch (error) {
onNotify({
tone: "error",
message: error instanceof ApiRequestError ? error.message : "保存用户失败",
});
} finally {
setSaving(false);
}
};
const remove = async (employee: Employee): Promise<void> => {
if (!window.confirm(`确定从平台删除用户"${employee.display_name}"吗?`)) return;
try {
await api.deletePlatformEmployee(employee.user_id);
setEmployees((current) => current.filter((item) => item.user_id !== employee.user_id));
onNotify({ tone: "success", message: "用户已删除" });
} catch (error) {
onNotify({
tone: "error",
message: error instanceof Error ? error.message : "删除用户失败",
});
}
};
return (
<section className="admin-page">
<div className="admin-toolbar">
<div className="admin-toolbar__search">
<Icon name="search" size={14} />
<input
type="text"
placeholder="请输入用户名"
className="admin-toolbar__input"
value={userSearchTerm}
onChange={(event) => setUserSearchTerm(event.target.value)}
/>
</div>
<button
className="primary-button admin-toolbar__button"
type="button"
disabled={!canManage}
onClick={openCreate}
>
<Icon name="plus" size={15} />
</button>
</div>
{!canManage && <div className="admin-readonly"></div>}
<div className="employee-table">
<div className="employee-table__head"><span></span><span></span><span></span><span></span><span></span></div>
{loading ? (
<p className="admin-empty"></p>
) : (
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 (
<div className="employee-row" key={employee.user_id}>
<span className="employee-name"><b className="avatar">{employee.display_name.slice(0, 1)}</b><span><strong>{employee.display_name}</strong><small>{employee.email ?? "未设置邮箱"}</small></span></span>
<code>{employee.username}</code>
{employee.role_code ? (<span className={`role-pill is-${employee.role_code}`}>{employee.role_name}</span>) : (<span>-</span>)}
<span className={`status-pill is-${employee.status}`}>{employee.status === "active" ? "正常" : employee.status === "disabled" ? "已停用" : "已锁定"}</span>
<span className="employee-actions">
<button type="button" disabled={!canManage} onClick={() => openEdit(employee)}></button>
<button type="button" className="is-danger" disabled={!canManage || isProtectedAdmin} title={isProtectedAdmin ? "管理员账号不能删除" : "删除"} onClick={() => void remove(employee)}></button>
</span>
</div>
);
})
)}
</div>
{dialogOpen && (
<div className="modal-backdrop">
<section className="modal modal--compact" role="dialog" aria-modal="true">
<div className="modal__header">
<div>
<span className="modal__eyebrow">EMPLOYEE</span>
<h2>{editing ? "编辑用户" : "添加用户"}</h2>
</div>
<button className="icon-button" type="button" onClick={() => setDialogOpen(false)}><Icon name="close" /></button>
</div>
<form onSubmit={(event) => void submit(event)}>
<label className="form-field">
<span><span className="required">*</span></span>
<input
autoFocus={!editing}
value={form.display_name}
onChange={(event) => setForm({ ...form, display_name: event.target.value })}
placeholder="请输入姓名"
/>
</label>
<label className="form-field">
<span><span className="required">*</span></span>
<input
disabled={Boolean(editing)}
value={form.username}
onChange={(event) => setForm({ ...form, username: event.target.value })}
placeholder="请输入登录账号"
autoComplete="username"
/>
</label>
{!editing && (
<label className="form-field">
<span><span className="required">*</span></span>
<input
type="password"
autoComplete="new-password"
value={form.password}
onChange={(event) => setForm({ ...form, password: event.target.value })}
placeholder="请输入密码(8~72 字符)"
/>
</label>
)}
<label className="form-field">
<span></span>
<input
type="email"
autoComplete="email"
value={form.email}
onChange={(event) => setForm({ ...form, email: event.target.value })}
placeholder="请输入邮箱(可选)"
/>
</label>
{/* <label className="form-field">
<span>角色</span>
<select value={form.role_code} disabled={Boolean(editing) && ((editing as Employee).user_id === user?.user_id || (editing as Employee).role_code === "admin")} onChange={(event) => setForm({ ...form, role_code: event.target.value as "admin" | "developer" })}>
<option value="developer">开发人员</option>
<option value="admin">管理员</option>
</select>
</label> */}
{editing && (
<label className="form-field">
<span></span>
<select value={form.status} disabled={Boolean(editing) && ((editing as Employee).user_id === user?.user_id || (editing as Employee).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="modal__footer">
<button className="secondary-button" type="button" onClick={() => setDialogOpen(false)}></button>
<button className="primary-button" type="submit" disabled={saving}>{saving ? "保存中…" : "保存"}</button>
</div>
</form>
</section>
</div>
)}
</section>
);
}
@@ -1,172 +0,0 @@
import { useEffect, useState, useRef } from "react";
import { type Employee } from "../../services/api";
// 多选用户下拉框组件 - 带 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="admin-toolbar__input"
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>
);
}
@@ -1,80 +0,0 @@
import { type FormEvent } from "react";
import Icon from "../common/Icon";
type CreateFolderModalProps = {
open: boolean;
parentPath: string;
name: string;
busy: boolean;
onNameChange: (name: string) => void;
onSubmit: (event: FormEvent) => void;
onClose: () => void;
};
export function CreateFolderModal({
open,
parentPath,
name,
busy,
onNameChange,
onSubmit,
onClose,
}: CreateFolderModalProps) {
if (!open) return null;
return (
<div className="modal-backdrop" role="presentation">
<section className="modal modal--compact" role="dialog" aria-modal="true">
<div className="modal__header">
<div>
<span className="modal__eyebrow">WORKSPACE</span>
<h2></h2>
</div>
<button
className="icon-button"
type="button"
aria-label="关闭"
onClick={onClose}
>
<Icon name="close" />
</button>
</div>
<form onSubmit={onSubmit}>
<div className="destination-chip">
<Icon name="folder" size={16} />
{parentPath || "个人根目录"}
</div>
<label className="form-field">
<span></span>
<input
autoFocus
maxLength={255}
placeholder="例如:模型训练"
value={name}
onChange={(event) => onNameChange(event.target.value)}
/>
</label>
<div className="modal__footer">
<button
className="secondary-button"
type="button"
onClick={onClose}
>
</button>
<button
className="primary-button"
type="submit"
disabled={busy || !name.trim()}
>
{busy
? <span className="button-spinner" />
: <Icon name="folder" size={16} />}
{busy ? "正在创建…" : "创建文件夹"}
</button>
</div>
</form>
</section>
</div>
);
}
@@ -1,180 +0,0 @@
import { type FormEvent } from "react";
import Icon from "../common/Icon";
import type { ScriptType, Visibility } from "../../services/api";
type NewScriptForm = {
name: string;
scriptType: ScriptType;
visibility: Visibility;
parentPath: string;
};
type CreateScriptModalProps = {
open: boolean;
creating: boolean;
form: NewScriptForm;
scripts: Array<{
script_type: ScriptType;
script_name: string;
relative_path?: string;
}>;
onFormChange: (form: NewScriptForm) => void;
onSubmit: (event: FormEvent) => void;
onClose: () => void;
};
export function CreateScriptModal({
open,
creating,
form,
scripts,
onFormChange,
onSubmit,
onClose,
}: CreateScriptModalProps) {
if (!open) return null;
const suffix = form.scriptType === "notebook" ? ".ipynb" : ".py";
const requestedName = form.name.trim();
const normalizedName = requestedName.toLocaleLowerCase().endsWith(suffix)
? requestedName
: `${requestedName}${suffix}`;
const duplicate = scripts.some((script) => {
if (script.script_type !== form.scriptType) return false;
if (script.script_name.toLocaleLowerCase()
!== normalizedName.toLocaleLowerCase()) return false;
// Same name in a different subdirectory is allowed; mirror the
// backend name_clash scope (StorageObjects.relative_path JOIN).
if (!script.relative_path) return true;
const segments = script.relative_path.replaceAll("\\", "/")
.split("/").slice(2);
const existingUserPath = segments.join("/");
const existingParent = existingUserPath.includes("/")
? existingUserPath.slice(0, existingUserPath.lastIndexOf("/"))
: "";
return existingParent === form.parentPath;
});
return (
<div className="modal-backdrop" role="presentation">
<section className="modal" role="dialog" aria-modal="true">
<div className="modal__header">
<div>
<span className="modal__eyebrow"></span>
<h2></h2>
</div>
<button
className="icon-button"
type="button"
aria-label="关闭"
onClick={onClose}
>
<Icon name="close" />
</button>
</div>
<form onSubmit={onSubmit}>
<div className="destination-chip">
<Icon name="folder" size={16} />
{form.parentPath || "个人根目录"}
</div>
<label className="form-field">
<span></span>
<input
autoFocus
maxLength={255}
placeholder={form.scriptType === "notebook"
? "例如:数据探索"
: "例如:data_process"}
value={form.name}
onChange={(event) =>
onFormChange({
...form,
name: event.target.value,
})}
/>
<small>
{form.scriptType === "notebook" ? " .ipynb" : " .py"}
</small>
</label>
<fieldset className="type-picker">
<legend></legend>
<button
className={form.scriptType === "notebook" ? "is-selected" : ""}
type="button"
onClick={() => onFormChange({
...form,
scriptType: "notebook",
})}
>
<span className="type-picker__icon type-picker__icon--notebook">
<Icon name="notebook" size={22} />
</span>
<span>
<strong>Jupyter Notebook</strong>
<small></small>
</span>
<span className="type-picker__check">
<Icon name="check" size={14} />
</span>
</button>
<button
className={form.scriptType === "python" ? "is-selected" : ""}
type="button"
onClick={() => onFormChange({
...form,
scriptType: "python",
})}
>
<span className="type-picker__icon type-picker__icon--python">
<Icon name="python" size={23} />
</span>
<span>
<strong>Python </strong>
<small></small>
</span>
<span className="type-picker__check">
<Icon name="check" size={14} />
</span>
</button>
</fieldset>
<label className="form-field">
<span></span>
<select
value={form.visibility}
onChange={(event) =>
onFormChange({
...form,
visibility: event.target.value as Visibility,
})}
>
<option value="private"></option>
<option value="workspace">Workspace </option>
<option value="public"></option>
</select>
</label>
<div className="modal__footer">
<button
className="secondary-button"
type="button"
onClick={onClose}
>
</button>
<button
className="primary-button"
type="submit"
disabled={creating || !form.name.trim()}
>
{creating ? <span className="button-spinner" /> : <Icon name="plus" size={16} />}
{creating ? "正在创建…" : "创建脚本"}
</button>
</div>
</form>
</section>
</div>
);
}
@@ -1,141 +0,0 @@
import { type FormEvent } from "react";
import Icon from "../common/Icon";
import type { Visibility } from "../../services/api";
type DataResourceUploadModalProps = {
open: boolean;
file: File | null;
resourceName: string;
visibility: Visibility;
description: string;
uploading: boolean;
parentPath: string;
targetPath: string;
onNameChange: (name: string) => void;
onVisibilityChange: (visibility: Visibility) => void;
onDescriptionChange: (description: string) => void;
onTargetPathChange: (path: string) => void;
onSubmit: (event: FormEvent) => void;
onClose: () => void;
};
export function DataResourceUploadModal({
open,
file,
resourceName,
visibility,
description,
uploading,
parentPath,
targetPath,
onNameChange,
onVisibilityChange,
onDescriptionChange,
onTargetPathChange,
onSubmit,
onClose,
}: DataResourceUploadModalProps) {
if (!open) return null;
const finalTarget = targetPath.trim();
const finalPath = finalTarget
? `${finalTarget}/${file?.name ?? ""}`
: file?.name ?? "";
return (
<div className="modal-backdrop" role="presentation">
<section className="modal modal--compact" role="dialog" aria-modal="true">
<div className="modal__header">
<div>
<span className="modal__eyebrow"></span>
<h2></h2>
</div>
<button
className="icon-button"
type="button"
aria-label="关闭"
onClick={onClose}
>
<Icon name="close" />
</button>
</div>
<form onSubmit={onSubmit}>
<label className="form-field">
<span></span>
<div>{file ? file.name : "未选择文件"}</div>
</label>
<label className="form-field">
<span></span>
<input
autoFocus
maxLength={255}
placeholder="例如:训练数据"
value={resourceName}
onChange={(event) => onNameChange(event.target.value)}
required
/>
</label>
<label className="form-field">
<span></span>
<input
type="text"
maxLength={1024}
placeholder="例如:train/v1(留空上传到当前目录)"
value={targetPath}
onChange={(event) => onTargetPathChange(event.target.value)}
/>
<small className="form-field__hint">
{parentPath
? `当前位于 ${parentPath || "根目录"} · 最终路径:${finalPath || "—"}`
: `最终路径:${finalPath || "—"}`}
</small>
</label>
<label className="form-field">
<span></span>
<select
value={visibility}
onChange={(event) =>
onVisibilityChange(event.target.value as Visibility)
}
>
<option value="private"></option>
<option value="workspace"></option>
<option value="public"></option>
</select>
</label>
<label className="form-field">
<span></span>
<textarea
rows={3}
placeholder="可选描述"
value={description}
onChange={(event) => onDescriptionChange(event.target.value)}
/>
</label>
<div className="modal__footer">
<button
className="secondary-button"
type="button"
onClick={onClose}
disabled={uploading}
>
</button>
<button
className="primary-button"
type="submit"
disabled={uploading || !resourceName.trim()}
>
{uploading ? (
<span className="button-spinner" />
) : (
<Icon name="upload" size={16} />
)}
{uploading ? "上传中…" : "上传"}
</button>
</div>
</form>
</section>
</div>
);
}
@@ -1,101 +0,0 @@
import { type FormEvent } from "react";
import Icon from "../common/Icon";
import type { ScriptItem, Visibility } from "../../services/api";
import { scriptIcon } from "../../features/platform/WorkspaceTree";
type PublishModalProps = {
publishTarget: ScriptItem | null;
releaseNote: string;
publishVisibility: Visibility;
publishing: boolean;
onReleaseNoteChange: (note: string) => void;
onPublishVisibilityChange: (visibility: Visibility) => void;
onSubmit: (event: FormEvent) => void;
onClose: () => void;
};
export function PublishModal({
publishTarget,
releaseNote,
publishVisibility,
publishing,
onReleaseNoteChange,
onPublishVisibilityChange,
onSubmit,
onClose,
}: PublishModalProps) {
if (!publishTarget) return null;
return (
<div className="modal-backdrop" role="presentation">
<section className="modal publish-modal" role="dialog" aria-modal="true">
<div className="modal__header">
<div>
<span className="modal__eyebrow"></span>
<h2></h2>
</div>
<button
className="icon-button"
type="button"
aria-label="关闭"
onClick={onClose}
>
<Icon name="close" />
</button>
</div>
<form onSubmit={onSubmit}>
<div className="publish-source">
<span className={`file-icon file-icon--${publishTarget.script_type}`}>
<Icon name={scriptIcon(publishTarget)} size={18} />
</span>
<span>
<strong>{publishTarget.script_name}</strong>
<small> Workspace </small>
</span>
</div>
<label className="form-field">
<span></span>
<textarea
maxLength={1000}
placeholder="例如:完成数据清洗和特征工程"
value={releaseNote}
onChange={(event) => onReleaseNoteChange(event.target.value)}
/>
<small></small>
</label>
<label className="form-field">
<span></span>
<select
value={publishVisibility}
onChange={(event) =>
onPublishVisibilityChange(event.target.value as Visibility)}
>
<option value="private"></option>
<option value="workspace">Workspace </option>
<option value="public"></option>
</select>
</label>
<div className="modal__footer">
<button
className="secondary-button"
type="button"
onClick={onClose}
>
</button>
<button
className="primary-button"
type="submit"
disabled={publishing}
>
{publishing
? <span className="button-spinner" />
: <Icon name="release" size={16} />}
{publishing ? "正在发布…" : "确认发布"}
</button>
</div>
</form>
</section>
</div>
);
}
@@ -1,316 +0,0 @@
import { type ChangeEvent, type MouseEvent as ReactMouseEvent, type RefObject, useMemo } from "react";
import Icon from "../common/Icon";
import { WorkspaceTreeGroup } from "~/features/platform/WorkspaceTree";
import { useScriptWorkspaceStore } from "~/features/platform/state/scriptWorkspaceStore";
import type { ScriptItem, WorkspaceDirectory } from "~/services/api";
import { type AuthUser } from "~/context/AuthContext";
import type { ResourceItem } from "~/services/api";
type ScriptExplorerProps = {
scripts: ScriptItem[];
filteredScripts: ScriptItem[];
directories: WorkspaceDirectory[];
dataResources: ResourceItem[];
user: AuthUser | null;
selectedId: string | null;
loading: boolean;
refreshing: boolean;
uploading: boolean;
keyword: string;
onKeywordChange: (keyword: string) => void;
onRefresh: () => void;
onUpload: () => void;
onOpenCreateDialog: (parentPath?: string, scriptType?: "notebook" | "python") => void;
onOpenFolderDialog: (parentPath?: string) => void;
onChooseUpload: (parentPath?: string) => void;
onContextMenu: (
event: ReactMouseEvent,
target: { kind: "root" | "directory" | "file"; path: string; script?: ScriptItem }
) => void;
onSelect: (scriptId: string) => void;
onCopyResourcePath: (jupyterPath: string) => void;
uploadInputRef: RefObject<HTMLInputElement | null>;
onHandleUpload: (event: ChangeEvent<HTMLInputElement>) => void;
};
export function ScriptExplorer({
scripts,
filteredScripts,
directories,
dataResources,
user,
selectedId,
loading,
refreshing,
uploading,
keyword,
onKeywordChange,
onRefresh,
onUpload,
onOpenCreateDialog,
onOpenFolderDialog,
onChooseUpload,
onContextMenu,
onSelect,
onCopyResourcePath,
uploadInputRef,
onHandleUpload,
}: ScriptExplorerProps) {
const expandedPaths = useScriptWorkspaceStore((s) => s.expandedPaths);
const loadingChildrenPaths = useScriptWorkspaceStore(
(s) => s.loadingChildrenPaths,
);
const members = useScriptWorkspaceStore((s) => s.members);
const onToggle = useScriptWorkspaceStore((s) => s.toggleExpanded);
const dataByOwner = useMemo(() => {
const map = new Map<string, ResourceItem[]>();
for (const r of dataResources) {
const list = map.get(r.owner_user_id) ?? [];
list.push(r);
map.set(r.owner_user_id, list);
}
return map;
}, [dataResources]);
const memberScriptGroups = useMemo(() => {
const visibleScripts =
user?.is_system_admin === true
? filteredScripts
: filteredScripts.filter(
(item) =>
item.owner_user_id === user?.user_id ||
item.visibility === "workspace" ||
item.visibility === "public",
);
// 用工作区成员列表播种分组 —— 顶层"我 / user1 / user2 / …"折叠分组
// 的来源。即使某成员尚未加载任何脚本/数据(默认折叠、点击才拉取),
// 也作为空分组出现,保证目录树结构稳定可见(修"目录树结构消失")。
const byOwner = new Map<string, ScriptItem[]>();
for (const m of members) {
if (!byOwner.has(m.user_id)) byOwner.set(m.user_id, []);
}
// 当前用户兜底(members 未就绪时仍渲染"我"的分组)。
if (user?.user_id && !byOwner.has(user.user_id)) {
byOwner.set(user.user_id, []);
}
for (const item of visibleScripts) {
const list = byOwner.get(item.owner_user_id) ?? [];
list.push(item);
byOwner.set(item.owner_user_id, list);
}
// data-only owner(只有数据资源、没有 scripts 的用户,且不在 members 列表
// 里,如已移除成员遗留的资源)也要出现在分组里。
for (const ownerUserId of dataByOwner.keys()) {
if (!byOwner.has(ownerUserId)) {
byOwner.set(ownerUserId, []);
}
}
// 成员 id → display_name 优先取 members 列表(最准)。
const memberName = new Map(members.map((m) => [m.user_id, m.display_name]));
const groups: {
user: AuthUser | null;
scripts: ScriptItem[];
directories: WorkspaceDirectory[];
dataResources: ResourceItem[];
}[] = [];
for (const [ownerUserId, groupScripts] of byOwner.entries()) {
const groupDataResources = dataByOwner.get(ownerUserId) ?? [];
const displayName =
memberName.get(ownerUserId) ??
groupScripts[0]?.owner_display_name ??
groupDataResources[0]?.owner_display_name ??
(ownerUserId === user?.user_id ? user?.display_name : null) ??
`${ownerUserId.slice(-6)}`;
const groupUser =
ownerUserId === user?.user_id
? user
: ({
user_id: ownerUserId,
username: ownerUserId,
display_name: displayName,
email: null,
status: "unknown",
role_code: null,
is_system_admin: false,
} as AuthUser);
const inferred = inferredDirectories(groupScripts, ownerUserId);
// directories flat 数组现在按 owner_user_id 标记,按 owner 切分后与
// inferred 合并(inferred 补全 fetched 目录行未覆盖的祖先路径)。
const ownerDirs = directories.filter(
(d) => d.owner_user_id === ownerUserId,
);
groups.push({
user: groupUser,
scripts: groupScripts,
directories: mergeDirectories(ownerDirs, inferred),
dataResources: groupDataResources,
});
}
groups.sort((a, b) => {
if (a.user?.user_id === user?.user_id) return -1;
if (b.user?.user_id === user?.user_id) return 1;
return (a.user?.display_name ?? a.user?.user_id ?? "").localeCompare(
b.user?.display_name ?? b.user?.user_id ?? "",
);
});
return groups;
}, [filteredScripts, directories, user, dataByOwner, members]);
return (
<aside className="explorer">
<div className="explorer__header">
<div>
<h2></h2>
</div>
<div className="explorer__actions">
<button
className="text-button"
type="button"
disabled={uploading}
onClick={onUpload}
>
<Icon name="upload" size={15} />
{uploading ? "上传中…" : "上传"}
</button>
<input
ref={uploadInputRef}
className="visually-hidden"
type="file"
accept=".py,.ipynb,.csv,.xlsx,.xls,.tsv,.json,.parquet,.txt"
multiple
onChange={onHandleUpload}
/>
<button
className="text-button"
type="button"
onClick={() => onOpenCreateDialog()}
>
<Icon name="plus" size={16} />
</button>
</div>
</div>
<div className="search-box">
<Icon name="search" size={17} />
<input
aria-label="搜索脚本"
placeholder="搜索脚本名称"
value={keyword}
onChange={(event) => onKeywordChange(event.target.value)}
/>
<button
className={refreshing ? "is-spinning" : ""}
type="button"
aria-label="刷新脚本"
onClick={onRefresh}
>
<Icon name="refresh" size={16} />
</button>
</div>
<div className="tree-scroll">
{loading ? (
<div className="tree-skeleton">
<span /><span /><span /><span />
</div>
) : (
<>
{memberScriptGroups.map((group) => {
const ownerKey = group.user?.user_id ?? "anon";
return (
<WorkspaceTreeGroup
key={ownerKey}
groupKey={`__group__${ownerKey}`}
ownerUserId={ownerKey}
title={`${group.user?.display_name}`}
scripts={group.scripts}
directories={group.directories}
dataResources={group.dataResources}
selectedId={selectedId}
onSelect={onSelect}
onContextMenu={
group.user?.user_id === user?.user_id
? onContextMenu
: undefined
}
readOnly={group.user?.user_id !== user?.user_id}
expandedPaths={expandedPaths}
onToggle={onToggle}
loadingChildrenPaths={loadingChildrenPaths}
onCopyResourcePath={onCopyResourcePath}
/>
);
})}
{filteredScripts.length === 0 && directories.length === 0 && (
<div className="tree-empty">
<span className="tree-empty__icon">
<Icon name="script" size={24} />
</span>
<strong>{keyword ? "没有匹配脚本" : "还没有构建脚本"}</strong>
<p>
{keyword
? "换个关键词试试"
: "新建 Notebook 或 Python 脚本开始实验"}
</p>
{!keyword && (
<button type="button" onClick={() => onOpenCreateDialog()}>
<Icon name="plus" size={15} />
</button>
)}
</div>
)}
</>
)}
</div>
</aside>
);
}
function parentOf(path: string) {
const parts = path.split("/");
parts.pop();
return parts.join("/");
}
function ownedScriptPath(item: ScriptItem) {
return item.relative_path.replaceAll("\\", "/").split("/").slice(2).join("/");
}
function inferredDirectories(
items: ScriptItem[],
ownerUserId: string,
): WorkspaceDirectory[] {
const result = new Map<string, WorkspaceDirectory>();
for (const item of items) {
const parts = parentOf(ownedScriptPath(item)).split("/").filter(Boolean);
let parentPath = "";
for (const name of parts) {
const path = parentPath ? `${parentPath}/${name}` : name;
result.set(path, {
path,
name,
parent_path: parentPath,
owner_user_id: ownerUserId,
});
parentPath = path;
}
}
return [...result.values()];
}
function mergeDirectories(
left: WorkspaceDirectory[],
right: WorkspaceDirectory[],
): WorkspaceDirectory[] {
return [...new Map(
[...left, ...right].map((item) => [item.path, item]),
).values()];
}
@@ -1,181 +0,0 @@
import Icon from "../common/Icon";
import type { ScriptItem, ScriptType } from "../../services/api";
type ContextMenuState = {
x: number;
y: number;
kind: "root" | "directory" | "file";
path: string;
script?: ScriptItem;
};
type TreeContextMenuProps = {
contextMenu: ContextMenuState | null;
onOpenScript: (scriptId: string) => void;
onRemoveScript: (script: ScriptItem) => void;
onToggleLock: (script: ScriptItem) => void;
onOpenCreateDialog: (parentPath: string, scriptType: ScriptType) => void;
onOpenFolderDialog: (parentPath: string) => void;
onChooseUpload: (parentPath: string) => void;
onRemoveDirectory: (path: string) => void;
onCopyResourcePath?: (jupyterPath: string) => void;
onRemoveResource?: (resourceId: string) => void;
onClose: () => void;
};
export function TreeContextMenu({
contextMenu,
onOpenScript,
onRemoveScript,
onToggleLock,
onOpenCreateDialog,
onOpenFolderDialog,
onChooseUpload,
onRemoveDirectory,
onCopyResourcePath,
onRemoveResource,
onClose,
}: TreeContextMenuProps) {
if (!contextMenu) return null;
const width = 188;
const height = contextMenu.kind === "file" ? 124 : 190;
const isDataResource = !!contextMenu.script?.script_id.startsWith("data:");
return (
<div
className="tree-context-menu"
role="menu"
style={{
left: contextMenu.x,
top: contextMenu.y,
position: "fixed",
zIndex: 1000,
}}
onPointerDown={(event) => event.stopPropagation()}
>
{contextMenu.kind === "file" && contextMenu.script ? (
<>
{isDataResource ? (
<>
<button
type="button"
role="menuitem"
onClick={() => {
if (onCopyResourcePath) {
onCopyResourcePath(contextMenu.script!.relative_path);
}
onClose();
}}
>
<Icon name="database" size={16} />
Jupyter
</button>
{onRemoveResource && (
<button
className="is-danger"
type="button"
role="menuitem"
onClick={() => {
const id = contextMenu.script!.script_id.replace(/^data:/, "");
onRemoveResource(id);
onClose();
}}
>
<Icon name="close" size={16} />
</button>
)}
</>
) : (
<>
<button
type="button"
role="menuitem"
onClick={() => {
onOpenScript(contextMenu.script!.script_id);
onClose();
}}
>
<Icon name="script" size={16} />
</button>
<button
type="button"
role="menuitem"
onClick={() => {
onToggleLock(contextMenu.script!);
onClose();
}}
>
<Icon
name={contextMenu.script.is_locked ? "unlock" : "lock"}
size={16}
/>
{contextMenu.script.is_locked ? "解锁文件" : "锁定文件"}
</button>
<button
className="is-danger"
type="button"
role="menuitem"
onClick={() => onRemoveScript(contextMenu.script!)}
>
<Icon name="close" size={16} />
</button>
</>
)}
</>
) : (
<>
<button
type="button"
role="menuitem"
onClick={() => onOpenCreateDialog(contextMenu.path, "notebook")}
>
<Icon name="notebook" size={16} />
Notebook
</button>
<button
type="button"
role="menuitem"
onClick={() => onOpenCreateDialog(contextMenu.path, "python")}
>
<Icon name="python" size={16} />
Python
</button>
<button
type="button"
role="menuitem"
onClick={() => onOpenFolderDialog(contextMenu.path)}
>
<Icon name="folder" size={16} />
</button>
<button
type="button"
role="menuitem"
onClick={() => onChooseUpload(contextMenu.path)}
>
<Icon name="upload" size={16} />
</button>
{contextMenu.kind === "directory" && (
<>
<span className="tree-context-menu__separator" />
<button
className="is-danger"
type="button"
role="menuitem"
onClick={() => onRemoveDirectory(contextMenu.path)}
>
<Icon name="close" size={16} />
</button>
</>
)}
</>
)}
</div>
);
}
@@ -1,53 +0,0 @@
import Icon from "../common/Icon";
import type { StableVersion } from "../../services/api";
import { copyToClipboard } from "../../lib/clipboard";
type VersionReceiptModalProps = {
publishedVersion: StableVersion | null;
onClose: () => void;
onCopy: (text: string) => void;
};
export function VersionReceiptModal({
publishedVersion,
onClose,
onCopy,
}: VersionReceiptModalProps) {
if (!publishedVersion) return null;
return (
<div className="modal-backdrop" role="presentation">
<section className="modal version-receipt" role="dialog" aria-modal="true">
<div className="version-receipt__check">
<Icon name="check" size={28} />
</div>
<span className="modal__eyebrow">STABLE VERSION</span>
<h2></h2>
<p>
{publishedVersion.version_label}
versions_id
</p>
<div className="version-id-box">
<span>versions_id</span>
<code>{publishedVersion.versions_id}</code>
<button
type="button"
onClick={async () => {
await copyToClipboard(publishedVersion.versions_id);
onCopy("versions_id 已复制");
}}
>
</button>
</div>
<button
className="primary-button version-receipt__close"
type="button"
onClick={onClose}
>
</button>
</section>
</div>
);
}