Files
model-platform/frontend/app/components/admin/ProjectManagementPage.tsx
T
2026-08-05 15:13:34 +08:00

365 lines
14 KiB
TypeScript

import { useEffect, useState } from "react";
import { ApiRequestError, type Employee, type Workspace } 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 } = 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 [selectedRoleCode, setSelectedRoleCode] = useState<"admin" | "developer">("developer");
const [projectSearchTerm, setProjectSearchTerm] = useState("");
const [saving, setSaving] = 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);
}
};
const loadAvailableUsers = async (): Promise<void> => {
try {
const list = await api.listEmployees();
setAvailableUsers(list);
} catch (error) {
onNotify({
tone: "error",
message: error instanceof Error ? error.message : "用户列表加载失败",
});
}
};
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]);
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 = (project: Workspace): void => {
setSelectedProject(project);
setSelectedUserIds([]);
setSelectedRoleCode("developer");
void loadAvailableUsers();
setImportMemberDialogOpen(true);
};
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,
role_code: selectedRoleCode,
})
)
);
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={() => 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
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 importMember()}>
{saving ? "添加中…" : `添加 (${selectedUserIds.length})`}
</button>
</div>
</section>
</div>
)}
</section>
);
}