Files
browser/platform_web/pages/UsersPage.tsx
T
2026-07-11 14:56:10 +08:00

297 lines
12 KiB
TypeScript

import { HeartHandshake, Sparkles, UserRoundCheck, UserRoundPlus } from "lucide-react";
import { type FormEvent, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type { UserCreateRequest, UserResponse, UserStatus } from "../api/types";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import { PageFrame } from "../components/PageFrame";
import type { PageComponentProps } from "../contracts/page";
import { userAccess } from "../contracts/shell";
import { isPlatformAdmin } from "../contracts/workspace";
import { cx } from "../utils/classes";
const roleOptions = [
{ value: "platform-admin", label: "平台管理员" },
{ value: "server-owner", label: "服主" },
{ value: "server-admin", label: "服务器管理员" }
];
const statusOptions: Array<{ value: UserStatus; label: string }> = [
{ value: "active", label: "启用" },
{ value: "pending", label: "待审核" },
{ value: "disabled", label: "停用" }
];
const fallbackUsers: UserResponse[] = userAccess.map((user, index) => ({
id: user.id,
displayName: user.displayName,
email: `${user.id}@local.example.test`,
status: user.status,
roles: user.roles,
profile: {
phone: index === 0 ? "13800000000" : "",
qq: index === 0 ? "10001" : "",
contactNote: user.review
},
createdAt: "2026-07-03T00:00:00Z",
updatedAt: "2026-07-03T00:00:00Z"
}));
export function UsersPage({ session, operations }: PageComponentProps) {
const [users, setUsers] = useState<UserResponse[]>(fallbackUsers);
const [loading, setLoading] = useState(true);
const [source, setSource] = useState<"api" | "local">("local");
const [loadError, setLoadError] = useState("");
const [result, setResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string }>();
const [draft, setDraft] = useState<UserCreateRequest>({
displayName: "",
email: "",
roles: ["server-admin"],
status: "pending",
profile: { phone: "", qq: "", contactNote: "" }
});
useEffect(() => {
let cancelled = false;
void platformApiClient
.listUsers()
.then((response) => {
if (!cancelled) {
setUsers(response.items);
setSource("api");
setLoadError("");
}
})
.catch(() => {
if (!cancelled) {
setUsers(fallbackUsers);
setSource("local");
setLoadError("账号 API 加载失败,当前显示本地样例;创建与状态更新仍会尝试平台 API。");
}
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, []);
const counts = useMemo(
() => ({
active: users.filter((user) => user.status === "active").length,
pending: users.filter((user) => user.status === "pending").length,
roleCount: new Set(users.flatMap((user) => user.roles)).size
}),
[users]
);
async function createUser(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const request: UserCreateRequest = {
...draft,
displayName: draft.displayName.trim(),
email: draft.email?.trim(),
roles: draft.roles.length ? draft.roles : ["server-admin"],
profile: {
phone: draft.profile?.phone?.trim(),
qq: draft.profile?.qq?.trim(),
contactNote: draft.profile?.contactNote?.trim()
}
};
const operationId = operations.begin({ intent: "创建用户", targetKind: "platform", targetId: "users", requester: session.displayName });
setResult({ status: "pending", label: `正在创建用户 ${operationId}` });
try {
const created = await platformApiClient.createUser(request);
setUsers((current) => [created, ...current.filter((user) => user.id !== created.id)]);
operations.succeed(operationId, `用户已创建:${created.id}`);
setSource("api");
setLoadError("");
setResult({ status: "succeeded", label: `已创建 ${created.displayName}` });
} catch (error) {
operations.fail(operationId, error instanceof Error ? error.message : "用户创建失败");
setResult({ status: "failed", label: "用户创建失败,未写入数据库" });
}
setDraft({ displayName: "", email: "", roles: ["server-admin"], status: "pending", profile: { phone: "", qq: "", contactNote: "" } });
}
async function setUserStatus(user: UserResponse, status: UserStatus) {
const operationId = operations.begin({ intent: "更新用户状态", targetKind: "platform", targetId: user.id, requester: session.displayName });
setResult({ status: "pending", label: `正在更新 ${user.displayName}` });
try {
const updated = await platformApiClient.updateUser(user.id, { status });
setUsers((current) => current.map((item) => (item.id === updated.id ? updated : item)));
operations.succeed(operationId, `用户状态已更新:${updated.id}`);
setSource("api");
setLoadError("");
setResult({ status: "succeeded", label: `${updated.displayName}${statusLabel(updated.status)}` });
} catch (error) {
operations.fail(operationId, error instanceof Error ? error.message : "用户状态更新失败");
setResult({ status: "failed", label: "状态更新失败,未写入数据库" });
}
}
function toggleRole(role: string) {
setDraft((current) => {
const nextRoles = current.roles.includes(role) ? current.roles.filter((item) => item !== role) : [...current.roles, role];
return { ...current, roles: nextRoles };
});
}
if (!isPlatformAdmin(session)) {
return (
<div className="console-page">
<PageFrame kicker="身份" title="用户管理" status="无访问权限" metrics={[]} />
<section className="console-panel">
<div className="state-view state-error" role="alert">
<HeartHandshake size={24} />
<strong>当前账号不能管理用户</strong>
<p>服务器用户不会看到用户管理导航;直接访问该页时应返回角色默认工作区。</p>
</div>
</section>
</div>
);
}
return (
<div className="console-page">
<PageFrame
kicker="身份"
title="用户管理"
status={source === "api" ? "账号 API 已连接" : "本地回退"}
metrics={[
{ label: "用户", value: `${users.length}`, tone: "success" },
{ label: "角色", value: `${counts.roleCount}`, tone: "neutral" },
{ label: "待审核", value: `${counts.pending}`, tone: "warning" }
]}
/>
{loading && <LoadingState label="正在加载用户列表…" />}
{loadError && <ErrorState title="用户 API 暂不可用" reason={loadError} diagnosticId="user-management:fallback" compact />}
<section className="console-panel">
<div className="panel-header">
<h2>创建用户</h2>
{loading ? <ResultBadge status="pending" label="加载用户…" /> : result && <ResultBadge status={result.status} label={result.label} />}
</div>
<form className="management-form" onSubmit={createUser}>
<label>
<span>显示名称</span>
<input value={draft.displayName} required onChange={(event) => setDraft((current) => ({ ...current, displayName: event.target.value }))} />
</label>
<label>
<span>邮箱</span>
<input value={draft.email} type="email" onChange={(event) => setDraft((current) => ({ ...current, email: event.target.value }))} />
</label>
<label>
<span>手机号</span>
<input
value={draft.profile?.phone ?? ""}
inputMode="tel"
onChange={(event) => setDraft((current) => ({ ...current, profile: { ...current.profile, phone: event.target.value } }))}
/>
</label>
<label>
<span>QQ</span>
<input
value={draft.profile?.qq ?? ""}
inputMode="numeric"
onChange={(event) => setDraft((current) => ({ ...current, profile: { ...current.profile, qq: event.target.value } }))}
/>
</label>
<label>
<span>备注</span>
<input
value={draft.profile?.contactNote ?? ""}
onChange={(event) => setDraft((current) => ({ ...current, profile: { ...current.profile, contactNote: event.target.value } }))}
/>
</label>
<label>
<span>状态</span>
<select value={draft.status} onChange={(event) => setDraft((current) => ({ ...current, status: event.target.value as UserStatus }))}>
{statusOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<div className="role-selector" aria-label="角色">
{roleOptions.map((role) => (
<button key={role.value} type="button" className={cx("role-chip", draft.roles.includes(role.value) && "role-chip-active")} onClick={() => toggleRole(role.value)}>
<Sparkles size={13} />
<span>{role.label}</span>
</button>
))}
</div>
<button type="submit" className="profile-save-button">
<UserRoundPlus size={14} />
<span>创建用户</span>
</button>
</form>
</section>
<section className="console-panel">
<div className="panel-header">
<h2>访问列表</h2>
<span className="page-status">{source === "api" ? "平台数据" : "本地样例 / 待同步"}</span>
</div>
{users.length === 0 ? (
<EmptyState title="暂无用户" description="平台暂未返回可管理账号。创建用户后会在这里显示 API 连接结果。" />
) : (
<div className="resource-list user-management-list">
{users.map((user) => (
<article key={user.id} className="resource-list-item user-management-item">
<div>
<strong>{user.displayName}</strong>
<span className="provider-id">{user.email ?? user.id}</span>
</div>
<span className={cx("status-pill", statusClass(user.status))}>
<UserRoundCheck size={13} />
{statusLabel(user.status)}
</span>
<span>{user.roles.map(roleLabel).join(" / ")}</span>
<span>{profileSummary(user)}</span>
<div className="user-actions" aria-label={`${user.displayName} 状态操作`}>
{statusOptions.map((option) => (
<button
key={option.value}
type="button"
className="theme-upload"
disabled={user.status === option.value}
aria-label={`将 ${user.displayName} 设为${option.label}`}
onClick={() => void setUserStatus(user, option.value)}
>
{option.label}
</button>
))}
</div>
</article>
))}
</div>
)}
</section>
</div>
);
}
function statusLabel(status: UserStatus): string {
return status === "active" ? "启用" : status === "pending" ? "待审核" : "停用";
}
function statusClass(status: UserStatus): string {
return status === "active" ? "status-active" : status === "pending" ? "status-pending" : "status-disabled";
}
function roleLabel(role: string): string {
const matched = roleOptions.find((option) => option.value === role || option.value.replace("-", "") === role.toLowerCase());
return matched?.label ?? role;
}
function profileSummary(user: UserResponse): string {
const parts = [user.profile?.phone, user.profile?.qq ? `QQ ${user.profile.qq}` : undefined, user.profile?.contactNote].filter(Boolean);
return parts.length > 0 ? parts.join(" · ") : "暂无联系方式";
}