Files
Cyrene_v2/frontend/web/src/admin/modules/Users.tsx
T
AskaEth 625ae33c1e fix(admin): 主人账号编辑器草稿改存无损形态 ——「+ 添加账号 ID」不再点了没点
根因:`emit` 把每次改动都写成 `flattenAccounts(groups)`(写盘形态,空行被丢掉),而
草稿本身就是界面手势的往返载体 ⇒ 刚点出来的占位空行当场蒸发,界面照旧(主人报的
「配置指定适配器主人账号的卡片内的添加账号ID按钮似乎没用」)。`+ 添加平台` 走同一条
通路(空平台组同样被丢),实测同样失效。

改法:分清两种扁平形态 ——
  · 草稿 `draftAccounts`(编辑期,无损:空行/未选平台的组都留着;不 trim 正在输入的字)
  · 写盘形态 `accountsForSave`(保存期,只留平台与账号 ID 都非空的条目)
后端契约与 `configs/users/owner.yaml` 格式一个字不改。保存路径写盘前必须收形:半成品
条目(有 id 没平台)会被后端 `Validate()` 直接 400(internal/user/load.go)。

测试:`OwnerAccountsView` 从编辑器里拆出来(无 hook),测试可直接调用它、点它自己的
按钮、把 onChange 收到的真草稿再喂回去重渲染 —— 走的是「手势 → 草稿 → 界面」真往返
(本项目没有 jsdom)。修前该测试红在「点『+ 添加账号 ID』⇒ 界面上真的多出一行 —
got 1, want 2」。
2026-10-13 20:00:43 +08:00

339 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import {
adminCreateUser,
adminGetOwnerProfile,
adminListChannels,
adminListUsers,
adminIssueApiKey,
adminRevokeApiKey,
adminSaveOwnerProfile,
fetchEnums,
getStoredRole,
} from "../../lib/api";
import type {
AdminChannelRecord,
AdminUserRecord,
EnumItem,
OwnerProfile,
} from "../../lib/types";
import { useAuditStore } from "../audit";
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
import { OwnerAccountsEditor } from "./OwnerAccountsEditor";
import { accountsForSave } from "./ownerAccounts";
import { Button, Surface, Tag } from "../../components/primitives";
const roleTone: Record<string, "accent" | "ok" | "neutral" | "warn"> = {
owner: "accent",
admin: "ok",
viewer: "neutral",
};
export function UsersModule(): React.ReactNode {
const isOwner = getStoredRole() === "owner";
const users = useAsyncData(adminListUsers, []);
const log = useAuditStore((s) => s.log);
const [busy, setBusy] = useState(false);
const [fail, setFail] = useState<string | null>(null);
const createUser = () => {
setBusy(true);
setFail(null);
adminCreateUser({ username: "new-user", role: "viewer" })
.then(() => {
log("user.create", "ok");
users.reload();
})
.catch((err: unknown) => {
log("user.create", "error");
setFail(err instanceof Error ? err.message : "新增用户失败");
})
.finally(() => setBusy(false));
};
const revokeKey = (id: string) => {
setBusy(true);
setFail(null);
adminRevokeApiKey(id)
.then(() => {
log("apikey.revoke", "ok");
users.reload();
})
.catch((err: unknown) => {
log("apikey.revoke", "error");
setFail(err instanceof Error ? err.message : "撤销密钥失败");
})
.finally(() => setBusy(false));
};
return (
<div className="space-y-5">
<PageHeader
title="用户与权限"
subtitle="GET /api/v1/admin/users · RBAC owner/admin/viewer · API key(一律超管)· 主人档案(白名单 + 身份说明书)"
actions={
<Button variant="soft" disabled={!isOwner || busy} onClick={createUser}>
新增用户
</Button>
}
/>
{fail ? <Notice tone="err">{fail}</Notice> : null}
<UsersTableSection
rows={users.data}
loading={users.loading}
error={users.error}
isOwner={isOwner}
busy={busy}
onRevoke={revokeKey}
/>
<OwnerProfileSection isOwner={isOwner} />
<ApiKeySection isOwner={isOwner} onIssued={users.reload} />
</div>
);
}
interface UsersTableSectionProps {
rows: AdminUserRecord[] | null;
loading: boolean;
error: string | null;
isOwner: boolean;
busy: boolean;
onRevoke: (id: string) => void;
}
/** 账号/RBAC 列表 + API key 行的真实撤销。 */
function UsersTableSection({
rows,
loading,
error,
isOwner,
busy,
onRevoke,
}: UsersTableSectionProps): React.ReactNode {
const columns: AdminColumn<AdminUserRecord>[] = [
{ key: "username", title: "用户", render: (u) => <span className="font-medium">{u.username}</span> },
{ key: "role", title: "角色", render: (u) => <Tag tone={roleTone[u.role] ?? "neutral"}>{u.role}</Tag> },
{ key: "created", title: "创建时间", render: (u) => <span className="text-xs text-[var(--mu-text-lo)]">{u.createdAt ?? "—"}</span> },
{ key: "id", title: "ID", render: (u) => <span className="font-mono text-xs text-[var(--mu-text-lo)]">{u.id}</span> },
{
key: "actions",
title: "操作",
render: (u) =>
u.username === "apikey" && isOwner ? (
<Button size="sm" variant="ghost" disabled={busy} onClick={() => onRevoke(u.id)}>
撤销
</Button>
) : (
<span className="text-xs text-[var(--mu-text-lo)]">—</span>
),
},
];
return (
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">用户列表 · 角色(RBAC)</h3>
<AdminTable
columns={columns}
rows={rows}
loading={loading}
error={error}
emptyTitle="无用户数据"
rowKey={(u) => u.id}
/>
</Surface>
);
}
/** 主人档案编辑块(裁决 10.4.5)。它自带读取/草稿/保存状态,因此面板的「保存」
* 是一次真实 PUT:成功回读服务端档案,失败如实报错 —— 绝不假装保存成功。 */
function OwnerProfileSection({ isOwner }: { isOwner: boolean }): React.ReactNode {
const { data, loading, error } = useAsyncData(adminGetOwnerProfile, []);
// 平台下拉取权威枚举(未接线/失败时回落本仓镜像的标签表,取值仍原样呈现);
// 账号 id 候选取该平台已配置的渠道账号,避免手打错一位就静默失效。
const enums = useAsyncData(fetchEnums, []);
const channels = useAsyncData(adminListChannels, []);
const log = useAuditStore((s) => s.log);
const [profile, setProfile] = useState<OwnerProfile | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [fail, setFail] = useState<string | null>(null);
// 后端是唯一事实源:每次成功读取都用服务端返回的档案覆盖草稿。
useEffect(() => {
if (data) setProfile(data);
}, [data]);
const save = () => {
if (!profile) return;
setBusy(true);
setFail(null);
setNotice(null);
// 草稿里允许有还没填完的空行(编辑期不能丢行),写盘前才收成写盘形态:只留平台与账号 ID
// 都非空的条目,并去掉两侧空白。既不把空行写进 owner.yaml,也不会因为半成品条目被后端 400。
adminSaveOwnerProfile({ ...profile, accounts: accountsForSave(profile.accounts) })
.then((saved) => {
log("owner.profile.edit", "ok");
setProfile(saved);
setNotice("已保存并热生效:configs/users/owner.yaml(0600 原子写),白名单立即参与 owner 判定。");
})
.catch((err: unknown) => {
log("owner.profile.edit", "error");
setFail(err instanceof Error ? err.message : "保存主人档案失败");
})
.finally(() => setBusy(false));
};
return (
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">主人档案({isOwner ? "可编辑" : "只读 · 需 owner"})</h3>
<p className="text-xs text-[var(--mu-text-lo)]">
对应 <span className="font-mono">configs/users/owner.yaml</span>:各渠道确认的主人账号白名单(策略引擎据此比对 actor 元数据,非提示词/LLM 判断)+ 关系备注。身份说明书(给 LLM 的人格知识,非权限依据)见 identityFile。
</p>
{error ? (
<Notice tone="err">
主人档案读取失败:{error}(未拿到真实档案,表单已禁用,不拿本地假数据冒充)
</Notice>
) : null}
{fail ? <Notice tone="err">{fail}</Notice> : null}
{notice ? <Notice tone="ok">{notice}</Notice> : null}
{profile ? (
<OwnerProfileForm
profile={profile}
disabled={!isOwner}
busy={busy}
platforms={enums.data?.platforms}
channels={channels.data}
onChange={setProfile}
onSave={save}
/>
) : (
<p className="text-xs text-[var(--mu-text-lo)]">{loading ? "读取中…" : "未加载到主人档案"}</p>
)}
</Surface>
);
}
interface OwnerProfileFormProps {
profile: OwnerProfile;
disabled: boolean;
busy: boolean;
platforms: EnumItem[] | undefined;
channels: AdminChannelRecord[] | null;
onChange: (next: OwnerProfile) => void;
onSave: () => void;
}
/** 档案表单本体(纯受控:所有改动都经 onChange 回到草稿)。 */
function OwnerProfileForm({
profile,
disabled,
busy,
platforms,
channels,
onChange,
onSave,
}: OwnerProfileFormProps): React.ReactNode {
const patch = (p: Partial<OwnerProfile>): void => onChange({ ...profile, ...p });
return (
<>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field label="id" value={profile.id} onChange={(v) => patch({ id: v })} disabled={disabled} />
<Field label="显示名" value={profile.displayName} onChange={(v) => patch({ displayName: v })} disabled={disabled} />
<Field label="默认人格" value={profile.defaultPersona} onChange={(v) => patch({ defaultPersona: v })} disabled={disabled} />
<Field label="身份说明书" value={profile.identityFile} onChange={(v) => patch({ identityFile: v })} disabled={disabled} />
</div>
<OwnerAccountsEditor
accounts={profile.accounts}
disabled={disabled}
platforms={platforms}
channels={channels}
onChange={(accounts) => patch({ accounts })}
/>
<div className="space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">关系备注</span>
<textarea
value={profile.notes}
onChange={(e) => patch({ notes: e.target.value })}
disabled={disabled}
rows={3}
className="w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)] disabled:opacity-60"
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="primary" disabled={disabled || busy} onClick={onSave}>
保存主人档案
</Button>
<span className="text-xs text-[var(--mu-text-lo)]">
保存走审计并真实落盘 owner.yaml(0600 原子写);后端热替换后白名单立即生效,无需重启。
</span>
</div>
</>
);
}
/** API key 签发块(裁决 15.4.3):不选角色 —— 所有 key 一律超管。 */
function ApiKeySection({ isOwner, onIssued }: { isOwner: boolean; onIssued: () => void }): React.ReactNode {
const log = useAuditStore((s) => s.log);
const [issuedKey, setIssuedKey] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [fail, setFail] = useState<string | null>(null);
const issue = () => {
setBusy(true);
setFail(null);
adminIssueApiKey()
.then((res) => {
log("apikey.generate", "ok");
setIssuedKey(res.key);
onIssued();
})
.catch((err: unknown) => {
log("apikey.generate", "error");
setFail(err instanceof Error ? err.message : "签发密钥失败");
})
.finally(() => setBusy(false));
};
return (
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">API Key({isOwner ? "可签发" : "只读 · 需 owner"})</h3>
{fail ? <Notice tone="err">{fail}</Notice> : null}
{issuedKey ? (
<div className="space-y-1">
<div className="rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 font-mono text-xs text-[var(--mu-ok)]">{issuedKey}</div>
<span className="text-xs text-[var(--mu-text-lo)]">密钥仅此一次可见,请立即复制保存;以 X-API-Key 头访问管理端点。</span>
</div>
) : null}
<div className="flex flex-wrap items-center gap-2">
<Button variant="soft" disabled={!isOwner || busy} onClick={issue}>
签发新密钥
</Button>
<span className="text-xs text-[var(--mu-text-lo)]">
所有 key 一律超级管理员(owner),不做角色分级;X-API-Key 头直接过 owner 门。行内「撤销」真实移除该密钥。
</span>
</div>
</Surface>
);
}
interface FieldProps {
label: string;
value: string;
onChange: (v: string) => void;
disabled?: boolean;
}
function Field({ label, value, onChange, disabled }: FieldProps): React.ReactNode {
return (
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">{label}</span>
<input
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
className="w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)] disabled:opacity-60"
/>
</label>
);
}