Files
Genarrative/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx
T
k88936 b8309eb3ae 重构(后台金额): 抽出统一的元金额格式化
- shared AdminFormat 新增 formatAdminCurrencyCents(分 → ¥x.xx)并从 components 导出
- AdminUserDetailDialog / AdminMembershipPlanPage / AdminRechargeOrderPage / AdminRechargeProductPage 删除各自重复的 formatMoney/formatPrice
- 补充 formatAdminCurrencyCents 单测,避免后台各页面金额格式漂移
2026-10-05 18:12:22 +08:00

1668 lines
52 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 {
AdminActionRow,
AdminAlert,
AdminButton,
AdminDialog,
AdminField,
AdminFormRow,
AdminInfoList,
AdminListPanel,
AdminPage,
AdminPageHeading,
AdminPanel,
AdminPanelHeading,
AdminTag,
AdminTagList,
formatAdminCurrencyCents,
} from '@genarrative/shared/components';
import {
AlertTriangle,
FileCheck2,
FileInput,
RefreshCcw,
RotateCcw,
Search,
Undo2,
} from 'lucide-react';
import { FormEvent, useEffect, useRef, useState } from 'react';
import {
executeAdminRechargeRefund,
formatAdminApiError,
isAdminApiError,
listAdminRechargeOrders,
previewAdminRechargeRefund,
registerAdminRechargeRefund,
resolveAdminRechargeRefundManualReview,
} from '../api/adminApiClient';
import type {
AdminMembershipOrderChangePayload,
AdminRechargeOrderEntryPayload,
AdminRechargeOrderListQuery,
AdminRechargeRefundActionResponse,
AdminRechargeRefundPayload,
AdminRechargeRefundPreviewResponse,
} from '../api/adminApiTypes';
import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import {
formatMembershipCycleKind,
formatMembershipPlan,
} from '../config/membershipDisplay';
import { handlePageError } from './pageUtils';
interface AdminRechargeOrderPageProps {
token: string;
onUnauthorized: (message?: string) => void;
}
interface RechargeFilters {
orderId: string;
providerTransactionId: string;
userId: string;
publicUserCode: string;
paymentChannel: string;
status: string;
createdAfter: string;
createdBefore: string;
limit: string;
}
type RefundMode = 'remaining' | 'partial';
interface PersistedRefundRequestContext {
orderId: string;
amountCents: number;
requestId: string;
reason: string;
outRefundNo?: string;
createdAtMillis: number;
}
interface ManualReviewTarget {
order: AdminRechargeOrderEntryPayload;
refund: AdminRechargeRefundPayload;
}
const REFUND_REQUEST_CONTEXT_STORAGE_KEY =
'genarrative.admin.recharge-refund-contexts.v2';
const REFUND_REQUEST_CONTEXT_TTL_MILLIS = 2 * 60 * 60 * 1_000;
const defaultFilters: RechargeFilters = {
orderId: '',
providerTransactionId: '',
userId: '',
publicUserCode: '',
paymentChannel: '',
status: '',
createdAfter: '',
createdBefore: '',
limit: '100',
};
export function AdminRechargeOrderPage({
token,
onUnauthorized,
}: AdminRechargeOrderPageProps) {
const [filters, setFilters] = useState<RechargeFilters>(defaultFilters);
const [orders, setOrders] = useState<AdminRechargeOrderEntryPayload[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [listError, setListError] = useState('');
const [refundOrder, setRefundOrder] =
useState<AdminRechargeOrderEntryPayload | null>(null);
const [refundMode, setRefundMode] = useState<RefundMode>('remaining');
const [refundAmountYuan, setRefundAmountYuan] = useState('');
const [refundReason, setRefundReason] = useState('');
const [preview, setPreview] =
useState<AdminRechargeRefundPreviewResponse | null>(null);
const [isPreviewing, setIsPreviewing] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [refundError, setRefundError] = useState('');
const [providerStatusUnknown, setProviderStatusUnknown] = useState(false);
const [lastAction, setLastAction] =
useState<AdminRechargeRefundActionResponse | null>(null);
const [registerOpen, setRegisterOpen] = useState(false);
const [outRefundNo, setOutRefundNo] = useState('');
const [registerError, setRegisterError] = useState('');
const [isRegistering, setIsRegistering] = useState(false);
const [refundRequestId, setRefundRequestId] = useState('');
const [resumableRefundRequest, setResumableRefundRequest] =
useState<PersistedRefundRequestContext | null>(null);
const [manualReviewTarget, setManualReviewTarget] =
useState<ManualReviewTarget | null>(null);
const [manualReviewReason, setManualReviewReason] = useState('');
const [manualReviewError, setManualReviewError] = useState('');
const [isResolvingManualReview, setIsResolvingManualReview] = useState(false);
const lastQueryRef = useRef<AdminRechargeOrderListQuery>({ limit: 100 });
const submitLockRef = useRef(false);
const registerLockRef = useRef(false);
const manualReviewLockRef = useRef(false);
const { confirmWrite, confirmDialog } = useAdminWriteConfirm();
const visibleDebtOrder = findDebtOrder(lastAction?.order, orders);
useEffect(() => {
void refreshOrders(lastQueryRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
useEffect(() => {
if (
refundOrder &&
refundMode === 'remaining' &&
!preview &&
!providerStatusUnknown
) {
setRefundAmountYuan(
formatCentsInput(refundOrder.remainingRefundableCents),
);
}
}, [preview, providerStatusUnknown, refundMode, refundOrder]);
async function refreshOrders(query = lastQueryRef.current) {
setIsLoading(true);
setListError('');
try {
const response = await listAdminRechargeOrders(token, query);
setOrders(response.entries);
setRefundOrder((current) =>
current
? (response.entries.find(
(entry) => entry.orderId === current.orderId,
) ?? current)
: current,
);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setListError);
} finally {
setIsLoading(false);
}
}
async function handleSearch(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const query = buildRechargeOrderQuery(filters);
lastQueryRef.current = query;
await refreshOrders(query);
}
async function handleResetFilters() {
setFilters(defaultFilters);
const query = { limit: 100 };
lastQueryRef.current = query;
await refreshOrders(query);
}
function openRefund(order: AdminRechargeOrderEntryPayload) {
const persistedContext = findPersistedRefundRequestContext(order, token);
if (order.activeHold && !persistedContext) {
setOutRefundNo(order.activeHold.outRefundNo);
setRegisterError('');
setRegisterOpen(true);
return;
}
const amountCents =
order.activeHold?.refundCents ??
persistedContext?.amountCents ??
order.remainingRefundableCents;
const hasUncertainRequest = Boolean(order.activeHold || persistedContext);
setRefundOrder(order);
setRefundMode(
amountCents === order.remainingRefundableCents ? 'remaining' : 'partial',
);
setRefundAmountYuan(formatCentsInput(amountCents));
setRefundReason(order.activeHold?.reason ?? persistedContext?.reason ?? '');
setPreview(null);
setRefundError('');
setProviderStatusUnknown(hasUncertainRequest);
setRefundRequestId(persistedContext?.requestId ?? createRequestId());
setResumableRefundRequest(
order.activeHold && persistedContext ? persistedContext : null,
);
}
function closeRefund() {
if (isSubmitting) {
return;
}
setRefundOrder(null);
setPreview(null);
setRefundError('');
setResumableRefundRequest(null);
}
function updateRefundAmount(nextValue: string) {
if (refundOrder) {
removePersistedRefundRequestContext(token, refundOrder.orderId);
}
setRefundAmountYuan(nextValue);
setPreview(null);
setRefundError('');
setProviderStatusUnknown(false);
setRefundRequestId(createRequestId());
setResumableRefundRequest(null);
}
function changeRefundMode(nextMode: RefundMode) {
if (!refundOrder) {
return;
}
setRefundMode(nextMode);
updateRefundAmount(
nextMode === 'remaining'
? formatCentsInput(refundOrder.remainingRefundableCents)
: '',
);
}
async function handlePreviewRefund() {
if (!refundOrder || isPreviewing || isSubmitting) {
return;
}
const amountCents = parseYuanToCents(refundAmountYuan);
if (!amountCents) {
setRefundError('请输入有效的退款金额');
return;
}
setIsPreviewing(true);
setRefundError('');
try {
const response = await previewAdminRechargeRefund(token, {
orderId: refundOrder.orderId,
refundAmountCents: amountCents,
});
setPreview(response);
setProviderStatusUnknown(false);
setRefundOrder(response.order);
upsertOrder(response.order);
if (!response.canSubmit) {
setRefundError(formatRefundBlockReason(response.blockReasonCode));
}
} catch (error: unknown) {
handleDialogError(error, setRefundError);
if (!(isAdminApiError(error) && error.status === 401)) {
void refreshOrders();
}
} finally {
setIsPreviewing(false);
}
}
async function handleExecuteRefund() {
if (
!refundOrder ||
(!preview?.canSubmit && !resumableRefundRequest) ||
submitLockRef.current ||
isSubmitting
) {
return;
}
const reason = normalizeRefundReason(refundReason);
if (!reason) {
setRefundError('请填写退款原因');
return;
}
const amountCents =
resumableRefundRequest?.amountCents ?? preview?.refundAmountCents;
if (!amountCents) {
return;
}
const requestId =
resumableRefundRequest?.requestId || refundRequestId || createRequestId();
setRefundRequestId(requestId);
const confirmed = await confirmWrite({
action: '发起微信退款并追回泥点',
target: `${refundOrder.orderId} / ${formatAdminCurrencyCents(amountCents)}`,
});
if (!confirmed || submitLockRef.current) {
return;
}
submitLockRef.current = true;
setIsSubmitting(true);
setRefundError('');
const requestContext: PersistedRefundRequestContext = {
orderId: refundOrder.orderId,
amountCents,
requestId,
reason,
outRefundNo: refundOrder.activeHold?.outRefundNo,
createdAtMillis: resumableRefundRequest?.createdAtMillis ?? Date.now(),
};
persistRefundRequestContext(token, requestContext);
try {
const response = await executeAdminRechargeRefund(token, {
orderId: refundOrder.orderId,
refundAmountCents: amountCents,
requestId,
reason,
});
setLastAction(response);
setRefundOrder(response.order);
upsertOrder(response.order);
setPreview(null);
if (response.providerStatusUnknown) {
const pendingRequestContext = {
...requestContext,
outRefundNo: response.outRefundNo,
};
persistRefundRequestContext(token, pendingRequestContext);
setResumableRefundRequest(
response.order.activeHold ? pendingRequestContext : null,
);
setProviderStatusUnknown(true);
setRefundError(unknownProviderStatusMessage);
} else {
removePersistedRefundRequestContext(
token,
refundOrder.orderId,
response.outRefundNo,
);
setProviderStatusUnknown(false);
setResumableRefundRequest(null);
}
} catch (error: unknown) {
if (!isAdminApiError(error)) {
setProviderStatusUnknown(true);
setPreview(null);
setResumableRefundRequest(requestContext);
setRefundError(unknownProviderStatusMessage);
} else {
removePersistedRefundRequestContext(token, refundOrder.orderId);
setResumableRefundRequest(null);
handleDialogError(error, setRefundError);
}
} finally {
submitLockRef.current = false;
setIsSubmitting(false);
}
}
async function handleRegisterRefund(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const normalizedOutRefundNo = outRefundNo.trim();
if (!normalizedOutRefundNo || registerLockRef.current) {
if (!normalizedOutRefundNo) {
setRegisterError('请填写商户退款单号 out_refund_no');
}
return;
}
const confirmed = await confirmWrite({
action: '登记商户平台退款',
target: normalizedOutRefundNo,
});
if (!confirmed || registerLockRef.current) {
return;
}
registerLockRef.current = true;
setIsRegistering(true);
setRegisterError('');
try {
const response = await registerAdminRechargeRefund(token, {
outRefundNo: normalizedOutRefundNo,
});
setLastAction(response);
upsertOrder(response.order);
removePersistedRefundRequestContext(
token,
response.order.orderId,
normalizedOutRefundNo,
);
setOutRefundNo('');
setRegisterOpen(false);
} catch (error: unknown) {
handleDialogError(error, setRegisterError);
} finally {
registerLockRef.current = false;
setIsRegistering(false);
}
}
function openManualReview(
order: AdminRechargeOrderEntryPayload,
refund: AdminRechargeRefundPayload,
) {
setManualReviewTarget({ order, refund });
setManualReviewReason('');
setManualReviewError('');
}
async function handleResolveManualReview(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const reason = manualReviewReason.trim();
const expectedErrorCode = manualReviewTarget?.refund.lastErrorCode?.trim();
if (
!manualReviewTarget ||
manualReviewLockRef.current ||
!reason ||
!expectedErrorCode
) {
if (!reason) {
setManualReviewError('请填写人工复核原因');
} else if (!expectedErrorCode) {
setManualReviewError('退款人工复核状态已变化,请刷新后重试');
}
return;
}
const confirmed = await confirmWrite({
action: '确认微信退款并追回泥点',
target: `${manualReviewTarget.order.orderId} / ${manualReviewTarget.refund.outRefundNo}`,
});
if (!confirmed || manualReviewLockRef.current) {
return;
}
manualReviewLockRef.current = true;
setIsResolvingManualReview(true);
setManualReviewError('');
try {
const response = await resolveAdminRechargeRefundManualReview(token, {
outRefundNo: manualReviewTarget.refund.outRefundNo,
reason,
expectedErrorCode,
});
setLastAction(response);
upsertOrder(response.order);
setManualReviewTarget(null);
setManualReviewReason('');
} catch (error: unknown) {
handleDialogError(error, setManualReviewError);
} finally {
manualReviewLockRef.current = false;
setIsResolvingManualReview(false);
}
}
function handleDialogError(
error: unknown,
setMessage: (message: string) => void,
) {
if (isAdminApiError(error) && error.status === 401) {
onUnauthorized('登录状态已失效');
return;
}
setMessage(formatAdminApiError(error));
}
function upsertOrder(nextOrder: AdminRechargeOrderEntryPayload) {
setOrders((current) => {
const index = current.findIndex(
(entry) => entry.orderId === nextOrder.orderId,
);
if (index < 0) {
return [nextOrder, ...current];
}
return current.map((entry) =>
entry.orderId === nextOrder.orderId ? nextOrder : entry,
);
});
}
return (
<AdminPage wide>
<AdminPageHeading
title="充值管理"
description="充值订单、微信退款与泥点追回"
actions={
<AdminActionRow>
<AdminButton
variant="secondary"
type="button"
onClick={() => {
setRegisterError('');
setRegisterOpen(true);
}}
>
<FileInput size={17} aria-hidden="true" />
<span>登记商户退款</span>
</AdminButton>
<AdminButton
variant="secondary"
disabled={isLoading}
type="button"
onClick={() => void refreshOrders()}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</AdminButton>
</AdminActionRow>
}
/>
{visibleDebtOrder ? (
<RefundDebtBanner order={visibleDebtOrder} />
) : lastAction ? (
<RefundActionBanner action={lastAction} />
) : null}
{listError ? <AdminAlert>{listError}</AdminAlert> : null}
<AdminPanel as="form" onSubmit={handleSearch}>
<div className="admin-recharge-filter-grid">
<FilterInput
label="订单号"
value={filters.orderId}
onChange={(value) =>
setFilters({
...filters,
orderId: value,
})
}
/>
<FilterInput
label="微信交易单号"
value={filters.providerTransactionId}
onChange={(value) =>
setFilters({
...filters,
providerTransactionId: value,
})
}
/>
<FilterInput
label="内部用户 ID"
value={filters.userId}
onChange={(value) =>
setFilters({
...filters,
userId: value,
publicUserCode: value.trim() ? '' : filters.publicUserCode,
})
}
/>
<FilterInput
label="陶泥号"
value={filters.publicUserCode}
onChange={(value) =>
setFilters({
...filters,
publicUserCode: value,
userId: value.trim() ? '' : filters.userId,
})
}
/>
<AdminField label="支付渠道">
<select
value={filters.paymentChannel}
onChange={(event) =>
setFilters({
...filters,
paymentChannel: event.target.value,
})
}
>
<option value="">全部</option>
<option value="wechat_native">微信 Native</option>
<option value="wechat_jsapi">微信 JSAPI</option>
<option value="wechat_h5">微信 H5</option>
<option value="wechat_mp">微信小程序</option>
<option value="wechat_mp_virtual">微信小程序虚拟支付</option>
</select>
</AdminField>
<AdminField label="订单状态">
<select
value={filters.status}
onChange={(event) =>
setFilters({
...filters,
status: event.target.value,
})
}
>
<option value="">全部</option>
<option value="pending">待支付</option>
<option value="paid">已支付</option>
<option value="refunded">已退款</option>
<option value="closed">已关闭</option>
<option value="failed">支付失败</option>
<option value="expired">已过期</option>
</select>
</AdminField>
<AdminField label="创建时间起">
<input
type="datetime-local"
value={filters.createdAfter}
onChange={(event) =>
setFilters({
...filters,
createdAfter: event.target.value,
})
}
/>
</AdminField>
<AdminField label="创建时间止">
<input
type="datetime-local"
value={filters.createdBefore}
onChange={(event) =>
setFilters({
...filters,
createdBefore: event.target.value,
})
}
/>
</AdminField>
<AdminField label="条数" compact>
<input
min={1}
max={500}
step={1}
type="number"
value={filters.limit}
onChange={(event) =>
setFilters({
...filters,
limit: event.target.value,
})
}
/>
</AdminField>
<AdminActionRow className="admin-recharge-filter-actions">
<AdminButton variant="secondary" type="submit">
<Search size={17} aria-hidden="true" />
<span>查询</span>
</AdminButton>
<AdminButton
variant="ghost"
title="重置筛选"
type="button"
onClick={() => void handleResetFilters()}
className="admin-query-reset-button"
>
<RotateCcw size={16} aria-hidden="true" />
<span>重置</span>
</AdminButton>
</AdminActionRow>
</div>
</AdminPanel>
<AdminListPanel
columns={[
{ key: 'user', label: '用户' },
{ key: 'order', label: '订单' },
{ key: 'payment', label: '支付' },
{ key: 'paidAmount', label: '实付' },
{ key: 'points', label: '发放泥点' },
{ key: 'refund', label: '退款与追回' },
{ key: 'wallet', label: '钱包' },
{ key: 'status', label: '状态' },
{ key: 'actions', label: '操作' },
]}
busy={isLoading}
busyText="正在读取充值订单"
count={<>{orders.length} 条</>}
emptyText="暂无匹配订单"
renderRow={(order) => (
<RechargeOrderRow
key={order.orderId}
order={order}
token={token}
onRefund={openRefund}
onManualReview={openManualReview}
onUnauthorized={onUnauthorized}
/>
)}
rows={orders}
tableClassName="admin-recharge-table"
title="充值订单"
/>
{refundOrder ? (
<AdminDialog
closeDisabled={isSubmitting}
closeLabel="关闭退款面板"
closeOnEscape={!isSubmitting}
description={refundOrder.orderId}
panelClassName="admin-recharge-dialog"
title="退款处理"
titleProps={{ id: 'admin-refund-dialog-title' }}
onClose={closeRefund}
>
<div className="admin-refund-summary-grid">
<Metric
label="订单实付"
value={formatAdminCurrencyCents(refundOrder.paidAmountCents)}
/>
<Metric
label="累计已退"
value={formatAdminCurrencyCents(
refundOrder.cumulativeSuccessRefundCents,
)}
/>
<Metric
label="剩余可退"
value={formatAdminCurrencyCents(
refundOrder.remainingRefundableCents,
)}
/>
<Metric
label="永久泥点"
value={String(refundOrder.wallet.permanentPoints)}
/>
<Metric
label="占用泥点"
value={String(refundOrder.wallet.heldPoints)}
/>
<Metric
label="退款欠账"
value={String(refundOrder.wallet.refundDebtPoints)}
/>
</div>
{refundOrder.membershipChange ? (
<div className="admin-refund-summary-grid">
<Metric
label="会员变更"
value={formatMembershipChangeSummary(
refundOrder.membershipChange,
)}
/>
<Metric
label="补点"
value={`${refundOrder.membershipChange.grantedPointsDelta} 泥点`}
/>
<Metric
label="变更后到期"
value={
refundOrder.membershipChange.expiresAtAfterMicros
? formatMicros(
refundOrder.membershipChange.expiresAtAfterMicros,
)
: '-'
}
/>
</div>
) : null}
<div
className="admin-segmented-control admin-refund-mode"
role="tablist"
>
<button
data-active={refundMode === 'remaining'}
disabled={providerStatusUnknown || isPreviewing || isSubmitting}
type="button"
onClick={() => changeRefundMode('remaining')}
>
退剩余全额
</button>
<button
data-active={refundMode === 'partial'}
disabled={providerStatusUnknown || isPreviewing || isSubmitting}
type="button"
onClick={() => changeRefundMode('partial')}
>
部分退款
</button>
</div>
<AdminFormRow>
<AdminField label="退款金额(元)">
<input
aria-label="退款金额(元)"
disabled={
refundMode === 'remaining' ||
providerStatusUnknown ||
isPreviewing ||
isSubmitting
}
inputMode="decimal"
value={refundAmountYuan}
onChange={(event) => updateRefundAmount(event.target.value)}
/>
</AdminField>
<AdminField label="退款原因">
<input
aria-label="退款原因"
disabled={isSubmitting || Boolean(resumableRefundRequest)}
maxLength={80}
value={refundReason}
onChange={(event) => setRefundReason(event.target.value)}
/>
</AdminField>
</AdminFormRow>
{providerStatusUnknown ? (
<AdminAlert tone="warning">
<AlertTriangle size={17} aria-hidden="true" />
<span>
{resumableRefundRequest
? resumableRefundRequestMessage
: unknownProviderStatusMessage}
</span>
</AdminAlert>
) : null}
{refundError && !providerStatusUnknown ? (
<AdminAlert>{refundError}</AdminAlert>
) : null}
{preview ? (
<section className="admin-refund-preview" aria-label="退款预检结果">
<AdminPanelHeading
title="退款预检"
count={preview.canSubmit ? '可以提交' : '已阻止'}
/>
<AdminInfoList
items={[
{
label: <>微信账单核验</>,
value: (
<>
{preview.paymentCheck.verified ? '已核验' : '未核验'} /{' '}
{preview.paymentCheck.tradeState || '-'}
</>
),
},
{
label: <>本次应追回</>,
value: <>{preview.incrementalRecoveryPoints} 泥点</>,
},
{
label: <>刷新退款单</>,
value: <>{preview.paymentCheck.knownRefundsRefreshed} 笔</>,
},
{
label: <>预检后剩余可退</>,
value: (
<>
{formatAdminCurrencyCents(
preview.remainingRefundableCents,
)}
</>
),
},
]}
/>
</section>
) : null}
<div className="admin-confirm-actions">
<AdminButton
variant="secondary"
disabled={
isPreviewing || isSubmitting || Boolean(resumableRefundRequest)
}
type="button"
onClick={() => void handlePreviewRefund()}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isPreviewing ? '核验中' : '核验支付账单'}</span>
</AdminButton>
<AdminButton
variant="danger"
disabled={
(!preview?.canSubmit && !resumableRefundRequest) ||
!refundReason.trim() ||
isSubmitting ||
isPreviewing
}
type="button"
onClick={() => void handleExecuteRefund()}
>
<Undo2 size={17} aria-hidden="true" />
<span>
{isSubmitting
? '提交中'
: resumableRefundRequest
? '继续核对退款'
: '确认退款'}
</span>
</AdminButton>
</div>
</AdminDialog>
) : null}
{registerOpen ? (
<AdminDialog
as="form"
closeDisabled={isRegistering}
closeLabel="关闭登记面板"
closeOnBackdrop={!isRegistering}
closeOnEscape={!isRegistering}
description="填写退款详情中的 out_refund_no"
panelClassName="admin-confirm-panel"
title="登记商户平台退款"
titleProps={{ id: 'admin-register-refund-title' }}
onClose={() => setRegisterOpen(false)}
onSubmit={handleRegisterRefund}
>
<AdminField label="商户退款单号(out_refund_no)">
<input
autoFocus
aria-label="商户退款单号 out_refund_no"
placeholder="从退款详情复制,不能填写微信退款单号 refund_id"
value={outRefundNo}
onChange={(event) => setOutRefundNo(event.target.value)}
/>
</AdminField>
{registerError ? <AdminAlert>{registerError}</AdminAlert> : null}
<div className="admin-confirm-actions">
<AdminButton
variant="secondary"
disabled={isRegistering}
type="button"
onClick={() => setRegisterOpen(false)}
>
取消
</AdminButton>
<AdminButton
variant="danger"
disabled={isRegistering || !outRefundNo.trim()}
type="submit"
>
{isRegistering ? '登记中' : '查询并登记'}
</AdminButton>
</div>
</AdminDialog>
) : null}
{manualReviewTarget ? (
<AdminDialog
as="form"
closeDisabled={isResolvingManualReview}
closeLabel="关闭人工复核面板"
closeOnBackdrop={!isResolvingManualReview}
closeOnEscape={!isResolvingManualReview}
description={manualReviewTarget.refund.outRefundNo}
panelClassName="admin-confirm-panel"
title="确认退款人工复核"
titleProps={{ id: 'admin-refund-manual-review-title' }}
onClose={() => setManualReviewTarget(null)}
onSubmit={handleResolveManualReview}
>
<AdminInfoList
items={[
{
label: <>冲突类型</>,
value: (
<>
{formatManualReviewErrorCode(
manualReviewTarget.refund.lastErrorCode,
)}
</>
),
},
{
label: <>本地微信交易单号</>,
value: (
<>{manualReviewTarget.order.providerTransactionId || '-'}</>
),
},
{
label: <>退款微信交易单号</>,
value: <>{manualReviewTarget.refund.providerTransactionId}</>,
},
{
label: <>本地订单总额</>,
value: (
<>
{formatAdminCurrencyCents(
manualReviewTarget.order.amountCents,
)}
</>
),
},
{
label: <>退款订单总额</>,
value: (
<>
{formatAdminCurrencyCents(
manualReviewTarget.refund.totalCents,
)}
</>
),
},
{
label: <>本次退款金额</>,
value: (
<>
{formatAdminCurrencyCents(
manualReviewTarget.refund.refundCents,
)}
</>
),
},
]}
aria-label="退款冲突核对信息"
/>
<AdminField label="人工复核原因">
<textarea
autoFocus
aria-label="人工复核原因"
disabled={isResolvingManualReview}
rows={3}
value={manualReviewReason}
onChange={(event) => setManualReviewReason(event.target.value)}
/>
</AdminField>
{manualReviewError ? (
<AdminAlert>{manualReviewError}</AdminAlert>
) : null}
<div className="admin-confirm-actions">
<AdminButton
variant="secondary"
disabled={isResolvingManualReview}
type="button"
onClick={() => setManualReviewTarget(null)}
>
取消
</AdminButton>
<AdminButton
variant="danger"
disabled={isResolvingManualReview || !manualReviewReason.trim()}
type="submit"
>
{isResolvingManualReview ? '处理中' : '确认并追回'}
</AdminButton>
</div>
</AdminDialog>
) : null}
{confirmDialog}
</AdminPage>
);
}
function FilterInput({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (value: string) => void;
}) {
return (
<AdminField label={label}>
<input
aria-label={label}
value={value}
onChange={(event) => onChange(event.target.value)}
/>
</AdminField>
);
}
function RechargeOrderRow({
order,
token,
onRefund,
onManualReview,
onUnauthorized,
}: {
order: AdminRechargeOrderEntryPayload;
token: string;
onRefund: (order: AdminRechargeOrderEntryPayload) => void;
onManualReview: (
order: AdminRechargeOrderEntryPayload,
refund: AdminRechargeRefundPayload,
) => void;
onUnauthorized: (message?: string) => void;
}) {
const userName = order.user?.displayName || '未读取用户资料';
const manualReviewRefund = findResolvableManualReviewRefund(order);
const resolvedManualReviewRefund = findResolvedManualReviewRefund(order);
return (
<tr>
<td>
<div className="admin-inline-identity">
<div>
<strong>{userName}</strong>
<small>{order.user?.publicUserCode || order.userId}</small>
</div>
<AdminUserReferenceButton
token={token}
userId={order.userId}
publicUserCode={order.user?.publicUserCode}
onUnauthorized={onUnauthorized}
/>
</div>
</td>
<td>
<span className="admin-mono-value">{order.orderId}</span>
<small>{formatMicros(order.createdAtMicros)}</small>
<small>{order.productTitle || order.productId}</small>
</td>
<td>
<span>{formatPaymentChannel(order.paymentChannel)}</span>
<small>{order.providerTransactionId || '暂无微信交易单号'}</small>
<small>
{order.paidAtMicros ? formatMicros(order.paidAtMicros) : '-'}
</small>
</td>
<td>
<strong>{formatOrderPaidAmount(order)}</strong>
{order.paidAmountCents > 0 ? null : (
<small>订单 {formatAdminCurrencyCents(order.amountCents)}</small>
)}
</td>
<td>
<span>{order.pointsDelta} 泥点</span>
{order.membershipChange ? (
<small>{formatMembershipChangeSummary(order.membershipChange)}</small>
) : null}
</td>
<td>
<span>
累计 {formatAdminCurrencyCents(order.cumulativeSuccessRefundCents)}
</span>
<small>
应追回 {order.targetRecoveryPoints} / 已追回 {order.recoveredPoints}
</small>
<small>欠账 {order.unrecoveredPoints} 泥点</small>
{order.activeHold ? (
<small>占用 {order.activeHold.heldPoints} 泥点</small>
) : null}
{resolvedManualReviewRefund ? (
<>
<small>
复核{' '}
{formatManualReviewErrorCode(
resolvedManualReviewRefund.manualReviewResolvedErrorCode,
)}{' '}
/{' '}
{resolvedManualReviewRefund.manualReviewResolvedByAdminUserId ||
'-'}
</small>
<small>
{resolvedManualReviewRefund.manualReviewResolutionReason || '-'} /{' '}
{formatMicros(
resolvedManualReviewRefund.manualReviewResolvedAtMicros ?? 0,
)}
</small>
</>
) : null}
</td>
<td>
<span>可消费 {order.wallet.spendableBalance}</span>
<small>
永久 {order.wallet.permanentPoints} / 月度{' '}
{order.wallet.monthlyPoints} / 占用 {order.wallet.heldPoints}
</small>
<AdminTagList as="div" className="admin-recharge-tags">
{order.wallet.manualFrozen ? <AdminTag>人工冻结</AdminTag> : null}
{order.wallet.refundDebtFrozen ? (
<AdminTag>退款欠账限制</AdminTag>
) : null}
</AdminTagList>
</td>
<td>
<span>{formatOrderStatus(order.status)}</span>
<small>{formatRecoveryStatus(order.recoveryStatus)}</small>
</td>
<td>
<div className="admin-stack">
<AdminButton
variant="danger"
disabled={
!order.activeHold &&
(!order.refundEligible || order.remainingRefundableCents <= 0)
}
title={
order.activeHold
? '继续核对进行中的退款'
: order.refundEligible
? '发起退款'
: formatRefundBlockReason(order.refundBlockReasonCode)
}
type="button"
onClick={() => onRefund(order)}
className="admin-recharge-refund-button"
>
<Undo2 size={16} aria-hidden="true" />
<span>{order.activeHold ? '核对退款' : '退款'}</span>
</AdminButton>
{manualReviewRefund ? (
<AdminButton
variant="icon"
aria-label="人工复核"
title="人工复核"
type="button"
onClick={() => onManualReview(order, manualReviewRefund)}
>
<FileCheck2 size={16} aria-hidden="true" />
</AdminButton>
) : null}
</div>
</td>
</tr>
);
}
function RefundActionBanner({
action,
}: {
action: AdminRechargeRefundActionResponse;
}) {
if (
action.providerStatusUnknown ||
['manual_review', 'abnormal', 'closed'].includes(action.resultCode)
) {
return (
<AdminAlert tone="warning" className="admin-refund-result-banner">
<AlertTriangle size={18} aria-hidden="true" />
<div>
<strong>退款仍需核对</strong>
<span>
{action.outRefundNo} / {action.providerStatus} / {action.resultCode}
</span>
</div>
</AdminAlert>
);
}
return (
<AdminAlert tone="success" className="admin-refund-result-banner">
<div>
<strong>退款处理已受理</strong>
<span>
{action.outRefundNo} / {action.providerStatus} / {action.resultCode}
</span>
</div>
</AdminAlert>
);
}
function RefundDebtBanner({
order,
}: {
order: AdminRechargeOrderEntryPayload;
}) {
return (
<AdminAlert tone="warning" className="admin-refund-result-banner">
<AlertTriangle size={18} aria-hidden="true" />
<div>
<strong>
退款异常欠账{' '}
{Math.max(order.wallet.refundDebtPoints, order.unrecoveredPoints)}{' '}
泥点
</strong>
<span>
支付侧退款已记录,当前泥点不足,已限制继续消费并进入人工处理。
</span>
</div>
</AdminAlert>
);
}
function Metric({ label, value }: { label: string; value: string }) {
return (
<div className="admin-recharge-metric">
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
export function buildRechargeOrderQuery(
filters: RechargeFilters,
): AdminRechargeOrderListQuery {
const limit = Number.parseInt(filters.limit, 10);
return {
orderId: filters.orderId,
providerTransactionId: filters.providerTransactionId,
userId: filters.userId,
publicUserCode: filters.publicUserCode,
paymentChannel: filters.paymentChannel,
status: filters.status,
createdAfter: datetimeLocalToRfc3339(filters.createdAfter),
createdBefore: datetimeLocalToRfc3339(filters.createdBefore),
limit: Number.isFinite(limit) ? Math.min(500, Math.max(1, limit)) : 100,
};
}
function parseYuanToCents(value: string) {
const normalized = value.trim();
const match = /^(\d+)(?:\.(\d{1,2}))?$/u.exec(normalized);
if (!match) {
return null;
}
const yuan = Number.parseInt(match[1] ?? '0', 10);
const decimal = (match[2] ?? '').padEnd(2, '0');
const cents = yuan * 100 + Number.parseInt(decimal || '0', 10);
return Number.isSafeInteger(cents) && cents > 0 ? cents : null;
}
function formatMembershipChangeSummary(
change: AdminMembershipOrderChangePayload,
): string {
const action = change.changeKind === 'upgrade' ? '升级' : '新购';
const before = change.planBefore
? formatMembershipPlan(change.planBefore)
: null;
const after = change.planAfter
? formatMembershipPlan(change.planAfter)
: null;
const planPath =
before && after && before !== after
? `${before}→${after}`
: (after ?? before ?? '-');
const cycleKind = change.cycleKindAfter ?? change.cycleKindBefore;
const cycle = cycleKind ? ` / ${formatMembershipCycleKind(cycleKind)}` : '';
return `${action} ${planPath}${cycle}`;
}
/** 实付只属于真正支付过的订单:未支付 / 已关闭 / 已过期订单显示“未支付”。 */
function formatOrderPaidAmount(order: AdminRechargeOrderEntryPayload) {
return order.paidAmountCents > 0
? formatAdminCurrencyCents(order.paidAmountCents)
: '未支付';
}
function formatCentsInput(cents: number) {
return (cents / 100).toFixed(2);
}
function formatMicros(value: number) {
if (!Number.isFinite(value) || value <= 0) {
return '-';
}
return new Date(Math.floor(value / 1000)).toLocaleString('zh-CN', {
hour12: false,
});
}
function formatOrderStatus(status: string) {
const labels: Record<string, string> = {
pending: '待支付',
paid: '已支付',
refunded: '已退款',
closed: '已关闭',
failed: '支付失败',
expired: '已过期',
};
return labels[status.toLowerCase()] ?? status;
}
function formatPaymentChannel(channel: string) {
const labels: Record<string, string> = {
wechat_native: '微信 Native',
wechat_jsapi: '微信 JSAPI',
wechat_h5: '微信 H5',
wechat_mp: '微信小程序',
wechat_mp_virtual: '微信小程序虚拟支付',
};
return labels[channel.toLowerCase()] ?? channel;
}
function formatRecoveryStatus(status?: string | null) {
if (!status) {
return '无退款追回';
}
const labels: Record<string, string> = {
pending: '等待追回',
applied: '已完成追回',
shortfall: '退款异常欠账',
manual_review: '人工处理中',
not_applicable: '无需追回',
};
return labels[status.toLowerCase()] ?? status;
}
function formatRefundBlockReason(code?: string | null) {
if (!code) {
return '当前订单不可退款';
}
const labels: Record<string, string> = {
order_not_paid: '订单尚未支付',
payment_channel_not_supported: '首期仅支持普通微信 V3 充值退款',
membership_not_supported: '首期不支持会员或虚拟支付退款',
refund_in_progress: '当前订单已有退款泥点占用,请先完成对账',
insufficient_permanent_points: '用户永久泥点不足,本次不会向微信发起退款',
provider_transaction_missing: '充值订单缺少微信支付交易单号',
fully_refunded: '订单已无剩余可退款金额',
wechat_order_not_paid: '微信支付账单尚未确认可退款',
wechat_refund_not_reconciled: '微信侧已有退款,但本地尚未完成登记或对账',
refund_precondition_failed: '退款前置条件未满足,请人工核对',
invalid_refund_amount: '退款金额必须大于 0 且不超过剩余可退金额',
};
return labels[code.toLowerCase()] ?? `当前不可退款:${code}`;
}
function formatManualReviewErrorCode(code?: string | null) {
const normalized = code?.trim().toLowerCase();
const labels: Record<string, string> = {
provider_transaction_id_mismatch: '微信交易单号不一致',
order_total_mismatch: '订单总额不一致',
};
return normalized ? (labels[normalized] ?? normalized) : '-';
}
function normalizeRefundReason(value: string) {
return Array.from(value.trim()).slice(0, 80).join('');
}
function datetimeLocalToRfc3339(value: string) {
const normalized = value.trim();
if (!normalized) {
return '';
}
const parsed = new Date(normalized);
return Number.isNaN(parsed.getTime()) ? normalized : parsed.toISOString();
}
function findDebtOrder(
actionOrder: AdminRechargeOrderEntryPayload | undefined,
orders: AdminRechargeOrderEntryPayload[],
) {
if (
actionOrder &&
(actionOrder.wallet.refundDebtPoints > 0 ||
actionOrder.unrecoveredPoints > 0)
) {
return actionOrder;
}
return orders.find(
(order) => order.wallet.refundDebtPoints > 0 || order.unrecoveredPoints > 0,
);
}
function findResolvableManualReviewRefund(
order: AdminRechargeOrderEntryPayload,
) {
if (order.productKind.trim().toLowerCase() !== 'points') {
return undefined;
}
const resolvableErrorCodes = new Set([
'provider_transaction_id_mismatch',
'order_total_mismatch',
]);
return order.refunds.find(
(refund) =>
refund.providerStatus.trim().toUpperCase() === 'SUCCESS' &&
refund.recoveryStatus.trim().toLowerCase() === 'manual_review' &&
resolvableErrorCodes.has(
refund.lastErrorCode?.trim().toLowerCase() ?? '',
) &&
!refund.manualReviewResolvedByAdminUserId &&
!refund.manualReviewResolutionReason &&
!refund.manualReviewResolvedAtMicros,
);
}
function findResolvedManualReviewRefund(order: AdminRechargeOrderEntryPayload) {
return order.refunds.find(
(refund) =>
Boolean(refund.manualReviewResolvedErrorCode) &&
Boolean(refund.manualReviewResolvedByAdminUserId) &&
Boolean(refund.manualReviewResolutionReason) &&
Boolean(refund.manualReviewResolvedAtMicros),
);
}
function createRequestId() {
if (
typeof crypto !== 'undefined' &&
typeof crypto.randomUUID === 'function'
) {
return crypto.randomUUID();
}
return `admin-refund-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
function findPersistedRefundRequestContext(
order: AdminRechargeOrderEntryPayload,
token: string,
) {
const context = readPersistedRefundRequestContexts(token)[order.orderId];
if (!context) {
return null;
}
const activeHold = order.activeHold;
if (activeHold) {
if (
context.amountCents !== activeHold.refundCents ||
context.reason.trim() !== activeHold.reason.trim() ||
(context.outRefundNo && context.outRefundNo !== activeHold.outRefundNo)
) {
return null;
}
const reconciledContext = {
...context,
amountCents: activeHold.refundCents,
reason: activeHold.reason,
outRefundNo: activeHold.outRefundNo,
};
persistRefundRequestContext(token, reconciledContext);
return reconciledContext;
}
return context.amountCents > 0 &&
context.amountCents <= order.remainingRefundableCents
? context
: null;
}
function readPersistedRefundRequestContexts(token: string) {
if (typeof sessionStorage === 'undefined') {
return {} as Record<string, PersistedRefundRequestContext>;
}
try {
const storageKey = refundRequestContextStorageKey(token);
const rawValue = sessionStorage.getItem(storageKey);
if (!rawValue) {
return {};
}
const parsedValue: unknown = JSON.parse(rawValue);
if (!parsedValue || typeof parsedValue !== 'object') {
return {};
}
const parsedEntries = Object.entries(parsedValue);
const contexts = Object.fromEntries(
Object.entries(parsedValue).filter(
(entry): entry is [string, PersistedRefundRequestContext] =>
isPersistedRefundRequestContext(entry[1]) &&
isPersistedRefundRequestContextFresh(entry[1]),
),
);
if (Object.keys(contexts).length !== parsedEntries.length) {
if (Object.keys(contexts).length === 0) {
sessionStorage.removeItem(storageKey);
} else {
sessionStorage.setItem(storageKey, JSON.stringify(contexts));
}
}
return contexts;
} catch {
return {};
}
}
function persistRefundRequestContext(
token: string,
context: PersistedRefundRequestContext,
) {
if (typeof sessionStorage === 'undefined') {
return;
}
try {
const storageKey = refundRequestContextStorageKey(token);
sessionStorage.setItem(
storageKey,
JSON.stringify({
...readPersistedRefundRequestContexts(token),
[context.orderId]: context,
}),
);
} catch {
// The in-memory dialog state remains usable when storage is unavailable.
}
}
function removePersistedRefundRequestContext(
token: string,
orderId: string,
outRefundNo?: string,
) {
if (typeof sessionStorage === 'undefined') {
return;
}
try {
const storageKey = refundRequestContextStorageKey(token);
const contexts = readPersistedRefundRequestContexts(token);
const context = contexts[orderId];
if (
!context ||
(outRefundNo &&
context.outRefundNo &&
context.outRefundNo !== outRefundNo)
) {
return;
}
delete contexts[orderId];
if (Object.keys(contexts).length === 0) {
sessionStorage.removeItem(storageKey);
} else {
sessionStorage.setItem(storageKey, JSON.stringify(contexts));
}
} catch {
// Ignore unavailable or externally corrupted session storage.
}
}
function isPersistedRefundRequestContext(
value: unknown,
): value is PersistedRefundRequestContext {
if (!value || typeof value !== 'object') {
return false;
}
const candidate = value as Partial<PersistedRefundRequestContext>;
return (
typeof candidate.orderId === 'string' &&
candidate.orderId.length > 0 &&
typeof candidate.amountCents === 'number' &&
Number.isSafeInteger(candidate.amountCents) &&
candidate.amountCents > 0 &&
typeof candidate.requestId === 'string' &&
candidate.requestId.length > 0 &&
typeof candidate.reason === 'string' &&
typeof candidate.createdAtMillis === 'number' &&
Number.isSafeInteger(candidate.createdAtMillis) &&
candidate.createdAtMillis > 0 &&
(typeof candidate.outRefundNo === 'undefined' ||
typeof candidate.outRefundNo === 'string')
);
}
function isPersistedRefundRequestContextFresh(
context: PersistedRefundRequestContext,
) {
const ageMillis = Date.now() - context.createdAtMillis;
return ageMillis >= 0 && ageMillis <= REFUND_REQUEST_CONTEXT_TTL_MILLIS;
}
function refundRequestContextStorageKey(token: string) {
let hash = 2_166_136_261;
for (let index = 0; index < token.length; index += 1) {
hash ^= token.charCodeAt(index);
hash = Math.imul(hash, 16_777_619);
}
return `${REFUND_REQUEST_CONTEXT_STORAGE_KEY}.${(hash >>> 0).toString(36)}`;
}
const unknownProviderStatusMessage =
'微信侧退款状态未知,请勿直接重复提交;请重新核验支付账单后再使用原请求继续处理。';
const resumableRefundRequestMessage =
'微信侧退款状态未知,已恢复原退款请求;请使用原请求继续核对,不能新建退款。';