388 lines
17 KiB
TypeScript
388 lines
17 KiB
TypeScript
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>
|
||
);
|
||
}
|