把 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>
297 lines
12 KiB
TypeScript
297 lines
12 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
|
|
import { ApiRequestError, type Employee } from "../../services/api";
|
|
import { useApi, useAuth } from "../../context/AuthContext";
|
|
import Icon from "../../components/common/Icon";
|
|
|
|
import "../../styles/admin.css";
|
|
|
|
const EMPTY_FORM = {
|
|
username: "",
|
|
display_name: "",
|
|
email: "",
|
|
role_code: "developer" as "admin" | "developer",
|
|
password: "",
|
|
status: "active" as "active" | "disabled" | "locked",
|
|
};
|
|
|
|
export function UserManagementPage({
|
|
onNotify,
|
|
onConnectionChange,
|
|
}: {
|
|
onNotify: (notice: { tone: "success" | "error" | "info"; message: string }) => void;
|
|
onConnectionChange: (online: boolean) => void;
|
|
}) {
|
|
const api = useApi();
|
|
const { user } = 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 [userSearchTerm, setUserSearchTerm] = useState("");
|
|
|
|
const canManage = user?.role_code === "admin";
|
|
|
|
const load = async (): Promise<void> => {
|
|
setLoading(true);
|
|
try {
|
|
setEmployees(await api.listPlatformEmployees());
|
|
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,
|
|
password: "",
|
|
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;
|
|
// 新建用户时密码必填
|
|
if (!editing && !form.password.trim()) {
|
|
onNotify({ tone: "error", message: "请输入密码" });
|
|
return;
|
|
}
|
|
// 编辑自身或管理员时,前端二次拦截禁用 status / role_code 的修改
|
|
if (editing) {
|
|
const editingSelf = editing.user_id === user?.user_id;
|
|
const targetIsAdmin = editing.role_code === "admin";
|
|
if (editingSelf && form.status !== editing.status) {
|
|
onNotify({ tone: "error", message: "不能停用当前登录账号" });
|
|
return;
|
|
}
|
|
if (targetIsAdmin && form.status !== editing.status) {
|
|
onNotify({ tone: "error", message: "不能停用或锁定管理员账号" });
|
|
return;
|
|
}
|
|
if (targetIsAdmin && form.role_code !== editing.role_code) {
|
|
onNotify({ tone: "error", message: "不能降级管理员账号" });
|
|
return;
|
|
}
|
|
if (editingSelf && form.role_code !== editing.role_code) {
|
|
onNotify({ tone: "error", message: "不能降级自身管理员角色" });
|
|
return;
|
|
}
|
|
}
|
|
// 密码长度校验 8~72 字符
|
|
if (form.password && (form.password.length < 8 || form.password.length > 72)) {
|
|
onNotify({ tone: "error", message: "密码长度必须在 8~72 字符之间" });
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
if (editing) {
|
|
const updated = await api.updatePlatformEmployee(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,
|
|
));
|
|
onNotify({ tone: "success", message: "用户信息已更新" });
|
|
} else {
|
|
const created = await api.createPlatformEmployee({
|
|
username: form.username.trim(),
|
|
display_name: form.display_name.trim(),
|
|
email: form.email.trim() || undefined,
|
|
password: form.password,
|
|
role_code: "developer",
|
|
});
|
|
setEmployees((current) => [...current, created]);
|
|
onNotify({ tone: "success", message: "用户已添加" });
|
|
}
|
|
setDialogOpen(false);
|
|
} catch (error) {
|
|
onNotify({
|
|
tone: "error",
|
|
message: error instanceof ApiRequestError ? error.message : "保存用户失败",
|
|
});
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const remove = async (employee: Employee): Promise<void> => {
|
|
if (!window.confirm(`确定从平台删除用户"${employee.display_name}"吗?`)) return;
|
|
try {
|
|
await api.deletePlatformEmployee(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 : "删除用户失败",
|
|
});
|
|
}
|
|
};
|
|
|
|
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={userSearchTerm}
|
|
onChange={(event) => setUserSearchTerm(event.target.value)}
|
|
/>
|
|
</div>
|
|
<button
|
|
className="primary-button admin-toolbar__button"
|
|
type="button"
|
|
disabled={!canManage}
|
|
onClick={openCreate}
|
|
>
|
|
<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>
|
|
{loading ? (
|
|
<p className="admin-empty">正在加载用户…</p>
|
|
) : (
|
|
employees
|
|
.filter((employee) => {
|
|
const term = userSearchTerm.toLowerCase().trim();
|
|
if (!term) return true;
|
|
return (
|
|
employee.display_name.toLowerCase().includes(term) ||
|
|
employee.username.toLowerCase().includes(term) ||
|
|
(employee.email && employee.email.toLowerCase().includes(term))
|
|
);
|
|
})
|
|
.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>
|
|
{employee.role_code ? (<span className={`role-pill is-${employee.role_code}`}>{employee.role_name}</span>) : (<span>-</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={!editing}
|
|
value={form.display_name}
|
|
onChange={(event) => setForm({ ...form, display_name: event.target.value })}
|
|
placeholder="请输入姓名"
|
|
/>
|
|
</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 })}
|
|
placeholder="请输入登录账号"
|
|
autoComplete="username"
|
|
/>
|
|
</label>
|
|
{!editing && (
|
|
<label className="form-field">
|
|
<span>密码<span className="required">*</span></span>
|
|
<input
|
|
type="password"
|
|
autoComplete="new-password"
|
|
value={form.password}
|
|
onChange={(event) => setForm({ ...form, password: event.target.value })}
|
|
placeholder="请输入密码(8~72 字符)"
|
|
/>
|
|
</label>
|
|
)}
|
|
<label className="form-field">
|
|
<span>邮箱</span>
|
|
<input
|
|
type="email"
|
|
autoComplete="email"
|
|
value={form.email}
|
|
onChange={(event) => setForm({ ...form, email: event.target.value })}
|
|
placeholder="请输入邮箱(可选)"
|
|
/>
|
|
</label>
|
|
{/* <label className="form-field">
|
|
<span>角色</span>
|
|
<select value={form.role_code} disabled={Boolean(editing) && ((editing as Employee).user_id === user?.user_id || (editing as Employee).role_code === "admin")} 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} disabled={Boolean(editing) && ((editing as Employee).user_id === user?.user_id || (editing as Employee).role_code === "admin")} 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>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|