213 lines
7.2 KiB
TypeScript
213 lines
7.2 KiB
TypeScript
|
|
"use client";
|
|||
|
|
|
|||
|
|
import { FormEvent, useCallback, useEffect, useState } from "react";
|
|||
|
|
import type { AuthUser, UserRole } from "../lib/user-auth";
|
|||
|
|
|
|||
|
|
type ManagedUser = {
|
|||
|
|
id: string;
|
|||
|
|
username: string;
|
|||
|
|
role: UserRole;
|
|||
|
|
created_at: string;
|
|||
|
|
updated_at: string;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const ROLE_LABEL: Record<UserRole, string> = {
|
|||
|
|
super_admin: "超级管理员",
|
|||
|
|
admin: "管理员",
|
|||
|
|
user: "普通用户",
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
async function userApi(response: Response) {
|
|||
|
|
const result = (await response.json()) as { users?: ManagedUser[]; error?: string };
|
|||
|
|
if (!response.ok) throw new Error(result.error || "操作失败");
|
|||
|
|
return result.users ?? [];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export default function UsersPage({ currentUser }: { currentUser: AuthUser }) {
|
|||
|
|
const [users, setUsers] = useState<ManagedUser[]>([]);
|
|||
|
|
const [username, setUsername] = useState("");
|
|||
|
|
const [password, setPassword] = useState("");
|
|||
|
|
const [role, setRole] = useState<"admin" | "user">("user");
|
|||
|
|
const [resetTarget, setResetTarget] = useState<ManagedUser | null>(null);
|
|||
|
|
const [resetPassword, setResetPassword] = useState("");
|
|||
|
|
const [message, setMessage] = useState("");
|
|||
|
|
const [working, setWorking] = useState(false);
|
|||
|
|
|
|||
|
|
const loadUsers = useCallback(async () => {
|
|||
|
|
try {
|
|||
|
|
setUsers(await userApi(await fetch("/api/users", { cache: "no-store" })));
|
|||
|
|
} catch (reason) {
|
|||
|
|
setMessage(reason instanceof Error ? reason.message : "加载失败");
|
|||
|
|
}
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
const timer = window.setTimeout(() => {
|
|||
|
|
void loadUsers();
|
|||
|
|
}, 0);
|
|||
|
|
return () => window.clearTimeout(timer);
|
|||
|
|
}, [loadUsers]);
|
|||
|
|
|
|||
|
|
const createUser = async (event: FormEvent) => {
|
|||
|
|
event.preventDefault();
|
|||
|
|
setWorking(true);
|
|||
|
|
setMessage("");
|
|||
|
|
try {
|
|||
|
|
const next = await userApi(
|
|||
|
|
await fetch("/api/users", {
|
|||
|
|
method: "POST",
|
|||
|
|
headers: { "Content-Type": "application/json" },
|
|||
|
|
body: JSON.stringify({ action: "create", username, password, role }),
|
|||
|
|
}),
|
|||
|
|
);
|
|||
|
|
setUsers(next);
|
|||
|
|
setUsername("");
|
|||
|
|
setPassword("");
|
|||
|
|
setRole("user");
|
|||
|
|
setMessage("账号已创建,可以把账号和密码交给用户登录");
|
|||
|
|
} catch (reason) {
|
|||
|
|
setMessage(reason instanceof Error ? reason.message : "创建失败");
|
|||
|
|
} finally {
|
|||
|
|
setWorking(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const resetUserPassword = async (event: FormEvent) => {
|
|||
|
|
event.preventDefault();
|
|||
|
|
if (!resetTarget) return;
|
|||
|
|
setWorking(true);
|
|||
|
|
setMessage("");
|
|||
|
|
try {
|
|||
|
|
const next = await userApi(
|
|||
|
|
await fetch("/api/users", {
|
|||
|
|
method: "POST",
|
|||
|
|
headers: { "Content-Type": "application/json" },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
action: "reset_password",
|
|||
|
|
userId: resetTarget.id,
|
|||
|
|
password: resetPassword,
|
|||
|
|
}),
|
|||
|
|
}),
|
|||
|
|
);
|
|||
|
|
setUsers(next);
|
|||
|
|
setResetTarget(null);
|
|||
|
|
setResetPassword("");
|
|||
|
|
setMessage("密码已重置,旧登录状态已退出");
|
|||
|
|
} catch (reason) {
|
|||
|
|
setMessage(reason instanceof Error ? reason.message : "重置失败");
|
|||
|
|
} finally {
|
|||
|
|
setWorking(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const canReset = (item: ManagedUser) => {
|
|||
|
|
if (currentUser.role === "super_admin") {
|
|||
|
|
return item.role !== "super_admin" || item.id === currentUser.id;
|
|||
|
|
}
|
|||
|
|
return item.role === "user";
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="user-management-layout">
|
|||
|
|
<section className="panel user-create-panel">
|
|||
|
|
<div className="panel-heading">
|
|||
|
|
<div>
|
|||
|
|
<h2>新增登录账号</h2>
|
|||
|
|
<p>账号密码由后台统一分配,不开放注册和个人改密。</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<form className="user-create-form" onSubmit={createUser}>
|
|||
|
|
<label>
|
|||
|
|
<span>登录账号</span>
|
|||
|
|
<input
|
|||
|
|
value={username}
|
|||
|
|
onChange={(event) => setUsername(event.target.value)}
|
|||
|
|
placeholder="例如:linlin01"
|
|||
|
|
autoComplete="off"
|
|||
|
|
required
|
|||
|
|
/>
|
|||
|
|
</label>
|
|||
|
|
<label>
|
|||
|
|
<span>初始密码</span>
|
|||
|
|
<input
|
|||
|
|
type="password"
|
|||
|
|
value={password}
|
|||
|
|
onChange={(event) => setPassword(event.target.value)}
|
|||
|
|
placeholder="至少8位"
|
|||
|
|
autoComplete="new-password"
|
|||
|
|
minLength={8}
|
|||
|
|
required
|
|||
|
|
/>
|
|||
|
|
</label>
|
|||
|
|
{currentUser.role === "super_admin" && (
|
|||
|
|
<label>
|
|||
|
|
<span>角色</span>
|
|||
|
|
<select value={role} onChange={(event) => setRole(event.target.value as "admin" | "user")}>
|
|||
|
|
<option value="user">普通用户</option>
|
|||
|
|
<option value="admin">管理员</option>
|
|||
|
|
</select>
|
|||
|
|
</label>
|
|||
|
|
)}
|
|||
|
|
<button className="primary-button" disabled={working}>
|
|||
|
|
{working ? "处理中…" : "创建账号"}
|
|||
|
|
</button>
|
|||
|
|
</form>
|
|||
|
|
{message && <p className="user-management-message">{message}</p>}
|
|||
|
|
</section>
|
|||
|
|
|
|||
|
|
<section className="panel user-list-panel">
|
|||
|
|
<div className="panel-heading">
|
|||
|
|
<div>
|
|||
|
|
<h2>账号列表</h2>
|
|||
|
|
<p>共 {users.length} 个后台账号</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div className="user-table">
|
|||
|
|
<div className="user-table-head">
|
|||
|
|
<span>登录账号</span><span>角色</span><span>更新时间</span><span>操作</span>
|
|||
|
|
</div>
|
|||
|
|
{users.map((item) => (
|
|||
|
|
<div className="user-table-row" key={item.id}>
|
|||
|
|
<strong>{item.username}</strong>
|
|||
|
|
<span className={`role-pill ${item.role}`}>{ROLE_LABEL[item.role]}</span>
|
|||
|
|
<span>{new Date(item.updated_at).toLocaleDateString("zh-CN", { timeZone: "Asia/Shanghai" })}</span>
|
|||
|
|
{canReset(item) ? (
|
|||
|
|
<button className="text-button" onClick={() => { setResetTarget(item); setResetPassword(""); }}>
|
|||
|
|
重置密码
|
|||
|
|
</button>
|
|||
|
|
) : <span>—</span>}
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</section>
|
|||
|
|
|
|||
|
|
{resetTarget && (
|
|||
|
|
<div className="modal-backdrop" role="presentation" onMouseDown={() => setResetTarget(null)}>
|
|||
|
|
<form className="modal-card compact" onSubmit={resetUserPassword} onMouseDown={(event) => event.stopPropagation()}>
|
|||
|
|
<div className="modal-heading">
|
|||
|
|
<div><p className="eyebrow">后台改密</p><h2>重置 {resetTarget.username} 的密码</h2></div>
|
|||
|
|
<button type="button" onClick={() => setResetTarget(null)} aria-label="关闭">×</button>
|
|||
|
|
</div>
|
|||
|
|
<label>
|
|||
|
|
<span>新密码</span>
|
|||
|
|
<input
|
|||
|
|
type="password"
|
|||
|
|
value={resetPassword}
|
|||
|
|
onChange={(event) => setResetPassword(event.target.value)}
|
|||
|
|
minLength={8}
|
|||
|
|
autoComplete="new-password"
|
|||
|
|
required
|
|||
|
|
/>
|
|||
|
|
<small>保存后,该账号已登录的设备会自动退出。</small>
|
|||
|
|
</label>
|
|||
|
|
<div className="modal-actions">
|
|||
|
|
<button type="button" className="ghost-button" onClick={() => setResetTarget(null)}>取消</button>
|
|||
|
|
<button className="primary-button" disabled={working}>确认重置</button>
|
|||
|
|
</div>
|
|||
|
|
</form>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|