feat: add wallet settlement audit flow
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import { History, ShieldCheck } from 'lucide-react';
|
||||
import type { GatewayAuditLog } from '@easyai-ai-gateway/contracts';
|
||||
import { Badge, Card, CardContent, CardHeader, CardTitle, Table, TableCell, TableHead, TableRow } from '../../components/ui';
|
||||
|
||||
export function AuditLogsPanel(props: { auditLogs: GatewayAuditLog[]; message?: string }) {
|
||||
return (
|
||||
<div className="pageStack">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="identityHeaderTitle">
|
||||
<div className="iconBox"><ShieldCheck size={17} /></div>
|
||||
<div>
|
||||
<CardTitle>审计日志</CardTitle>
|
||||
<p className="mutedText">敏感管理动作独立记录,便于追踪操作者、对象和前后状态。</p>
|
||||
</div>
|
||||
<Badge variant="secondary">{props.auditLogs.length}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{props.message && <CardContent><p className="formMessage">{props.message}</p></CardContent>}
|
||||
</Card>
|
||||
{props.auditLogs.length ? (
|
||||
<Table className="identityDataTable auditLogTable">
|
||||
<TableRow>
|
||||
<TableHead>动作</TableHead>
|
||||
<TableHead>操作者</TableHead>
|
||||
<TableHead>目标</TableHead>
|
||||
<TableHead>摘要</TableHead>
|
||||
<TableHead>时间</TableHead>
|
||||
</TableRow>
|
||||
{props.auditLogs.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<span className="identityTableName">
|
||||
<strong>{actionLabel(item.action)}</strong>
|
||||
<small>{item.category}</small>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{item.actorUsername || item.actorUserId || '系统'}</TableCell>
|
||||
<TableCell>
|
||||
<span className="identityTableName">
|
||||
<strong>{targetLabel(item.targetType)}</strong>
|
||||
<small>{shortId(item.targetId)}</small>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{auditSummary(item)}</TableCell>
|
||||
<TableCell>{formatDateTime(item.createdAt)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="emptyState">
|
||||
<History size={18} />
|
||||
<strong>暂无审计日志</strong>
|
||||
<span>后台余额调整后会在这里留下独立记录。</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function actionLabel(action: string) {
|
||||
if (action === 'wallet.balance.set') return '余额调整';
|
||||
return action;
|
||||
}
|
||||
|
||||
function targetLabel(targetType: string) {
|
||||
if (targetType === 'gateway_user') return '用户';
|
||||
return targetType;
|
||||
}
|
||||
|
||||
function auditSummary(item: GatewayAuditLog) {
|
||||
const metadata = item.metadata ?? {};
|
||||
const direction = typeof metadata.direction === 'string' ? metadata.direction : '';
|
||||
const amount = typeof metadata.amount === 'number' ? metadata.amount : undefined;
|
||||
const currency = typeof metadata.currency === 'string' ? metadata.currency : 'resource';
|
||||
const reason = typeof metadata.reason === 'string' ? metadata.reason : '';
|
||||
const prefix = amount === undefined ? '' : `${direction === 'debit' ? '-' : '+'}${formatBalance(amount)} ${currency}`;
|
||||
return [prefix, reason].filter(Boolean).join(' · ') || '已记录';
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
return value ? new Date(value).toLocaleString() : '-';
|
||||
}
|
||||
|
||||
function formatBalance(value: number) {
|
||||
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 6 }).format(value);
|
||||
}
|
||||
|
||||
function shortId(value: string) {
|
||||
return value.length > 12 ? `${value.slice(0, 8)}...${value.slice(-4)}` : value;
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useMemo, useState, type FormEvent, type ReactNode } from 'react';
|
||||
import { Building2, KeyRound, Pencil, Plus, RotateCcw, Trash2, UserRound, UsersRound } from 'lucide-react';
|
||||
import { Building2, CircleDollarSign, KeyRound, Pencil, Plus, RotateCcw, Trash2, UserRound, UsersRound } from 'lucide-react';
|
||||
import type {
|
||||
GatewayTenant,
|
||||
GatewayTenantUpsertRequest,
|
||||
GatewayUser,
|
||||
GatewayUserUpsertRequest,
|
||||
GatewayWalletAccount,
|
||||
UserGroup,
|
||||
UserGroupUpsertRequest,
|
||||
WalletBalanceAdjustmentRequest,
|
||||
} from '@easyai-ai-gateway/contracts';
|
||||
import {
|
||||
Badge,
|
||||
@@ -64,6 +66,12 @@ type UserForm = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
type WalletForm = {
|
||||
currency: string;
|
||||
balance: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
type UserGroupForm = {
|
||||
groupKey: string;
|
||||
name: string;
|
||||
@@ -215,6 +223,8 @@ export function UsersPanel(props: IdentityPanelProps) {
|
||||
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]);
|
||||
|
||||
@@ -238,6 +248,22 @@ export function UsersPanel(props: IdentityPanelProps) {
|
||||
setLocalError('');
|
||||
}
|
||||
|
||||
function openWalletDialog(user: GatewayUser) {
|
||||
const wallet = primaryWallet(user);
|
||||
setLocalError('');
|
||||
setWalletUser(user);
|
||||
setWalletForm({
|
||||
currency: wallet?.currency ?? 'resource',
|
||||
balance: String(wallet?.balance ?? 0),
|
||||
reason: '',
|
||||
});
|
||||
}
|
||||
|
||||
function closeWalletDialog() {
|
||||
setWalletUser(null);
|
||||
setWalletForm(defaultWalletForm());
|
||||
}
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setLocalError('');
|
||||
@@ -258,6 +284,32 @@ export function UsersPanel(props: IdentityPanelProps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function submitWallet(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setLocalError('');
|
||||
if (!walletUser) return;
|
||||
const balance = Number(walletForm.balance);
|
||||
if (!Number.isFinite(balance) || balance < 0) {
|
||||
setLocalError('余额必须是非负数字');
|
||||
return;
|
||||
}
|
||||
if (!walletForm.reason.trim()) {
|
||||
setLocalError('请填写调整原因');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await props.onSetUserWalletBalance(walletUser.id, {
|
||||
currency: walletForm.currency,
|
||||
balance,
|
||||
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 });
|
||||
@@ -281,6 +333,7 @@ export function UsersPanel(props: IdentityPanelProps) {
|
||||
<TableHead>角色</TableHead>
|
||||
<TableHead>租户</TableHead>
|
||||
<TableHead>用户组</TableHead>
|
||||
<TableHead>余额</TableHead>
|
||||
<TableHead>来源</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
@@ -291,9 +344,10 @@ export function UsersPanel(props: IdentityPanelProps) {
|
||||
<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)} /></TableCell>
|
||||
<TableCell><TableActions onEdit={() => editUser(user)} onDelete={() => setPendingDeleteUser(user)} onWallet={() => openWalletDialog(user)} /></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
@@ -343,6 +397,20 @@ export function UsersPanel(props: IdentityPanelProps) {
|
||||
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.balance} inputMode="decimal" onChange={(event) => setWalletForm({ ...walletForm, balance: event.target.value })} /></Label>
|
||||
<Label className="spanTwo">调整原因<Textarea size="sm" rows={3} value={walletForm.reason} onChange={(event) => setWalletForm({ ...walletForm, reason: event.target.value })} placeholder="例如:线下充值确认 / 客服补偿 / 账务修正" /></Label>
|
||||
</FormDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -476,6 +544,7 @@ type IdentityPanelProps = {
|
||||
onDeleteUserGroup: (groupId: string) => Promise<void>;
|
||||
onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise<void>;
|
||||
onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise<void>;
|
||||
onSetUserWalletBalance: (userId: string, input: WalletBalanceAdjustmentRequest) => Promise<void>;
|
||||
onSaveUserGroup: (input: UserGroupUpsertRequest, groupId?: string) => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -515,9 +584,10 @@ function IdentityName(props: { title: string; subtitle: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function TableActions(props: { onDelete: () => void; onEdit: () => void }) {
|
||||
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>
|
||||
@@ -545,6 +615,15 @@ function DialogFooter(props: { editing: boolean; loading: boolean; onCancel: ()
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
@@ -631,6 +710,14 @@ function defaultUserForm(tenant?: GatewayTenant): UserForm {
|
||||
};
|
||||
}
|
||||
|
||||
function defaultWalletForm(): WalletForm {
|
||||
return {
|
||||
currency: 'resource',
|
||||
balance: '0',
|
||||
reason: '',
|
||||
};
|
||||
}
|
||||
|
||||
function userToForm(user: GatewayUser): UserForm {
|
||||
return {
|
||||
userKey: user.userKey,
|
||||
@@ -745,6 +832,27 @@ 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 = group.billingDiscountPolicy?.discountFactor ?? group.billingDiscountPolicy?.factor;
|
||||
const recharge = group.rechargeDiscountPolicy?.discountFactor ?? group.rechargeDiscountPolicy?.factor;
|
||||
|
||||
Reference in New Issue
Block a user