feat: add wallet settlement audit flow
This commit is contained in:
+40
-1
@@ -6,6 +6,7 @@ import type {
|
||||
GatewayAccessRule,
|
||||
GatewayAccessRuleUpsertRequest,
|
||||
GatewayApiKey,
|
||||
GatewayAuditLog,
|
||||
GatewayTenantUpsertRequest,
|
||||
GatewayTask,
|
||||
GatewayUserUpsertRequest,
|
||||
@@ -20,6 +21,7 @@ import type {
|
||||
RuntimePolicySet,
|
||||
UserGroupUpsertRequest,
|
||||
UserGroup,
|
||||
WalletBalanceAdjustmentRequest,
|
||||
} from '@easyai-ai-gateway/contracts';
|
||||
import {
|
||||
batchAccessRules,
|
||||
@@ -38,6 +40,7 @@ import {
|
||||
deleteUserGroup,
|
||||
getHealth,
|
||||
getTask,
|
||||
listAuditLogs,
|
||||
listAccessRules,
|
||||
listApiKeyAccessRules,
|
||||
listApiKeys,
|
||||
@@ -61,6 +64,7 @@ import {
|
||||
loginLocalAccount,
|
||||
registerLocalAccount,
|
||||
replacePlatformModels,
|
||||
setUserWalletBalance,
|
||||
type HealthResponse,
|
||||
updateAccessRule,
|
||||
updateGatewayUser,
|
||||
@@ -130,6 +134,7 @@ type DataKey =
|
||||
| 'userGroups'
|
||||
| 'tasks'
|
||||
| 'accessRules'
|
||||
| 'auditLogs'
|
||||
| 'apiKeys';
|
||||
|
||||
export function App() {
|
||||
@@ -160,6 +165,7 @@ export function App() {
|
||||
const [pricingRuleSets, setPricingRuleSets] = useState<PricingRuleSet[]>([]);
|
||||
const [runtimePolicySets, setRuntimePolicySets] = useState<RuntimePolicySet[]>([]);
|
||||
const [accessRules, setAccessRules] = useState<GatewayAccessRule[]>([]);
|
||||
const [auditLogs, setAuditLogs] = useState<GatewayAuditLog[]>([]);
|
||||
const [rateLimitWindows, setRateLimitWindows] = useState<RateLimitWindow[]>([]);
|
||||
const [tenants, setTenants] = useState<GatewayTenant[]>([]);
|
||||
const [users, setUsers] = useState<GatewayUser[]>([]);
|
||||
@@ -238,6 +244,7 @@ export function App() {
|
||||
|
||||
const data = useMemo<ConsoleData>(() => ({
|
||||
accessRules,
|
||||
auditLogs,
|
||||
apiKeys,
|
||||
baseModels,
|
||||
modelCatalog,
|
||||
@@ -253,7 +260,7 @@ export function App() {
|
||||
tenants,
|
||||
userGroups,
|
||||
users,
|
||||
}), [accessRules, apiKeys, baseModels, modelCatalog, models, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runtimePolicySets, taskResult, tasks, tenants, userGroups, users]);
|
||||
}), [accessRules, apiKeys, auditLogs, baseModels, modelCatalog, models, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runtimePolicySets, taskResult, tasks, tenants, userGroups, users]);
|
||||
|
||||
async function refresh(nextToken = token) {
|
||||
await ensureRouteData(nextToken, true);
|
||||
@@ -371,6 +378,9 @@ export function App() {
|
||||
? listApiKeyAccessRules(nextToken)
|
||||
: listAccessRules(nextToken))).items);
|
||||
return;
|
||||
case 'auditLogs':
|
||||
setAuditLogs((await listAuditLogs(nextToken)).items);
|
||||
return;
|
||||
case 'apiKeys':
|
||||
setApiKeys((await listApiKeys(nextToken)).items);
|
||||
}
|
||||
@@ -527,6 +537,31 @@ export function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveUserWalletBalance(userId: string, input: WalletBalanceAdjustmentRequest) {
|
||||
setCoreState('loading');
|
||||
setCoreMessage('');
|
||||
try {
|
||||
const response = await setUserWalletBalance(token, userId, input);
|
||||
setUsers((current) => current.map((user) => user.id === userId
|
||||
? {
|
||||
...user,
|
||||
walletAccounts: [
|
||||
response.account,
|
||||
...(user.walletAccounts ?? []).filter((account) => account.id !== response.account.id),
|
||||
],
|
||||
}
|
||||
: user));
|
||||
setAuditLogs((current) => [response.auditLog, ...current.filter((item) => item.id !== response.auditLog.id)]);
|
||||
invalidateDataKeys('auditLogs');
|
||||
setCoreState('ready');
|
||||
setCoreMessage('用户余额已更新,审计日志已记录。');
|
||||
} catch (err) {
|
||||
setCoreState('error');
|
||||
setCoreMessage(err instanceof Error ? err.message : '更新用户余额失败');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeUser(userId: string) {
|
||||
setCoreState('loading');
|
||||
setCoreMessage('');
|
||||
@@ -697,6 +732,7 @@ export function App() {
|
||||
setPricingRuleSets([]);
|
||||
setRuntimePolicySets([]);
|
||||
setAccessRules([]);
|
||||
setAuditLogs([]);
|
||||
setRateLimitWindows([]);
|
||||
setTenants([]);
|
||||
setUsers([]);
|
||||
@@ -866,6 +902,7 @@ export function App() {
|
||||
onSaveAccessRule={saveAccessRule}
|
||||
onSaveTenant={saveTenant}
|
||||
onSaveUser={saveUser}
|
||||
onSetUserWalletBalance={saveUserWalletBalance}
|
||||
onSaveUserGroup={saveUserGroup}
|
||||
onSectionChange={navigateAdminSection}
|
||||
/>
|
||||
@@ -1015,6 +1052,8 @@ function dataKeysForRoute(
|
||||
return ['users', 'tenants', 'userGroups'];
|
||||
case 'userGroups':
|
||||
return ['userGroups'];
|
||||
case 'auditLogs':
|
||||
return ['auditLogs'];
|
||||
case 'accessRules':
|
||||
return ['accessRules', 'userGroups', 'platforms', 'models'];
|
||||
default:
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
GatewayAccessRule,
|
||||
GatewayAccessRuleUpsertRequest,
|
||||
GatewayApiKey,
|
||||
GatewayAuditLog,
|
||||
GatewayTenant,
|
||||
GatewayTenantUpsertRequest,
|
||||
GatewayTask,
|
||||
@@ -27,6 +28,8 @@ import type {
|
||||
RuntimePolicySetUpsertRequest,
|
||||
UserGroup,
|
||||
UserGroupUpsertRequest,
|
||||
WalletAdjustmentResponse,
|
||||
WalletBalanceAdjustmentRequest,
|
||||
} from '@easyai-ai-gateway/contracts';
|
||||
import type { PlatformCreateInput, PlatformModelBindingInput, WorkspaceTaskQuery } from './types';
|
||||
|
||||
@@ -287,6 +290,18 @@ export async function updateGatewayUser(token: string, userId: string, input: Ga
|
||||
});
|
||||
}
|
||||
|
||||
export async function setUserWalletBalance(
|
||||
token: string,
|
||||
userId: string,
|
||||
input: WalletBalanceAdjustmentRequest,
|
||||
): Promise<WalletAdjustmentResponse> {
|
||||
return request<WalletAdjustmentResponse>(`/api/admin/users/${userId}/wallet`, {
|
||||
body: input,
|
||||
method: 'PATCH',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteGatewayUser(token: string, userId: string): Promise<void> {
|
||||
await request<void>(`/api/admin/users/${userId}`, {
|
||||
method: 'DELETE',
|
||||
@@ -294,6 +309,10 @@ export async function deleteGatewayUser(token: string, userId: string): Promise<
|
||||
});
|
||||
}
|
||||
|
||||
export async function listAuditLogs(token: string): Promise<ListResponse<GatewayAuditLog>> {
|
||||
return request<ListResponse<GatewayAuditLog>>('/api/admin/audit-logs', { token });
|
||||
}
|
||||
|
||||
export async function listUserGroups(token: string): Promise<ListResponse<UserGroup>> {
|
||||
return request<ListResponse<UserGroup>>('/api/admin/user-groups', { token });
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
CatalogProvider,
|
||||
GatewayAccessRule,
|
||||
GatewayApiKey,
|
||||
GatewayAuditLog,
|
||||
GatewayTask,
|
||||
GatewayTenant,
|
||||
GatewayUser,
|
||||
@@ -18,6 +19,7 @@ import type {
|
||||
|
||||
export interface ConsoleData {
|
||||
accessRules: GatewayAccessRule[];
|
||||
auditLogs: GatewayAuditLog[];
|
||||
apiKeys: GatewayApiKey[];
|
||||
baseModels: BaseModelCatalogItem[];
|
||||
modelCatalog: ModelCatalogResponse;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Boxes, Building2, Gauge, KeyRound, Route, ServerCog, ShieldCheck, UsersRound, Workflow } from 'lucide-react';
|
||||
import { Boxes, Building2, Gauge, History, KeyRound, Route, ServerCog, ShieldCheck, UsersRound, Workflow } from 'lucide-react';
|
||||
import type {
|
||||
BaseModelUpsertRequest,
|
||||
CatalogProviderUpsertRequest,
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
PricingRuleSetUpsertRequest,
|
||||
RuntimePolicySetUpsertRequest,
|
||||
UserGroupUpsertRequest,
|
||||
WalletBalanceAdjustmentRequest,
|
||||
} from '@easyai-ai-gateway/contracts';
|
||||
import type { ConsoleData, StatItem } from '../app-state';
|
||||
import { EntityTable } from '../components/EntityTable';
|
||||
@@ -17,6 +18,7 @@ import { StatGrid } from '../components/StatGrid';
|
||||
import { Badge, Card, CardContent, CardHeader, CardTitle, Tabs } from '../components/ui';
|
||||
import type { AdminSection, LoadState, PlatformWithModelsInput } from '../types';
|
||||
import { AccessRulesPanel } from './admin/AccessRulesPanel';
|
||||
import { AuditLogsPanel } from './admin/AuditLogsPanel';
|
||||
import { BaseModelCatalogPanel } from './admin/BaseModelCatalogPanel';
|
||||
import { TenantsPanel, UserGroupsPanel, UsersPanel } from './admin/IdentityManagementPanels';
|
||||
import { PlatformManagementPanel } from './admin/PlatformManagementPanel';
|
||||
@@ -35,6 +37,7 @@ const tabs = [
|
||||
{ value: 'users', label: '用户', icon: <UsersRound size={15} /> },
|
||||
{ value: 'userGroups', label: '用户组', icon: <UsersRound size={15} /> },
|
||||
{ value: 'accessRules', label: '模型权限', icon: <KeyRound size={15} /> },
|
||||
{ value: 'auditLogs', label: '审计日志', icon: <History size={15} /> },
|
||||
] satisfies Array<{ value: AdminSection; label: string; icon: ReactNode }>;
|
||||
|
||||
export function AdminPage(props: {
|
||||
@@ -63,6 +66,7 @@ export function AdminPage(props: {
|
||||
onSaveAccessRule: (input: GatewayAccessRuleUpsertRequest, ruleId?: 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>;
|
||||
onSectionChange: (value: AdminSection) => void;
|
||||
}) {
|
||||
@@ -141,6 +145,7 @@ export function AdminPage(props: {
|
||||
{props.section === 'tenants' && <TenantsPanel {...identityPanelProps(props)} />}
|
||||
{props.section === 'users' && <UsersPanel {...identityPanelProps(props)} />}
|
||||
{props.section === 'userGroups' && <UserGroupsPanel {...identityPanelProps(props)} />}
|
||||
{props.section === 'auditLogs' && <AuditLogsPanel auditLogs={props.data.auditLogs} message={props.operationMessage} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,6 +161,7 @@ function identityPanelProps(props: {
|
||||
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>;
|
||||
}) {
|
||||
return {
|
||||
@@ -167,6 +173,7 @@ function identityPanelProps(props: {
|
||||
onDeleteUserGroup: props.onDeleteUserGroup,
|
||||
onSaveTenant: props.onSaveTenant,
|
||||
onSaveUser: props.onSaveUser,
|
||||
onSetUserWalletBalance: props.onSetUserWalletBalance,
|
||||
onSaveUserGroup: props.onSaveUserGroup,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -34,6 +34,7 @@ const adminPaths: Record<AdminSection, string> = {
|
||||
tenants: '/admin/tenants',
|
||||
users: '/admin/users',
|
||||
userGroups: '/admin/user-groups',
|
||||
auditLogs: '/admin/audit-logs',
|
||||
runtime: '/admin/runtime',
|
||||
accessRules: '/admin/access-rules',
|
||||
};
|
||||
|
||||
@@ -621,12 +621,21 @@
|
||||
min-width: 880px;
|
||||
}
|
||||
|
||||
.userTable .shTableRow,
|
||||
.userTable .shTableRow {
|
||||
grid-template-columns: minmax(210px, 1.25fr) minmax(100px, 0.55fr) minmax(150px, 0.8fr) minmax(150px, 0.8fr) minmax(120px, 0.65fr) minmax(100px, 0.55fr) minmax(88px, 0.5fr) minmax(112px, 0.6fr);
|
||||
min-width: 1120px;
|
||||
}
|
||||
|
||||
.groupTable .shTableRow {
|
||||
grid-template-columns: minmax(210px, 1.25fr) minmax(100px, 0.55fr) minmax(150px, 0.8fr) minmax(150px, 0.8fr) minmax(100px, 0.55fr) minmax(88px, 0.5fr) minmax(86px, 0.5fr);
|
||||
min-width: 980px;
|
||||
}
|
||||
|
||||
.auditLogTable .shTableRow {
|
||||
grid-template-columns: minmax(150px, 0.9fr) minmax(140px, 0.8fr) minmax(140px, 0.8fr) minmax(260px, 1.4fr) minmax(160px, 0.9fr);
|
||||
min-width: 920px;
|
||||
}
|
||||
|
||||
.identityTableName {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
|
||||
@@ -14,6 +14,7 @@ export type AdminSection =
|
||||
| 'tenants'
|
||||
| 'users'
|
||||
| 'userGroups'
|
||||
| 'auditLogs'
|
||||
| 'runtime'
|
||||
| 'accessRules';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user