Files
model-platform/frontend/app/features/admin/AdminPages.tsx
T

388 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { type FormEvent, useEffect, useState } from "react";
import {
ApiRequestError,
type Employee,
} from "../../services/api";
import { useApi, useAuth } from "../../context/AuthContext";
import Icon from "../../components/Icon";
import "../../styles/admin.css";
import "../../styles/dashboard.css";
import "../../styles/platform.css";
type Notice = {
tone: "success" | "error" | "info";
message: string;
};
export function DashboardPage({
scriptCount,
online,
onNavigate,
}: {
scriptCount: number;
online: boolean;
onNavigate: (page: "scripts" | "schedules" | "system") => void;
}) {
const { user, currentWorkspace } = useAuth();
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>
<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>
);
}
const EMPTY_FORM = {
username: "",
display_name: "",
email: "",
role_code: "developer" as "admin" | "developer",
status: "active" as "active" | "disabled" | "locked",
};
const EMPTY_PROJECT_FORM = {
project_name: "",
description: "",
};
export function SystemAdminPage({
onNotify,
onConnectionChange,
}: {
onNotify: (notice: Notice) => void;
onConnectionChange: (online: boolean) => void;
}) {
const [activeTab, setActiveTab] = useState<"users" | "projects">("users");
const api = useApi();
const { user, currentWorkspace } = 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 [projectDialogOpen, setProjectDialogOpen] = useState(false);
const [projectForm, setProjectForm] = useState(EMPTY_PROJECT_FORM);
const canManage = user?.role_code === "admin";
const load = async (): Promise<void> => {
setLoading(true);
try {
setEmployees(await api.listEmployees());
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,
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;
setSaving(true);
try {
if (editing) {
const updated = await api.updateEmployee(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,
));
} else {
const created = await api.createEmployee({
username: form.username.trim(),
display_name: form.display_name.trim(),
email: form.email.trim() || null,
role_code: form.role_code,
});
setEmployees((current) => [...current, created]);
}
setDialogOpen(false);
onNotify({ tone: "success", message: editing ? "用户信息已更新" : "用户已添加" });
} catch (error) {
onNotify({
tone: "error",
message: error instanceof ApiRequestError ? error.message : "保存用户失败",
});
} finally {
setSaving(false);
}
};
const remove = async (employee: Employee): Promise<void> => {
if (!window.confirm(`确定从当前 Workspace 删除用户"${employee.display_name}"吗?`)) return;
try {
await api.deleteEmployee(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 : "删除用户失败",
});
}
};
const openCreateProject = (): void => {
setProjectForm(EMPTY_PROJECT_FORM);
setProjectDialogOpen(true);
};
const submitProject = async (event: React.FormEvent): Promise<void> => {
event.preventDefault();
if (!projectForm.project_name.trim()) return;
setSaving(true);
try {
// TODO: 调用实际的项目创建 API
// await api.createProject({ ... })
setProjectDialogOpen(false);
onNotify({ tone: "success", message: "项目已创建" });
} catch (error) {
onNotify({
tone: "error",
message: error instanceof ApiRequestError ? error.message : "创建项目失败",
});
} finally {
setSaving(false);
}
};
return (
<section className="admin-page">
{/* <header className="admin-page__header">
<div>
<span>系统管理</span>
<h3>{activeTab === "users" ? "用户管理" : "项目管理"}</h3>
<p>
{currentWorkspace?.workspace_name ?? "(未选择 Workspace"}
{activeTab === "users" ? ` · ${employees.length} 名用户` : ""}
</p>
</div>
{activeTab === "users" && (
<button className="primary-button" type="button" disabled={!canManage} onClick={openCreate}>
<Icon name="plus" size={15} />添加用户
</button>
)}
</header> */}
<div className="admin-tabs">
<button
className={`admin-tab${activeTab === "users" ? " is-active" : ""}`}
type="button"
onClick={() => setActiveTab("users")}
>
<Icon name="workspace" size={16} />
用户管理
</button>
<button
className={`admin-tab${activeTab === "projects" ? " is-active" : ""}`}
type="button"
onClick={() => setActiveTab("projects")}
>
<Icon name="folder" size={16} />
项目管理
</button>
</div>
{activeTab === "users" && (
<div className="admin-toolbar">
<div className="admin-toolbar__search">
<Icon name="search" size={14} />
<input
type="text"
placeholder="请输入用户名"
className="admin-toolbar__input"
/>
</div>
<button
className="primary-button admin-toolbar__button"
type="button"
disabled={!canManage}
onClick={openCreate}
>
<Icon name="plus" size={15} />
新建用户
</button>
</div>
)}
{activeTab === "projects" && (
<div className="admin-toolbar">
<div className="admin-toolbar__search">
<Icon name="search" size={14} />
<input
type="text"
placeholder="请输入项目名称"
className="admin-toolbar__input"
/>
</div>
<button
className="primary-button admin-toolbar__button"
type="button"
disabled={!canManage}
onClick={openCreateProject}
>
<Icon name="plus" size={15} />
新建项目
</button>
</div>
)}
{activeTab === "users" ? (
<>
{!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.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>
<span className={`role-pill is-${employee.role_code}`}>{employee.role_name}</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 value={form.display_name} onChange={(event) => setForm({ ...form, display_name: event.target.value })} /></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 })} /></label>
<label className="form-field"><span>邮箱</span><input type="email" value={form.email} onChange={(event) => setForm({ ...form, email: event.target.value })} /></label>
<label className="form-field"><span>角色</span><select value={form.role_code} 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} 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>
)}
</>
) : (
<div className="project-list-placeholder">
<Icon name="folder" size={42} />
<h3>项目管理</h3>
<p>项目管理功能开发中…</p>
</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>新建项目</h2></div><button className="icon-button" type="button" onClick={() => setProjectDialogOpen(false)}><Icon name="close" /></button></div>
<form onSubmit={(event) => void submitProject(event)}>
<label className="form-field"><span>项目名称<span className="required">*</span></span><input autoFocus value={projectForm.project_name} onChange={(event) => setProjectForm({ ...projectForm, project_name: event.target.value })} placeholder="请输入项目名称" /></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>
)}
</section>
);
}