feat: harden platform workflows and UI governance
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { adminApi, type ManualRechargePreflight, type ManualRechargeResult } from '@/api/adminApi';
|
||||
import { formatCents, isValidMoneyInput, yuanToMoneyUnits } from '@/utils/currency';
|
||||
import { Button } from './Button';
|
||||
import { Input } from './Input';
|
||||
import { Modal } from './Modal';
|
||||
import { Select } from './Select';
|
||||
import { Textarea } from './Textarea';
|
||||
|
||||
export type ManualRechargeTarget = {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
balanceCents: number;
|
||||
};
|
||||
|
||||
type ManualRechargeDialogProps = {
|
||||
initialTargetId?: string;
|
||||
lockTarget?: boolean;
|
||||
onClose: () => void;
|
||||
onCompleted: () => void | Promise<void>;
|
||||
open: boolean;
|
||||
targets: ManualRechargeTarget[];
|
||||
};
|
||||
|
||||
export function ManualRechargeDialog({ initialTargetId, lockTarget = false, onClose, onCompleted, open, targets }: ManualRechargeDialogProps) {
|
||||
const [tenantId, setTenantId] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [remark, setRemark] = useState('');
|
||||
const [review, setReview] = useState<ManualRechargePreflight | null>(null);
|
||||
const [result, setResult] = useState<ManualRechargeResult | null>(null);
|
||||
const [idempotencyKey, setIdempotencyKey] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setTenantId(initialTargetId ?? targets[0]?.id ?? '');
|
||||
setAmount('');
|
||||
setRemark('');
|
||||
setReview(null);
|
||||
setResult(null);
|
||||
setIdempotencyKey('');
|
||||
setError('');
|
||||
setSubmitting(false);
|
||||
}, [initialTargetId, open]);
|
||||
|
||||
if (!open) return null;
|
||||
const selectedTarget = targets.find((target) => target.id === tenantId);
|
||||
|
||||
function resetReview() {
|
||||
setReview(null);
|
||||
setResult(null);
|
||||
setIdempotencyKey('');
|
||||
setError('');
|
||||
}
|
||||
|
||||
function closeAndDestroyDraft() {
|
||||
if (submitting) return;
|
||||
setAmount('');
|
||||
setRemark('');
|
||||
resetReview();
|
||||
onClose();
|
||||
}
|
||||
|
||||
async function preflight() {
|
||||
if (!tenantId || !Number.isFinite(Number(amount)) || !isValidMoneyInput(amount, { allowNegative: true, allowZero: false })) {
|
||||
setError('请填写非 0 的充值金额;金额支持负数冲正。');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
const preview = await adminApi.preflightManualRecharge({ tenantId, amountCents: yuanToMoneyUnits(amount) });
|
||||
setReview(preview);
|
||||
setIdempotencyKey(`manual-recharge:${crypto.randomUUID()}`);
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '人工充值资格核对失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!review || !idempotencyKey) return;
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
const nextResult = await adminApi.createManualRecharge({
|
||||
tenantId,
|
||||
amountCents: review.amountCents,
|
||||
expectedAccountUpdatedAt: review.expectedAccountUpdatedAt,
|
||||
idempotencyKey,
|
||||
remark,
|
||||
});
|
||||
setResult(nextResult);
|
||||
await onCompleted();
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '人工充值失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const footer = (requestClose: () => void) => result ? (
|
||||
<Button onClick={closeAndDestroyDraft}>完成</Button>
|
||||
) : review ? (
|
||||
<>
|
||||
<Button disabled={submitting} onClick={resetReview} variant="ghost">返回修改</Button>
|
||||
<Button disabled={submitting} onClick={() => { void submit(); }}>{submitting ? '入账中...' : review.direction === 'topup' ? '确认充值' : '确认冲正'}</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button disabled={submitting} onClick={requestClose} variant="ghost">取消</Button>
|
||||
<Button disabled={submitting} onClick={() => { void preflight(); }}>{submitting ? '核对中...' : '下一步:核对信息'}</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
dirty={!result && !submitting && Boolean(amount.trim() || remark.trim() || review)}
|
||||
footer={({ requestClose }) => footer(requestClose)}
|
||||
onClose={closeAndDestroyDraft}
|
||||
open
|
||||
size="md"
|
||||
title={result ? '人工充值结果' : review ? '确认人工充值' : '企业人工充值'}
|
||||
>
|
||||
{result ? (
|
||||
<div className="manual-recharge-result" role="status">
|
||||
<strong>{result.amountCents > 0 ? '充值已入账' : '余额冲正已入账'}</strong>
|
||||
<span>订单号:{result.orderNo}</span>
|
||||
<span>充值后余额:¥{formatCents(result.balanceAfterCents)}</span>
|
||||
<span>操作单号:{result.operationId}{result.replayed ? '(幂等重放)' : ''}</span>
|
||||
</div>
|
||||
) : review ? (
|
||||
<div className="manual-recharge-review">
|
||||
<p>请核对以下资金变更,确认后将立即写入企业账户。</p>
|
||||
<dl>
|
||||
<div><dt>充值对象</dt><dd><strong>{review.tenant.name}</strong><span>{review.tenant.code} · {review.tenant.id}</span></dd></div>
|
||||
<div><dt>操作方向</dt><dd>{review.direction === 'topup' ? '余额充值' : '余额冲正'}</dd></div>
|
||||
<div><dt>当前现金余额</dt><dd>¥{formatCents(review.balanceCents)}</dd></div>
|
||||
<div><dt>本次变动</dt><dd className={review.amountCents > 0 ? 'is-positive' : 'is-negative'}>{review.amountCents > 0 ? '+' : '-'}¥{formatCents(Math.abs(review.amountCents))}</dd></div>
|
||||
<div><dt>预计现金余额</dt><dd><strong>¥{formatCents(review.balanceAfterCents)}</strong></dd></div>
|
||||
</dl>
|
||||
{remark.trim() ? <p className="manual-recharge-review__remark"><strong>备注:</strong>{remark.trim()}</p> : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-system-modal-form">
|
||||
{lockTarget ? (
|
||||
<Input disabled label="企业名称" value={selectedTarget?.name ?? ''} />
|
||||
) : (
|
||||
<Select label="企业名称" onChange={(event) => { setTenantId(event.target.value); resetReview(); }} options={targets.map((target) => ({ label: target.name, value: target.id }))} required value={tenantId} />
|
||||
)}
|
||||
<Input disabled label="当前现金余额" prefix="¥" value={formatCents(selectedTarget?.balanceCents ?? 0)} />
|
||||
<Input label="充值金额" onChange={(event) => { setAmount(event.target.value); resetReview(); }} prefix="¥" required step="0.0001" type="number" value={amount} />
|
||||
<Textarea className="admin-system-modal-form__wide" label="充值备注" onChange={(event) => { setRemark(event.target.value); resetReview(); }} rows={4} value={remark} />
|
||||
</div>
|
||||
)}
|
||||
{error ? <p className="form-error" role="alert">{error}</p> : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user