Complete platform management workflows

This commit is contained in:
npc0-hue
2026-07-14 16:39:37 +08:00
parent 7e05d0a4e7
commit 4f33f761a3
106 changed files with 11313 additions and 460 deletions
+312 -74
View File
@@ -1,13 +1,16 @@
import { HeartHandshake, Sparkles, UserRoundCheck, UserRoundPlus } from "lucide-react";
import { HeartHandshake, Sparkles, UserPen, 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 { ConfirmDialog, ManagementDialog } from "../components/OperationControls";
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 { type UserEditFormState, type UserListSource, type UserRemovalConfirmationState, userEditFormFromResponse } from "../contracts/users";
import { isPlatformAdmin } from "../contracts/workspace";
import { userCreateRequestFromDraft, userDeactivateRequest, userUpdateRequestFromEditForm } from "../schemas/users";
import { cx } from "../utils/classes";
const roleOptions = [
@@ -37,12 +40,29 @@ const fallbackUsers: UserResponse[] = userAccess.map((user, index) => ({
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("");
interface UsersPageInitialState {
users?: UserResponse[];
loading?: boolean;
source?: UserListSource;
loadError?: string;
}
interface UsersPageProps extends PageComponentProps {
initialState?: UsersPageInitialState;
}
export function UsersPage({ session, operations, initialState }: UsersPageProps) {
const [users, setUsers] = useState<UserResponse[]>(initialState?.users ?? []);
const [loading, setLoading] = useState(initialState?.loading ?? true);
const [source, setSource] = useState<UserListSource>(initialState?.source ?? "api");
const [loadError, setLoadError] = useState(initialState?.loadError ?? "");
const [result, setResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string }>();
const [createOpen, setCreateOpen] = useState(false);
const [editingUserId, setEditingUserId] = useState<string | null>(null);
const [editDraft, setEditDraft] = useState<UserEditFormState | null>(null);
const [confirmRemoval, setConfirmRemoval] = useState<UserRemovalConfirmationState | null>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
const [statusDrafts, setStatusDrafts] = useState<Record<string, UserStatus>>({});
const [draft, setDraft] = useState<UserCreateRequest>({
displayName: "",
email: "",
@@ -53,6 +73,15 @@ export function UsersPage({ session, operations }: PageComponentProps) {
useEffect(() => {
let cancelled = false;
if (initialState?.users) {
setUsers(initialState.users);
setLoading(initialState.loading ?? false);
setSource(initialState.source ?? "api");
setLoadError(initialState.loadError ?? "");
return () => {
cancelled = true;
};
}
void platformApiClient
.listUsers()
.then((response) => {
@@ -64,9 +93,9 @@ export function UsersPage({ session, operations }: PageComponentProps) {
})
.catch(() => {
if (!cancelled) {
setUsers(fallbackUsers);
setSource("local");
setLoadError("账号 API 加载失败,当前显示本地样例;创建与状态更新仍会尝试平台 API。");
setUsers(import.meta.env.DEV ? fallbackUsers : []);
setSource(import.meta.env.DEV ? "local-development" : "api");
setLoadError(import.meta.env.DEV ? "账号 API 加载失败,当前显示本地开发样例;状态变更会被平台 API 拒绝或写入。" : "账号 API 加载失败,未显示本地样例数据。");
}
})
.finally(() => {
@@ -77,7 +106,11 @@ export function UsersPage({ session, operations }: PageComponentProps) {
return () => {
cancelled = true;
};
}, []);
}, [initialState]);
useEffect(() => {
setStatusDrafts(Object.fromEntries(users.map((user) => [user.id, user.status])));
}, [users]);
const counts = useMemo(
() => ({
@@ -87,34 +120,96 @@ export function UsersPage({ session, operations }: PageComponentProps) {
}),
[users]
);
const persistenceDisabled = source === "local-development";
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}` });
const request = userCreateRequestFromDraft(draft);
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}`);
operations.succeed(operationId, `用户邀请已创建:${created.id}`);
setSource("api");
setLoadError("");
setResult({ status: "succeeded", label: `创建 ${created.displayName}` });
setResult({ status: "succeeded", label: `邀请 ${created.displayName}` });
setCreateOpen(false);
setDraft({ displayName: "", email: "", roles: ["server-admin"], status: "pending", profile: { phone: "", qq: "", contactNote: "" } });
} 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: "" } });
}
function startEdit(user: UserResponse) {
setEditingUserId(user.id);
setEditDraft(userEditFormFromResponse(user));
setResult(undefined);
}
function closeEdit() {
setEditingUserId(null);
setEditDraft(null);
}
function updateEdit<K extends keyof UserEditFormState>(key: K, value: UserEditFormState[K]) {
setEditDraft((current) => (current ? { ...current, [key]: value } : current));
}
function toggleEditRole(role: string) {
setEditDraft((current) => {
if (!current) {
return current;
}
const roles = current.roles.includes(role) ? current.roles.filter((item) => item !== role) : [...current.roles, role];
return { ...current, roles };
});
}
async function saveUserEdit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!editingUserId || !editDraft) {
return;
}
const operationId = operations.begin({ intent: "编辑用户", targetKind: "platform", targetId: editingUserId, requester: session.displayName });
setResult({ status: "pending", label: "正在保存用户" });
try {
const updated = await platformApiClient.updateUser(editingUserId, userUpdateRequestFromEditForm(editDraft));
setUsers((current) => current.map((item) => (item.id === updated.id ? updated : item)));
setEditingUserId(null);
setEditDraft(null);
setSource("api");
setLoadError("");
operations.succeed(operationId, `用户已更新:${updated.id}`);
setResult({ status: "succeeded", label: `已更新 ${updated.displayName}` });
} catch (error) {
operations.fail(operationId, error instanceof Error ? error.message : "用户更新失败");
setResult({ status: "failed", label: error instanceof Error ? error.message : "用户更新失败,未写入数据库" });
}
}
async function deactivateUser() {
if (!confirmRemoval) {
return;
}
setConfirmBusy(true);
const user = confirmRemoval.user;
const operationId = operations.begin({ intent: "停用用户", targetKind: "platform", targetId: user.id, requester: session.displayName });
try {
const updated = await platformApiClient.updateUser(user.id, userDeactivateRequest());
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} 已停用` });
setConfirmRemoval(null);
} catch (error) {
operations.fail(operationId, error instanceof Error ? error.message : "用户停用失败");
setResult({ status: "failed", label: error instanceof Error ? error.message : "停用失败,平台拒绝变更" });
} finally {
setConfirmBusy(false);
}
}
async function setUserStatus(user: UserResponse, status: UserStatus) {
@@ -140,6 +235,18 @@ export function UsersPage({ session, operations }: PageComponentProps) {
});
}
function applyUserStatus(user: UserResponse) {
const nextStatus = statusDrafts[user.id] ?? user.status;
if (nextStatus === user.status) {
return;
}
if (nextStatus === "disabled") {
setConfirmRemoval({ action: "deactivate", user });
return;
}
void setUserStatus(user, nextStatus);
}
if (!isPlatformAdmin(session)) {
return (
<div className="console-page">
@@ -160,7 +267,7 @@ export function UsersPage({ session, operations }: PageComponentProps) {
<PageFrame
kicker="身份"
title="用户管理"
status={source === "api" ? "账号 API 已连接" : "本地回退"}
status={source === "api" ? "账号 API 已连接" : "本地开发数据"}
metrics={[
{ label: "用户", value: `${users.length}`, tone: "success" },
{ label: "角色", value: `${counts.roleCount}`, tone: "neutral" },
@@ -173,10 +280,75 @@ export function UsersPage({ session, operations }: PageComponentProps) {
<section className="console-panel">
<div className="panel-header">
<h2></h2>
<h2>访</h2>
{loading ? <ResultBadge status="pending" label="加载用户…" /> : result && <ResultBadge status={result.status} label={result.label} />}
<span className="page-status">{source === "api" ? "平台数据" : "本地开发样例 / 禁止假成功"}</span>
<button type="button" className="primary-command" disabled={persistenceDisabled} onClick={() => setCreateOpen(true)}>
<UserRoundPlus size={14} />
<span>{persistenceDisabled ? "等待 API" : "邀请用户"}</span>
</button>
</div>
<form className="management-form" onSubmit={createUser}>
<AccessFlowGuide />
<RoleImpactGuide />
{users.length === 0 ? (
<EmptyState title="暂无用户" description="平台暂未返回可管理账号。邀请用户后会在这里显示 API 连接结果。" actionLabel="邀请用户" onAction={() => setCreateOpen(true)} />
) : (
<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} 状态操作`}>
<button type="button" className="theme-upload" disabled={persistenceDisabled} aria-label={`编辑 ${user.displayName}`} onClick={() => startEdit(user)}>
<UserPen size={13} />
</button>
<label className="status-update-control">
<span></span>
<select
value={statusDrafts[user.id] ?? user.status}
disabled={persistenceDisabled}
aria-label={`选择 ${user.displayName} 状态`}
onChange={(event) => setStatusDrafts((current) => ({ ...current, [user.id]: event.target.value as UserStatus }))}
>
{statusOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<button
type="button"
className="theme-upload"
disabled={persistenceDisabled || (statusDrafts[user.id] ?? user.status) === user.status}
aria-label={`应用 ${user.displayName} 状态变更`}
onClick={() => applyUserStatus(user)}
>
<UserRoundCheck size={13} />
</button>
</div>
</article>
))}
</div>
)}
</section>
<ManagementDialog open={createOpen} title="邀请用户" wide onClose={() => setCreateOpen(false)}>
<form className="management-form dialog-form" onSubmit={createUser}>
<div className="form-guidance management-form-wide">
<strong></strong>
<span></span>
</div>
<label>
<span></span>
<input value={draft.displayName} required onChange={(event) => setDraft((current) => ({ ...current, displayName: event.target.value }))} />
@@ -226,53 +398,119 @@ export function UsersPage({ session, operations }: PageComponentProps) {
</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>
))}
<RoleImpactGuide />
<div className="confirm-actions">
<button type="button" onClick={() => setCreateOpen(false)}>
</button>
<button type="submit" className="confirm-primary" disabled={persistenceDisabled}>
<UserRoundPlus size={14} />
<span>{persistenceDisabled ? "等待 API" : "发送邀请"}</span>
</button>
</div>
</form>
</ManagementDialog>
<ManagementDialog open={editDraft !== null} title="编辑用户" wide onClose={closeEdit}>
{editDraft && (
<form className="management-form dialog-form" onSubmit={(event) => void saveUserEdit(event)}>
<label>
<span></span>
<input value={editDraft.displayName} required onChange={(event) => updateEdit("displayName", event.target.value)} />
</label>
<label>
<span></span>
<input value={editDraft.email} type="email" onChange={(event) => updateEdit("email", event.target.value)} />
</label>
<label>
<span></span>
<input value={editDraft.phone} inputMode="tel" onChange={(event) => updateEdit("phone", event.target.value)} />
</label>
<label>
<span>QQ</span>
<input value={editDraft.qq} inputMode="numeric" onChange={(event) => updateEdit("qq", event.target.value)} />
</label>
<label>
<span></span>
<input value={editDraft.contactNote} onChange={(event) => updateEdit("contactNote", event.target.value)} />
</label>
<label>
<span></span>
<select value={editDraft.status} onChange={(event) => updateEdit("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", editDraft.roles.includes(role.value) && "role-chip-active")} onClick={() => toggleEditRole(role.value)}>
<Sparkles size={13} />
<span>{role.label}</span>
</button>
))}
</div>
<RoleImpactGuide />
<div className="confirm-actions">
<button type="button" onClick={closeEdit}>
</button>
<button type="submit" className="confirm-primary">
<UserPen size={14} />
<span></span>
</button>
</div>
</form>
)}
</section>
</ManagementDialog>
<ConfirmDialog
open={confirmRemoval !== null}
title="停用用户"
description={`确认停用 ${confirmRemoval?.user.displayName ?? ""}?该账号将无法登录,已有审计记录会保留。`}
confirmLabel="确认停用"
danger
busy={confirmBusy}
onCancel={() => {
if (confirmRemoval) {
setStatusDrafts((current) => ({ ...current, [confirmRemoval.user.id]: confirmRemoval.user.status }));
}
setConfirmRemoval(null);
}}
onConfirm={() => void deactivateUser()}
/>
</div>
);
}
function AccessFlowGuide() {
return (
<div className="workflow-hint-grid" aria-label="用户管理流程提示">
<div className="workflow-hint-card">
<strong></strong>
<span></span>
</div>
<div className="workflow-hint-card">
<strong></strong>
<span></span>
</div>
<div className="workflow-hint-card">
<strong></strong>
<span>/</span>
</div>
</div>
);
}
function RoleImpactGuide() {
return (
<div className="role-impact-list management-form-wide" aria-label="角色影响说明">
<strong></strong>
<span>AI </span>
<span></span>
<span></span>
</div>
);
}