330 lines
14 KiB
TypeScript
330 lines
14 KiB
TypeScript
import { useEffect, type FormEvent } from "react";
|
|
|
|
import { type Workspace } from "../../services/api";
|
|
import { useAuth } from "../../context/AuthContext";
|
|
import Icon from "../common/Icon";
|
|
import { useAdminStore } from "../../features/admin/state/adminStore";
|
|
|
|
import "../../styles/admin.css";
|
|
import { UserMultiSelect } from "./UserMultiSelect";
|
|
|
|
type Notice = {
|
|
tone: "success" | "error" | "info";
|
|
message: string;
|
|
};
|
|
|
|
export function ProjectManagementPage({
|
|
onNotify,
|
|
onConnectionChange,
|
|
}: {
|
|
onNotify: (notice: Notice) => void;
|
|
onConnectionChange: (online: boolean) => void;
|
|
}) {
|
|
const { user, currentWorkspace } = useAuth();
|
|
|
|
const projects = useAdminStore((s) => s.projects);
|
|
const projectLoading = useAdminStore((s) => s.projectsLoading);
|
|
const saving = useAdminStore((s) => s.projectsSaving);
|
|
const editingProject = useAdminStore((s) => s.editingProject);
|
|
const projectDialogOpen = useAdminStore((s) => s.projectDialogOpen);
|
|
const projectForm = useAdminStore((s) => s.projectForm);
|
|
const importMemberDialogOpen = useAdminStore((s) => s.importMemberDialogOpen);
|
|
const selectedProject = useAdminStore((s) => s.selectedProject);
|
|
const availableUsers = useAdminStore((s) => s.availableUsers);
|
|
const selectedUserIds = useAdminStore((s) => s.selectedUserIds);
|
|
const selectedRoleCode = useAdminStore((s) => s.selectedRoleCode);
|
|
const projectSearchTerm = useAdminStore((s) => s.projectSearchTerm);
|
|
|
|
const setProjectDialogOpen = useAdminStore((s) => s.setProjectDialogOpen);
|
|
const setProjectForm = useAdminStore((s) => s.setProjectForm);
|
|
const setEditingProject = useAdminStore((s) => s.setEditingProject);
|
|
const setImportMemberDialogOpen = useAdminStore((s) => s.setImportMemberDialogOpen);
|
|
const setSelectedProject = useAdminStore((s) => s.setSelectedProject);
|
|
const setSelectedUserIds = useAdminStore((s) => s.setSelectedUserIds);
|
|
const setSelectedRoleCode = useAdminStore((s) => s.setSelectedRoleCode);
|
|
const setProjectSearchTerm = useAdminStore((s) => s.setProjectSearchTerm);
|
|
|
|
const loadProjects = useAdminStore((s) => s.loadProjects);
|
|
const createProject = useAdminStore((s) => s.createProject);
|
|
const updateProject = useAdminStore((s) => s.updateProject);
|
|
const deleteProject = useAdminStore((s) => s.deleteProject);
|
|
const openImportMembers = useAdminStore((s) => s.openImportMembers);
|
|
const importMembers = useAdminStore((s) => s.importMembers);
|
|
const reset = useAdminStore((s) => s.reset);
|
|
|
|
const canManage = user?.role_code === "admin";
|
|
|
|
const wsId = currentWorkspace?.workspace_id;
|
|
useEffect(() => {
|
|
reset();
|
|
void loadProjects(onNotify, onConnectionChange);
|
|
}, [loadProjects, reset, wsId, onNotify, onConnectionChange]);
|
|
|
|
const openCreateProject = (): void => {
|
|
setEditingProject(null);
|
|
setProjectForm({
|
|
workspace_code: "",
|
|
workspace_name: "",
|
|
quota_bytes: 0,
|
|
description: "",
|
|
});
|
|
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: 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;
|
|
}
|
|
if (editingProject) {
|
|
await updateProject(
|
|
editingProject,
|
|
{
|
|
workspace_name: projectForm.workspace_name.trim(),
|
|
quota_bytes: projectForm.quota_bytes,
|
|
description: projectForm.description.trim() || "",
|
|
},
|
|
onNotify,
|
|
onConnectionChange,
|
|
);
|
|
} else {
|
|
const generatedCode = projectForm.workspace_code.trim()
|
|
|| projectForm.workspace_name.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").slice(0, 32);
|
|
await createProject(
|
|
{
|
|
workspace_code: generatedCode,
|
|
workspace_name: projectForm.workspace_name.trim(),
|
|
quota_bytes: projectForm.quota_bytes,
|
|
description: projectForm.description.trim() || "",
|
|
},
|
|
onNotify,
|
|
onConnectionChange,
|
|
);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (project: Workspace): Promise<void> => {
|
|
if (!window.confirm(`确定要删除项目"${project.workspace_name}"吗?此操作将级联软删所有成员。`)) return;
|
|
await deleteProject(project, onNotify, onConnectionChange);
|
|
};
|
|
|
|
const openImportMemberDialog = (project: Workspace): void => {
|
|
setSelectedProject(project);
|
|
setSelectedUserIds([]);
|
|
setSelectedRoleCode("developer");
|
|
void openImportMembers(project, onNotify);
|
|
};
|
|
|
|
const handleImportMember = async (): Promise<void> => {
|
|
if (!selectedProject || selectedUserIds.length === 0) {
|
|
onNotify({ tone: "error", message: "请选择要添加的用户" });
|
|
return;
|
|
}
|
|
if (!selectedProject) return;
|
|
await importMembers(
|
|
selectedProject,
|
|
selectedUserIds,
|
|
selectedRoleCode,
|
|
onNotify,
|
|
);
|
|
};
|
|
|
|
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={() => 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 handleDelete(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
|
|
autoFocus={!editingProject}
|
|
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>
|
|
<div style={{ padding: "16px 24px 16px" }}>
|
|
<label className="form-field">
|
|
<span>选择用户<span className="required">*</span></span>
|
|
<UserMultiSelect
|
|
users={availableUsers}
|
|
selectedUserIds={selectedUserIds}
|
|
onChange={setSelectedUserIds}
|
|
/>
|
|
</label>
|
|
<label className="form-field" style={{ marginTop: "12px" }}>
|
|
<span>角色<span className="required">*</span></span>
|
|
<select
|
|
value={selectedRoleCode}
|
|
onChange={(event) => setSelectedRoleCode(event.target.value as "admin" | "developer")}
|
|
style={{ marginTop: "4px" }}
|
|
>
|
|
<option value="developer">开发人员</option>
|
|
<option value="admin">管理员</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<div style={{ display: "flex", justifyContent: "flex-end", gap: "8px", padding: "16px 24px", borderTop: "1px solid #e8e8e8" }}>
|
|
<button className="secondary-button" type="button" onClick={() => setImportMemberDialogOpen(false)}>取消</button>
|
|
<button className="primary-button" type="button" disabled={saving || selectedUserIds.length === 0} onClick={() => void handleImportMember()}>
|
|
{saving ? "添加中…" : `添加 (${selectedUserIds.length})`}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
} |