feat(frontend/admin): M10 后台管理面板(/admin hash 路由 + 15 大模块 + RBAC(owner/admin/viewer) + 审计 + Miuix);Docs/api-client.md v0.3 契约补全;tsc+vite build 通过
This commit is contained in:
1 parent
4c48e94659
commit
f43eb17030
26 files changed
+2253
-4
No files matched your search
@@ -0,0 +1,159 @@
|
||||
import { useState } from "react";
|
||||
import { useAsyncData } from "../../lib/useAsyncData";
|
||||
import { adminListUsers, getStoredRole } from "../../lib/api";
|
||||
import type { AdminUserRecord, OwnerProfile } from "../../lib/types";
|
||||
import { useAuditStore } from "../audit";
|
||||
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
|
||||
import { Button, Surface, Tag } from "../../components/primitives";
|
||||
|
||||
const roleTone: Record<string, "accent" | "ok" | "neutral" | "warn"> = {
|
||||
owner: "accent",
|
||||
admin: "ok",
|
||||
viewer: "neutral",
|
||||
};
|
||||
|
||||
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> },
|
||||
];
|
||||
|
||||
const initialProfile: OwnerProfile = {
|
||||
id: "owner",
|
||||
displayName: "叶酱",
|
||||
defaultPersona: "cyrene",
|
||||
identityFile: "configs/users/owner_identity.md",
|
||||
notes: "主人。关系:陪伴对象(昔涟称「开拓者」)。群聊/陌生人使用隔离上下文、记忆只读不写、不泄露主人敏感信息。",
|
||||
accounts: [],
|
||||
};
|
||||
|
||||
export function UsersModule(): React.ReactNode {
|
||||
const users = useAsyncData(adminListUsers, []);
|
||||
const isOwner = getStoredRole() === "owner";
|
||||
const log = useAuditStore((s) => s.log);
|
||||
|
||||
const [profile, setProfile] = useState<OwnerProfile>(initialProfile);
|
||||
const [sampleKey, setSampleKey] = useState<string | null>(null);
|
||||
|
||||
const saveProfile = () => {
|
||||
log("owner.profile.edit", "ok");
|
||||
setProfile({ ...profile });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title="用户与权限"
|
||||
subtitle="GET /api/v1/admin/users · RBAC owner/admin/viewer · API key · 主人档案(白名单 + 身份说明书)"
|
||||
actions={
|
||||
<Button
|
||||
variant="soft"
|
||||
onClick={() => {
|
||||
log("user.create", "ok");
|
||||
alert("新增用户 / 角色分配(占位):后端 M10+ 提供。");
|
||||
}}
|
||||
>
|
||||
新增用户
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Surface className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">用户列表 · 角色(RBAC)</h3>
|
||||
{users.error && !users.data ? (
|
||||
<div className="space-y-2">
|
||||
<Notice tone="warn">后端未提供 /api/v1/admin/users,以下为示例壳(M10 提供真实数据)。{users.error}</Notice>
|
||||
<AdminTable
|
||||
columns={columns}
|
||||
rows={[
|
||||
{ id: "1", username: "owner", role: "owner", createdAt: "2026-01-01" },
|
||||
{ id: "2", username: "admin", role: "admin", createdAt: "2026-01-01" },
|
||||
{ id: "3", username: "viewer", role: "viewer", createdAt: "2026-01-01" },
|
||||
]}
|
||||
loading={false}
|
||||
error={null}
|
||||
rowKey={(u) => u.id}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<AdminTable columns={columns} rows={users.data} loading={users.loading} error={users.error} emptyTitle="无用户数据" rowKey={(u) => u.id} />
|
||||
)}
|
||||
</Surface>
|
||||
|
||||
<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 的人格知识)见 identityFile。
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field label="id" value={profile.id} onChange={(v) => setProfile({ ...profile, id: v })} disabled={!isOwner} />
|
||||
<Field label="显示名" value={profile.displayName} onChange={(v) => setProfile({ ...profile, displayName: v })} disabled={!isOwner} />
|
||||
<Field label="默认人格" value={profile.defaultPersona} onChange={(v) => setProfile({ ...profile, defaultPersona: v })} disabled={!isOwner} />
|
||||
<Field label="身份说明书" value={profile.identityFile} onChange={(v) => setProfile({ ...profile, identityFile: v })} disabled={!isOwner} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">关系备注</span>
|
||||
<textarea
|
||||
value={profile.notes}
|
||||
onChange={(e) => setProfile({ ...profile, notes: e.target.value })}
|
||||
disabled={!isOwner}
|
||||
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 items-center gap-2">
|
||||
<Button variant="primary" disabled={!isOwner} onClick={saveProfile}>
|
||||
保存主人档案
|
||||
</Button>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">保存走审计;真实持久化 + 热重载需后端 M10+ 提供。</span>
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
<Surface className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">API Key({isOwner ? "可签发" : "只读 · 需 owner"})</h3>
|
||||
{sampleKey ? (
|
||||
<div className="rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 font-mono text-xs text-[var(--mu-ok)]">
|
||||
{sampleKey}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="soft"
|
||||
disabled={!isOwner}
|
||||
onClick={() => {
|
||||
log("apikey.generate", "ok");
|
||||
const raw = new Uint8Array(24);
|
||||
crypto.getRandomValues(raw);
|
||||
setSampleKey(Array.from(raw, (b) => b.toString(16).padStart(2, "0")).join(""));
|
||||
}}
|
||||
>
|
||||
签发新密钥(模拟)
|
||||
</Button>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">以 X-API-Key 头访问管理端点;真实签发/撤销需后端 M10+。</span>
|
||||
</div>
|
||||
</Surface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user