把 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>
556 lines
22 KiB
TypeScript
556 lines
22 KiB
TypeScript
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 "../../components/common/Icon";
|
|
import { UserMultiSelect } from "./UserMultiSelect";
|
|
|
|
import "../../styles/admin.css";
|
|
|
|
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>
|
|
);
|
|
}
|