950 lines
39 KiB
TypeScript
950 lines
39 KiB
TypeScript
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>;
|
||
}
|