Files
easyai-ai-gateway/apps/web/src/pages/admin/IdentityManagementPanels.tsx
T

950 lines
39 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 { useMemo, useState, type FormEvent, type ReactNode } from 'react';
import { Building2, CircleDollarSign, KeyRound, Pencil, Plus, RotateCcw, Trash2, UserRound, UsersRound } from 'lucide-react';
import type {
GatewayTenant,
GatewayTenantUpsertRequest,
GatewayUser,
GatewayUserUpsertRequest,
GatewayWalletAccount,
UserGroup,
UserGroupUpsertRequest,
WalletRechargeRequest,
} from '@easyai-ai-gateway/contracts';
import {
Badge,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
ConfirmDialog,
FormDialog,
Input,
Label,
ScreenMessage,
Select,
Table,
TableCell,
TableHead,
TableRow,
Textarea,
} from '../../components/ui';
import type { ConsoleData } from '../../app-state';
import type { LoadState } from '../../types';
type TenantForm = {
tenantKey: string;
source: string;
externalTenantId: string;
name: string;
description: string;
defaultUserGroupId: string;
planKey: string;
billingProfileJson: string;
rateLimitPolicyJson: string;
authPolicyJson: string;
metadataJson: string;
status: string;
};
type UserForm = {
userKey: string;
source: string;
externalUserId: string;
username: string;
displayName: string;
email: string;
phone: string;
avatarUrl: string;
password: string;
gatewayTenantId: string;
tenantId: string;
tenantKey: string;
defaultUserGroupId: string;
roles: string;
authProfileJson: string;
metadataJson: string;
status: string;
};
type WalletForm = {
currency: string;
amount: string;
reason: string;
};
type UserGroupForm = {
groupKey: string;
name: string;
description: string;
source: string;
priority: string;
rechargeDiscountFactor: string;
rechargeDiscountPolicy: Record<string, unknown>;
billingDiscountFactor: string;
billingDiscountPolicy: Record<string, unknown>;
rateLimitPolicyJson: string;
quotaPolicyJson: string;
metadataJson: string;
status: string;
};
const tenantStatuses = ['active', 'disabled', 'locked'];
const userStatuses = ['active', 'disabled', 'locked'];
const userGroupStatuses = ['active', 'disabled'];
const sourceOptions = ['gateway', 'server-main', 'sync'];
const roleOptions = [
{ label: '普通用户', value: 'user' },
{ label: '创作者', value: 'creator' },
{ label: '管理', value: 'operator' },
{ label: '超级管理员', value: 'manager' },
{ label: '管理员', value: 'admin' },
];
export function TenantsPanel(props: IdentityPanelProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState('');
const [form, setForm] = useState<TenantForm>(() => defaultTenantForm());
const [localError, setLocalError] = useState('');
const [pendingDeleteTenant, setPendingDeleteTenant] = useState<GatewayTenant | null>(null);
function openCreateDialog() {
setEditingId('');
setLocalError('');
setForm(defaultTenantForm());
setDialogOpen(true);
}
function editTenant(tenant: GatewayTenant) {
setEditingId(tenant.id);
setLocalError('');
setForm(tenantToForm(tenant));
setDialogOpen(true);
}
function closeDialog() {
setDialogOpen(false);
setEditingId('');
setLocalError('');
}
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLocalError('');
try {
await props.onSaveTenant(formToTenantPayload(form), editingId || undefined);
closeDialog();
} catch (err) {
setLocalError(err instanceof Error ? err.message : '保存租户失败');
}
}
async function deleteTenant(tenant: GatewayTenant) {
try {
await props.onDeleteTenant(tenant.id);
setPendingDeleteTenant(null);
} catch (err) {
setLocalError(err instanceof Error ? err.message : '删除租户失败');
}
}
return (
<div className="pageStack">
<ScreenMessage message={identityLocalErrorMessage(localError, props)} variant="error" duration={0} onClose={() => setLocalError('')} />
<IdentityHeader
count={props.data.tenants.length}
description="租户可独立维护,也可承载 server-main 同步过来的租户标识。"
icon={<Building2 size={17} />}
message={identityHeaderMessage(props)}
title="租户管理"
actionLabel="新增租户"
onCreate={openCreateDialog}
/>
{props.data.tenants.length ? (
<Table className="identityDataTable tenantTable">
<TableRow>
<TableHead>租户</TableHead>
<TableHead>来源</TableHead>
<TableHead>默认用户组</TableHead>
<TableHead>套餐</TableHead>
<TableHead>状态</TableHead>
<TableHead>操作</TableHead>
</TableRow>
{props.data.tenants.map((tenant) => (
<TableRow key={tenant.id}>
<TableCell><IdentityName title={tenant.name} subtitle={tenant.tenantKey} /></TableCell>
<TableCell>{tenant.source}</TableCell>
<TableCell>{groupName(props.data.userGroups, tenant.defaultUserGroupId)}</TableCell>
<TableCell>{tenant.planKey || '未设置'}</TableCell>
<TableCell><Badge variant={tenant.status === 'active' ? 'success' : 'secondary'}>{tenant.status}</Badge></TableCell>
<TableCell><TableActions onEdit={() => editTenant(tenant)} onDelete={() => setPendingDeleteTenant(tenant)} /></TableCell>
</TableRow>
))}
</Table>
) : (
<EmptyIdentity title="暂无租户" description="点击新增租户建立本地租户闭环。" />
)}
<FormDialog
ariaLabel={editingId ? '编辑租户' : '新增租户'}
className="identityDialog"
eyebrow={editingId ? 'Edit Tenant' : 'New Tenant'}
footer={<DialogFooter editing={Boolean(editingId)} loading={props.state === 'loading'} onCancel={closeDialog} />}
open={dialogOpen}
title={editingId ? '编辑租户' : '新增租户'}
onClose={closeDialog}
onSubmit={submit}
>
<Label>租户 Key<Input size="sm" value={form.tenantKey} onChange={(event) => setForm({ ...form, tenantKey: event.target.value })} placeholder="default" /></Label>
<Label>租户名称<Input size="sm" value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder="默认租户" /></Label>
<Label>来源<Select size="sm" value={form.source} onChange={(event) => setForm({ ...form, source: event.target.value })}>{sourceOptions.map(option)}</Select></Label>
<Label>状态<Select size="sm" value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })}>{tenantStatuses.map(option)}</Select></Label>
<Label>外部租户 ID<Input size="sm" value={form.externalTenantId} onChange={(event) => setForm({ ...form, externalTenantId: event.target.value })} placeholder="server-main tenant id" /></Label>
<Label>套餐 Key<Input size="sm" value={form.planKey} onChange={(event) => setForm({ ...form, planKey: event.target.value })} placeholder="free / pro" /></Label>
<Label className="spanTwo">默认用户组<Select size="sm" value={form.defaultUserGroupId} onChange={(event) => setForm({ ...form, defaultUserGroupId: event.target.value })}><option value="">未设置</option>{props.data.userGroups.map((group) => <option value={group.id} key={group.id}>{group.name}</option>)}</Select></Label>
<Label className="spanTwo">描述<Input size="sm" value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} placeholder="内部备注" /></Label>
<JsonField label="计费资料 JSON" value={form.billingProfileJson} onChange={(value) => setForm({ ...form, billingProfileJson: value })} />
<JsonField label="限流策略 JSON" value={form.rateLimitPolicyJson} onChange={(value) => setForm({ ...form, rateLimitPolicyJson: value })} />
<JsonField label="授权策略 JSON" value={form.authPolicyJson} onChange={(value) => setForm({ ...form, authPolicyJson: value })} />
<JsonField label="元数据 JSON" value={form.metadataJson} onChange={(value) => setForm({ ...form, metadataJson: value })} />
</FormDialog>
<ConfirmDialog
confirmLabel="删除租户"
description="租户会被软删除,关联用户不会被自动删除。"
loading={props.state === 'loading'}
open={Boolean(pendingDeleteTenant)}
title={`确认删除租户 ${pendingDeleteTenant?.name ?? ''}`}
onCancel={() => setPendingDeleteTenant(null)}
onConfirm={() => pendingDeleteTenant ? deleteTenant(pendingDeleteTenant) : undefined}
/>
</div>
);
}
export function UsersPanel(props: IdentityPanelProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState('');
const [form, setForm] = useState<UserForm>(() => defaultUserForm(props.data.tenants[0]));
const [localError, setLocalError] = useState('');
const [pendingDeleteUser, setPendingDeleteUser] = useState<GatewayUser | null>(null);
const [walletUser, setWalletUser] = useState<GatewayUser | null>(null);
const [walletForm, setWalletForm] = useState<WalletForm>(() => defaultWalletForm());
const tenantById = useMemo(() => new Map(props.data.tenants.map((tenant) => [tenant.id, tenant])), [props.data.tenants]);
function openCreateDialog() {
setEditingId('');
setLocalError('');
setForm(defaultUserForm(props.data.tenants[0]));
setDialogOpen(true);
}
function editUser(user: GatewayUser) {
setEditingId(user.id);
setLocalError('');
setForm(userToForm(user));
setDialogOpen(true);
}
function closeDialog() {
setDialogOpen(false);
setEditingId('');
setLocalError('');
}
function openWalletDialog(user: GatewayUser) {
const wallet = primaryWallet(user);
setLocalError('');
setWalletUser(user);
setWalletForm({
currency: wallet?.currency ?? 'resource',
amount: '',
reason: defaultRechargeReason,
});
}
function closeWalletDialog() {
setWalletUser(null);
setWalletForm(defaultWalletForm());
}
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLocalError('');
try {
await props.onSaveUser(formToUserPayload(form), editingId || undefined);
closeDialog();
} catch (err) {
setLocalError(err instanceof Error ? err.message : '保存用户失败');
}
}
async function deleteUser(user: GatewayUser) {
try {
await props.onDeleteUser(user.id);
setPendingDeleteUser(null);
} catch (err) {
setLocalError(err instanceof Error ? err.message : '删除用户失败');
}
}
async function submitWallet(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLocalError('');
if (!walletUser) return;
const amount = Number(walletForm.amount);
if (!Number.isFinite(amount) || amount <= 0) {
setLocalError('充值金额必须大于 0');
return;
}
if (!walletForm.reason.trim()) {
setLocalError('请填写调整原因');
return;
}
try {
await props.onRechargeUserWalletBalance(walletUser.id, {
currency: walletForm.currency,
amount,
reason: walletForm.reason.trim(),
idempotencyKey: newIdempotencyKey(),
});
closeWalletDialog();
} catch (err) {
setLocalError(err instanceof Error ? err.message : '充值用户余额失败');
}
}
function selectTenant(gatewayTenantId: string) {
const tenant = tenantById.get(gatewayTenantId);
setForm({ ...form, gatewayTenantId, tenantKey: tenant?.tenantKey ?? form.tenantKey });
}
return (
<div className="pageStack">
<ScreenMessage message={identityLocalErrorMessage(localError, props)} variant="error" duration={0} onClose={() => setLocalError('')} />
<IdentityHeader
count={props.data.users.length}
description="支持本地用户闭环,也保留 server-main 用户同步字段。"
icon={<UserRound size={17} />}
message={identityHeaderMessage(props)}
title="用户管理"
actionLabel="新增用户"
onCreate={openCreateDialog}
/>
{props.data.users.length ? (
<Table className="identityDataTable userTable">
<TableRow>
<TableHead>用户</TableHead>
<TableHead>角色</TableHead>
<TableHead>租户</TableHead>
<TableHead>用户组</TableHead>
<TableHead>余额</TableHead>
<TableHead>来源</TableHead>
<TableHead>状态</TableHead>
<TableHead>操作</TableHead>
</TableRow>
{props.data.users.map((user) => (
<TableRow key={user.id}>
<TableCell><IdentityName title={user.displayName || user.username} subtitle={user.email || user.username} /></TableCell>
<TableCell>{roleLabel(user.roles)}</TableCell>
<TableCell>{tenantName(props.data.tenants, user.gatewayTenantId, user.tenantKey)}</TableCell>
<TableCell>{groupName(props.data.userGroups, user.defaultUserGroupId)}</TableCell>
<TableCell>{walletSummary(user)}</TableCell>
<TableCell>{user.source}</TableCell>
<TableCell><Badge variant={user.status === 'active' ? 'success' : 'secondary'}>{user.status}</Badge></TableCell>
<TableCell><TableActions onEdit={() => editUser(user)} onDelete={() => setPendingDeleteUser(user)} onWallet={() => openWalletDialog(user)} /></TableCell>
</TableRow>
))}
</Table>
) : (
<EmptyIdentity title="暂无用户" description="可先添加本地用户,后续再与 server-main 同步关系对齐。" />
)}
<FormDialog
ariaLabel={editingId ? '编辑用户' : '新增用户'}
className="identityDialog"
eyebrow={editingId ? 'Edit User' : 'New User'}
footer={<DialogFooter editing={Boolean(editingId)} loading={props.state === 'loading'} onCancel={closeDialog} />}
open={dialogOpen}
title={editingId ? '编辑用户' : '新增用户'}
onClose={closeDialog}
onSubmit={submit}
>
<Label>用户名<Input size="sm" value={form.username} onChange={(event) => setForm({ ...form, username: event.target.value })} placeholder="user@example.com" /></Label>
<Label>用户 Key<Input size="sm" value={form.userKey} onChange={(event) => setForm({ ...form, userKey: event.target.value })} placeholder="留空自动生成" /></Label>
<Label>显示名称<Input size="sm" value={form.displayName} onChange={(event) => setForm({ ...form, displayName: event.target.value })} placeholder="王小明" /></Label>
<Label>邮箱<Input size="sm" value={form.email} onChange={(event) => setForm({ ...form, email: event.target.value })} placeholder="name@example.com" /></Label>
<Label>手机号<Input size="sm" value={form.phone} onChange={(event) => setForm({ ...form, phone: event.target.value })} /></Label>
<Label>头像 URL<Input size="sm" value={form.avatarUrl} onChange={(event) => setForm({ ...form, avatarUrl: event.target.value })} /></Label>
<Label>来源<Select size="sm" value={form.source} onChange={(event) => setForm({ ...form, source: event.target.value })}>{sourceOptions.map(option)}</Select></Label>
<Label>状态<Select size="sm" value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })}>{userStatuses.map(option)}</Select></Label>
<Label>租户<Select size="sm" value={form.gatewayTenantId} onChange={(event) => selectTenant(event.target.value)}><option value="">未设置</option>{props.data.tenants.map((tenant) => <option value={tenant.id} key={tenant.id}>{tenant.name}</option>)}</Select></Label>
<Label>租户 Key<Input size="sm" value={form.tenantKey} onChange={(event) => setForm({ ...form, tenantKey: event.target.value })} /></Label>
<Label>外部用户 ID<Input size="sm" value={form.externalUserId} onChange={(event) => setForm({ ...form, externalUserId: event.target.value })} /></Label>
<Label>业务租户 ID<Input size="sm" value={form.tenantId} onChange={(event) => setForm({ ...form, tenantId: event.target.value })} /></Label>
<Label>默认用户组<Select size="sm" value={form.defaultUserGroupId} onChange={(event) => setForm({ ...form, defaultUserGroupId: event.target.value })}><option value="">未设置</option>{props.data.userGroups.map((group) => <option value={group.id} key={group.id}>{group.name}</option>)}</Select></Label>
<Label>登录密码<Input size="sm" value={form.password} type="password" onChange={(event) => setForm({ ...form, password: event.target.value })} placeholder={editingId ? '留空不修改' : '可为空'} /></Label>
<Label>
角色
<Select size="sm" value={form.roles} onChange={(event) => setForm({ ...form, roles: event.target.value })}>
{roleOptions.map((role) => <option value={role.value} key={role.value}>{role.label}</option>)}
</Select>
</Label>
<JsonField label="授权资料 JSON" value={form.authProfileJson} onChange={(value) => setForm({ ...form, authProfileJson: value })} />
<JsonField label="元数据 JSON" value={form.metadataJson} onChange={(value) => setForm({ ...form, metadataJson: value })} />
</FormDialog>
<ConfirmDialog
confirmLabel="删除用户"
description="用户会被软删除,已创建的任务记录会保留。"
loading={props.state === 'loading'}
open={Boolean(pendingDeleteUser)}
title={`确认删除用户 ${pendingDeleteUser?.username ?? ''}`}
onCancel={() => setPendingDeleteUser(null)}
onConfirm={() => pendingDeleteUser ? deleteUser(pendingDeleteUser) : undefined}
/>
<FormDialog
ariaLabel="充值用户余额"
className="identityDialog walletDialog"
eyebrow="Wallet Adjustment"
footer={<WalletDialogFooter loading={props.state === 'loading'} onCancel={closeWalletDialog} />}
open={Boolean(walletUser)}
title={`充值余额${walletUser ? ` · ${walletUser.displayName || walletUser.username}` : ''}`}
onClose={closeWalletDialog}
onSubmit={submitWallet}
>
<Label>币种<Input size="sm" value={walletForm.currency} onChange={(event) => setWalletForm({ ...walletForm, currency: event.target.value })} placeholder="resource" /></Label>
<Label>充值金额<Input size="sm" value={walletForm.amount} inputMode="decimal" onChange={(event) => setWalletForm({ ...walletForm, amount: event.target.value })} placeholder="100" /></Label>
<Label className="spanTwo">当前余额<Input size="sm" value={walletUser ? walletSummary(walletUser) : '0 resource'} disabled /></Label>
<Label className="spanTwo">充值原因<Textarea size="sm" rows={3} value={walletForm.reason} onChange={(event) => setWalletForm({ ...walletForm, reason: event.target.value })} placeholder="例如:线下充值确认 / 客服补偿 / 账务修正" /></Label>
</FormDialog>
</div>
);
}
export function UserGroupsPanel(props: IdentityPanelProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState('');
const [form, setForm] = useState<UserGroupForm>(() => defaultUserGroupForm());
const [localError, setLocalError] = useState('');
const [pendingDeleteGroup, setPendingDeleteGroup] = useState<UserGroup | null>(null);
function openCreateDialog() {
setEditingId('');
setLocalError('');
setForm(defaultUserGroupForm());
setDialogOpen(true);
}
function editGroup(group: UserGroup) {
setEditingId(group.id);
setLocalError('');
setForm(userGroupToForm(group));
setDialogOpen(true);
}
function closeDialog() {
setDialogOpen(false);
setEditingId('');
setLocalError('');
}
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLocalError('');
try {
await props.onSaveUserGroup(formToUserGroupPayload(form), editingId || undefined);
closeDialog();
} catch (err) {
setLocalError(err instanceof Error ? err.message : '保存用户组失败');
}
}
async function deleteGroup(group: UserGroup) {
try {
await props.onDeleteUserGroup(group.id);
setPendingDeleteGroup(null);
} catch (err) {
setLocalError(err instanceof Error ? err.message : '删除用户组失败');
}
}
return (
<div className="pageStack">
<ScreenMessage message={identityLocalErrorMessage(localError, props)} variant="error" duration={0} onClose={() => setLocalError('')} />
<IdentityHeader
count={props.data.userGroups.length}
description="用户组承载充值折扣、计费折扣、限流和额度策略。"
icon={<UsersRound size={17} />}
message={identityHeaderMessage(props)}
title="用户组管理"
actionLabel="新增用户组"
onCreate={openCreateDialog}
/>
{props.data.userGroups.length ? (
<Table className="identityDataTable groupTable">
<TableRow>
<TableHead>用户组</TableHead>
<TableHead>来源</TableHead>
<TableHead>优先级</TableHead>
<TableHead>折扣</TableHead>
<TableHead>限流</TableHead>
<TableHead>状态</TableHead>
<TableHead>操作</TableHead>
</TableRow>
{props.data.userGroups.map((group) => (
<TableRow key={group.id}>
<TableCell><IdentityName title={group.name} subtitle={group.groupKey} /></TableCell>
<TableCell>{group.source}</TableCell>
<TableCell>{group.priority}</TableCell>
<TableCell>{discountSummary(group)}</TableCell>
<TableCell>{policyKeys(group.rateLimitPolicy).join(', ') || '未设置'}</TableCell>
<TableCell><Badge variant={group.status === 'active' ? 'success' : 'secondary'}>{group.status}</Badge></TableCell>
<TableCell><TableActions onEdit={() => editGroup(group)} onDelete={() => setPendingDeleteGroup(group)} /></TableCell>
</TableRow>
))}
</Table>
) : (
<EmptyIdentity title="暂无用户组" description="可先新增默认用户组,用来配置折扣和并发控制。" />
)}
<FormDialog
ariaLabel={editingId ? '编辑用户组' : '新增用户组'}
className="identityDialog"
eyebrow={editingId ? 'Edit User Group' : 'New User Group'}
footer={<DialogFooter editing={Boolean(editingId)} loading={props.state === 'loading'} onCancel={closeDialog} />}
open={dialogOpen}
title={editingId ? '编辑用户组' : '新增用户组'}
onClose={closeDialog}
onSubmit={submit}
>
<Label>用户组 Key<Input size="sm" value={form.groupKey} onChange={(event) => setForm({ ...form, groupKey: event.target.value })} placeholder="default" /></Label>
<Label>名称<Input size="sm" value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder="默认用户组" /></Label>
<Label>来源<Select size="sm" value={form.source} onChange={(event) => setForm({ ...form, source: event.target.value })}>{sourceOptions.map(option)}</Select></Label>
<Label>状态<Select size="sm" value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })}>{userGroupStatuses.map(option)}</Select></Label>
<Label>优先级<Input size="sm" value={form.priority} inputMode="numeric" onChange={(event) => setForm({ ...form, priority: event.target.value })} /></Label>
<Label className="spanTwo">描述<Input size="sm" value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} /></Label>
<Label>充值折扣系数<Input size="sm" value={form.rechargeDiscountFactor} inputMode="decimal" placeholder="1 = 不打折,0.95 = 95 折" onChange={(event) => setForm({ ...form, rechargeDiscountFactor: event.target.value })} /></Label>
<Label>计费折扣系数<Input size="sm" value={form.billingDiscountFactor} inputMode="decimal" placeholder="1 = 不打折,0.95 = 95 折" onChange={(event) => setForm({ ...form, billingDiscountFactor: event.target.value })} /></Label>
<JsonField label="限流策略 JSON" value={form.rateLimitPolicyJson} onChange={(value) => setForm({ ...form, rateLimitPolicyJson: value })} />
<JsonField label="额度策略 JSON" value={form.quotaPolicyJson} onChange={(value) => setForm({ ...form, quotaPolicyJson: value })} />
<JsonField label="元数据 JSON" value={form.metadataJson} onChange={(value) => setForm({ ...form, metadataJson: value })} />
</FormDialog>
<ConfirmDialog
confirmLabel="删除用户组"
description="删除后租户和用户上引用该用户组的默认策略会失效。"
loading={props.state === 'loading'}
open={Boolean(pendingDeleteGroup)}
title={`确认删除用户组 ${pendingDeleteGroup?.name ?? ''}`}
onCancel={() => setPendingDeleteGroup(null)}
onConfirm={() => pendingDeleteGroup ? deleteGroup(pendingDeleteGroup) : undefined}
/>
</div>
);
}
type IdentityPanelProps = {
data: ConsoleData;
operationMessage: string;
state: LoadState;
onDeleteTenant: (tenantId: string) => Promise<void>;
onDeleteUser: (userId: string) => Promise<void>;
onDeleteUserGroup: (groupId: string) => Promise<void>;
onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise<void>;
onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise<void>;
onRechargeUserWalletBalance: (userId: string, input: WalletRechargeRequest) => Promise<void>;
onSaveUserGroup: (input: UserGroupUpsertRequest, groupId?: string) => Promise<void>;
};
const defaultRechargeReason = '手动充值';
function identityHeaderMessage(props: Pick<IdentityPanelProps, 'operationMessage' | 'state'>) {
return props.state === 'error' ? '' : props.operationMessage;
}
function identityLocalErrorMessage(localError: string, props: Pick<IdentityPanelProps, 'operationMessage' | 'state'>) {
if (!localError) return '';
if (props.state === 'error' && localError === props.operationMessage) return '';
return localError;
}
function IdentityHeader(props: {
actionLabel: string;
count: number;
description: string;
icon: ReactNode;
message?: string;
title: string;
onCreate: () => void;
}) {
return (
<Card>
<CardHeader>
<div className="identityHeaderTitle">
<div className="iconBox">{props.icon}</div>
<div>
<CardTitle>{props.title}</CardTitle>
<p className="mutedText">{props.description}</p>
</div>
<Badge variant="secondary">{props.count}</Badge>
</div>
<Button type="button" onClick={props.onCreate}><Plus size={15} />{props.actionLabel}</Button>
</CardHeader>
{props.message && <CardContent><p className="formMessage">{props.message}</p></CardContent>}
</Card>
);
}
function IdentityName(props: { title: string; subtitle: string }) {
return (
<span className="identityTableName">
<strong>{props.title}</strong>
<small>{props.subtitle}</small>
</span>
);
}
function TableActions(props: { onDelete: () => void; onEdit: () => void; onWallet?: () => void }) {
return (
<span className="tableActions">
{props.onWallet && <Button type="button" variant="outline" size="icon" title="余额" onClick={props.onWallet}><CircleDollarSign size={14} /></Button>}
<Button type="button" variant="outline" size="icon" title="修改" onClick={props.onEdit}><Pencil size={14} /></Button>
<Button type="button" variant="destructive" size="icon" title="删除" onClick={props.onDelete}><Trash2 size={14} /></Button>
</span>
);
}
function EmptyIdentity(props: { title: string; description: string }) {
return (
<Card>
<CardContent className="emptyState">
<KeyRound size={18} />
<strong>{props.title}</strong>
<span>{props.description}</span>
</CardContent>
</Card>
);
}
function DialogFooter(props: { editing: boolean; loading: boolean; onCancel: () => void }) {
return (
<>
<Button type="button" variant="outline" onClick={props.onCancel}><RotateCcw size={15} />取消</Button>
<Button type="submit" disabled={props.loading}>{props.editing ? <Pencil size={15} /> : <Plus size={15} />}{props.editing ? '保存修改' : '新增'}</Button>
</>
);
}
function WalletDialogFooter(props: { loading: boolean; onCancel: () => void }) {
return (
<>
<Button type="button" variant="outline" onClick={props.onCancel}><RotateCcw size={15} />取消</Button>
<Button type="submit" disabled={props.loading}><CircleDollarSign size={15} />确认充值</Button>
</>
);
}
function JsonField(props: { label: string; value: string; onChange: (value: string) => void }) {
return (
<Label className="spanTwo">
{props.label}
<Textarea size="sm" rows={3} value={props.value} placeholder='{"rules":[]}' onChange={(event) => props.onChange(event.target.value)} />
</Label>
);
}
function option(value: string) {
return <option value={value} key={value}>{value}</option>;
}
function defaultTenantForm(): TenantForm {
return {
tenantKey: `tenant-${Date.now().toString(36)}`,
source: 'gateway',
externalTenantId: '',
name: '默认租户',
description: '',
defaultUserGroupId: '',
planKey: '',
billingProfileJson: '{}',
rateLimitPolicyJson: '{}',
authPolicyJson: '{}',
metadataJson: '{}',
status: 'active',
};
}
function tenantToForm(tenant: GatewayTenant): TenantForm {
return {
tenantKey: tenant.tenantKey,
source: tenant.source,
externalTenantId: tenant.externalTenantId ?? '',
name: tenant.name,
description: tenant.description ?? '',
defaultUserGroupId: tenant.defaultUserGroupId ?? '',
planKey: tenant.planKey ?? '',
billingProfileJson: stringifyJson(tenant.billingProfile),
rateLimitPolicyJson: stringifyJson(tenant.rateLimitPolicy),
authPolicyJson: stringifyJson(tenant.authPolicy),
metadataJson: stringifyJson(tenant.metadata),
status: tenant.status,
};
}
function formToTenantPayload(form: TenantForm): GatewayTenantUpsertRequest {
return {
tenantKey: form.tenantKey.trim(),
source: form.source,
externalTenantId: form.externalTenantId.trim() || undefined,
name: form.name.trim(),
description: form.description.trim() || undefined,
defaultUserGroupId: form.defaultUserGroupId || undefined,
planKey: form.planKey.trim() || undefined,
billingProfile: parseJsonObject(form.billingProfileJson, '计费资料 JSON'),
rateLimitPolicy: parseJsonObject(form.rateLimitPolicyJson, '限流策略 JSON'),
authPolicy: parseJsonObject(form.authPolicyJson, '授权策略 JSON'),
metadata: parseJsonObject(form.metadataJson, '元数据 JSON'),
status: form.status,
};
}
function defaultUserForm(tenant?: GatewayTenant): UserForm {
return {
userKey: '',
source: 'gateway',
externalUserId: '',
username: '',
displayName: '',
email: '',
phone: '',
avatarUrl: '',
password: '',
gatewayTenantId: tenant?.id ?? '',
tenantId: '',
tenantKey: tenant?.tenantKey ?? '',
defaultUserGroupId: '',
roles: 'user',
authProfileJson: '{}',
metadataJson: '{}',
status: 'active',
};
}
function defaultWalletForm(): WalletForm {
return {
currency: 'resource',
amount: '',
reason: defaultRechargeReason,
};
}
function userToForm(user: GatewayUser): UserForm {
return {
userKey: user.userKey,
source: user.source,
externalUserId: user.externalUserId ?? '',
username: user.username,
displayName: user.displayName ?? '',
email: user.email ?? '',
phone: user.phone ?? '',
avatarUrl: user.avatarUrl ?? '',
password: '',
gatewayTenantId: user.gatewayTenantId ?? '',
tenantId: user.tenantId ?? '',
tenantKey: user.tenantKey ?? '',
defaultUserGroupId: user.defaultUserGroupId ?? '',
roles: roleValue(user.roles),
authProfileJson: stringifyJson(user.authProfile),
metadataJson: stringifyJson(user.metadata),
status: user.status,
};
}
function formToUserPayload(form: UserForm): GatewayUserUpsertRequest {
return {
userKey: form.userKey.trim() || undefined,
source: form.source,
externalUserId: form.externalUserId.trim() || undefined,
username: form.username.trim(),
displayName: form.displayName.trim() || undefined,
email: form.email.trim() || undefined,
phone: form.phone.trim() || undefined,
avatarUrl: form.avatarUrl.trim() || undefined,
password: form.password || undefined,
gatewayTenantId: form.gatewayTenantId || undefined,
tenantId: form.tenantId.trim() || undefined,
tenantKey: form.tenantKey.trim() || undefined,
defaultUserGroupId: form.defaultUserGroupId || undefined,
roles: [roleValue([form.roles])],
authProfile: parseJsonObject(form.authProfileJson, '授权资料 JSON'),
metadata: parseJsonObject(form.metadataJson, '元数据 JSON'),
status: form.status,
};
}
function defaultUserGroupForm(): UserGroupForm {
return {
groupKey: `group-${Date.now().toString(36)}`,
name: '默认用户组',
description: '',
source: 'gateway',
priority: '100',
rechargeDiscountFactor: '1',
rechargeDiscountPolicy: {},
billingDiscountFactor: '1',
billingDiscountPolicy: {},
rateLimitPolicyJson: '{"rules":[]}',
quotaPolicyJson: '{}',
metadataJson: '{}',
status: 'active',
};
}
function userGroupToForm(group: UserGroup): UserGroupForm {
return {
groupKey: group.groupKey,
name: group.name,
description: group.description ?? '',
source: group.source,
priority: String(group.priority),
rechargeDiscountFactor: discountFactorText(group.rechargeDiscountPolicy),
rechargeDiscountPolicy: group.rechargeDiscountPolicy ?? {},
billingDiscountFactor: discountFactorText(group.billingDiscountPolicy),
billingDiscountPolicy: group.billingDiscountPolicy ?? {},
rateLimitPolicyJson: stringifyJson(group.rateLimitPolicy),
quotaPolicyJson: stringifyJson(group.quotaPolicy),
metadataJson: stringifyJson(group.metadata),
status: group.status,
};
}
function formToUserGroupPayload(form: UserGroupForm): UserGroupUpsertRequest {
return {
groupKey: form.groupKey.trim(),
name: form.name.trim(),
description: form.description.trim() || undefined,
source: form.source,
priority: Number(form.priority) || 100,
rechargeDiscountPolicy: discountPolicyPayload(form.rechargeDiscountPolicy, form.rechargeDiscountFactor, '充值折扣系数'),
billingDiscountPolicy: discountPolicyPayload(form.billingDiscountPolicy, form.billingDiscountFactor, '计费折扣系数'),
rateLimitPolicy: parseJsonObject(form.rateLimitPolicyJson, '限流策略 JSON'),
quotaPolicy: parseJsonObject(form.quotaPolicyJson, '额度策略 JSON'),
metadata: parseJsonObject(form.metadataJson, '元数据 JSON'),
status: form.status,
};
}
function tenantName(tenants: GatewayTenant[], tenantId?: string, fallback?: string) {
if (!tenantId) return fallback || '未设置';
return tenants.find((tenant) => tenant.id === tenantId)?.name ?? fallback ?? tenantId;
}
function groupName(groups: UserGroup[], groupId?: string) {
if (!groupId) return '未设置';
return groups.find((group) => group.id === groupId)?.name ?? groupId;
}
function roleValue(roles?: string[]) {
if (roles?.includes('manager')) return 'manager';
if (roles?.includes('admin')) return 'admin';
if (roles?.includes('operator')) return 'operator';
if (roles?.includes('creator')) return 'creator';
return 'user';
}
function roleLabel(roles?: string[]) {
return roleOptions.find((role) => role.value === roleValue(roles))?.label ?? '普通用户';
}
function primaryWallet(user: GatewayUser): GatewayWalletAccount | undefined {
return (user.walletAccounts ?? []).find((account) => account.currency === 'resource') ?? user.walletAccounts?.[0];
}
function walletSummary(user: GatewayUser) {
const wallet = primaryWallet(user);
if (!wallet) return '0 resource';
return `${formatBalance(wallet.balance)} ${wallet.currency}`;
}
function formatBalance(value: number) {
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 6 }).format(value);
}
function newIdempotencyKey() {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID();
}
return `wallet-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
function discountSummary(group: UserGroup) {
const billing = discountFactorFromPolicy(group.billingDiscountPolicy);
const recharge = discountFactorFromPolicy(group.rechargeDiscountPolicy);
const parts = [];
if (billing) parts.push(`计费 ${trimNumber(billing)}`);
if (recharge) parts.push(`充值 ${trimNumber(recharge)}`);
return parts.join(' / ') || '未设置';
}
function discountFactorText(policy?: Record<string, unknown>) {
const value = discountFactorFromPolicy(policy);
return value ? trimNumber(value) : '1';
}
function discountFactorFromPolicy(policy?: Record<string, unknown>) {
return numberFromUnknown(policy?.discountFactor) ?? numberFromUnknown(policy?.factor);
}
function discountPolicyPayload(basePolicy: Record<string, unknown>, discountText: string, label: string) {
const policy = { ...basePolicy };
delete policy.discountFactor;
delete policy.factor;
const discount = optionalPositiveNumber(discountText, label);
if (discount && discount !== 1) {
policy.discountFactor = discount;
}
return Object.keys(policy).length ? policy : undefined;
}
function optionalPositiveNumber(value: string, label: string) {
const text = value.trim();
if (!text) return undefined;
const parsed = Number(text);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`${label} 必须是大于 0 的数字`);
}
return parsed;
}
function numberFromUnknown(value: unknown) {
if (typeof value === 'number' && Number.isFinite(value) && value > 0) return value;
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
}
return undefined;
}
function trimNumber(value: number) {
return value.toFixed(6).replace(/\.?0+$/, '');
}
function policyKeys(value?: Record<string, unknown>) {
if (!value) return [];
return Object.keys(value).slice(0, 3);
}
function stringifyJson(value: unknown) {
if (!value || (typeof value === 'object' && Object.keys(value as Record<string, unknown>).length === 0)) return '{}';
return JSON.stringify(value, null, 2);
}
function parseJsonObject(value: string, label: string): Record<string, unknown> | undefined {
const text = value.trim();
if (!text || text === '{}') return undefined;
const parsed = JSON.parse(text) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(`${label} 必须是 JSON 对象`);
}
return parsed as Record<string, unknown>;
}