Files
ttstd-infinite-canvas/web/src/pages/admin/components/credit-operations-panel.tsx

805 lines
42 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 { useEffect, useMemo, useRef, useState } from "react";
import { App, Button, Drawer, Form, Input, InputNumber, Modal, Select } from "antd";
import type { ColumnsType } from "antd/es/table";
import { BadgeCheck, Coins, Plus, RefreshCw, Search, Trash2, Undo2 } from "lucide-react";
import { PaginationBar } from "@/components/layout/workspace-page";
import { formatCredits } from "@/constant/credits";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { listAdminUsers, type AdminReferenceData, type AdminUser } from "@/services/api/auth";
import { adjustAdminUserCredits, getAdminCreditPolicy, listAdminBillingOrders, resolveAdminBillingOrder, resolveAdminBillingOrders, updateAdminCreditPolicy, type BillingOrder } from "@/services/api/wallet";
import { AdminBatchBar, AdminDataTable, AdminRowActions, AdminStatusBadge, AdminTableEmpty } from "./admin-ui";
export type CreditOperation = "policy" | "adjustment" | null;
type AdjustmentFormValues = { userId: string; amount: number; note: string };
type ResolutionFormValues = { note: string };
type PolicyMultiplierRow = { model?: string; multiplier?: number };
type PolicyFormValues = { signupBonus: number; checkinBonus: number; defaultMultiplier: number; modelMultipliers: PolicyMultiplierRow[] };
type BillingResolutionAction = "settle" | "refund";
type AdjustmentUser = AdminReferenceData["users"][number] & Partial<Pick<AdminUser, "email" | "availableMicrocredits" | "reservedMicrocredits">>;
type BillingResolutionTarget = { kind: "single"; order: BillingOrder; action: BillingResolutionAction } | { kind: "batch"; orders: BillingOrder[]; action: BillingResolutionAction };
const billingStatusLabels = {
uncertain: "待核对",
running: "运行中",
reserved: "已预授权",
settled: "已结算",
refunded: "已退款",
} satisfies Record<BillingOrder["status"], string>;
export default function CreditOperationsPanel({ users, activeOperation, onOperationChange }: { users: AdminReferenceData["users"]; activeOperation: CreditOperation; onOperationChange: (operation: CreditOperation) => void }) {
const { message } = App.useApp();
const [orders, setOrders] = useState<BillingOrder[]>([]);
const [loading, setLoading] = useState(true);
const [loadingPolicy, setLoadingPolicy] = useState(false);
const [savingPolicy, setSavingPolicy] = useState(false);
const [adjusting, setAdjusting] = useState(false);
const [resolving, setResolving] = useState(false);
const [keyword, setKeyword] = useState("");
const debouncedKeyword = useDebouncedValue(keyword);
const [orderStatus, setOrderStatus] = useState<"review" | "all" | BillingOrder["status"]>("review");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [total, setTotal] = useState(0);
const [adjustmentUsers, setAdjustmentUsers] = useState<AdjustmentUser[]>(users);
const [adjustmentSearch, setAdjustmentSearch] = useState("");
const debouncedAdjustmentSearch = useDebouncedValue(adjustmentSearch);
const [searchingUsers, setSearchingUsers] = useState(false);
const [pendingAdjustment, setPendingAdjustment] = useState<AdjustmentFormValues | null>(null);
const [selectedOrderIds, setSelectedOrderIds] = useState<string[]>([]);
const [resolutionTarget, setResolutionTarget] = useState<BillingResolutionTarget | null>(null);
const [adjustmentForm] = Form.useForm<AdjustmentFormValues>();
const [resolutionForm] = Form.useForm<ResolutionFormValues>();
const [policyForm] = Form.useForm<PolicyFormValues>();
const ordersRequestRef = useRef(0);
const userSearchRequestRef = useRef(0);
const selectedAdjustmentUserId = Form.useWatch("userId", adjustmentForm);
const userLabels = useMemo(() => {
const labels = new Map<string, string>();
for (const user of [...users, ...adjustmentUsers]) labels.set(user.id, user.displayName || user.username);
return labels;
}, [adjustmentUsers, users]);
const selectedAdjustmentUser = adjustmentUsers.find((user) => user.id === selectedAdjustmentUserId);
const pendingAdjustmentUser = adjustmentUsers.find((user) => user.id === pendingAdjustment?.userId);
const reload = async (targetPage = page, targetPageSize = pageSize) => {
const requestId = ++ordersRequestRef.current;
setLoading(true);
try {
const result = await listAdminBillingOrders({
keyword: debouncedKeyword || undefined,
status: orderStatus,
page: targetPage,
pageSize: targetPageSize,
});
if (requestId !== ordersRequestRef.current) return;
if (targetPage > 1 && result.total > 0 && result.orders.length === 0) {
setPage(1);
return;
}
setOrders(result.orders);
setTotal(result.total);
setSelectedOrderIds([]);
} catch (error) {
if (requestId === ordersRequestRef.current) message.error(error instanceof Error ? error.message : "读取待核对计费失败");
} finally {
if (requestId === ordersRequestRef.current) setLoading(false);
}
};
useEffect(() => {
void reload(page, pageSize);
}, [debouncedKeyword, orderStatus, page, pageSize]);
useEffect(() => {
if (activeOperation !== "policy") return;
let active = true;
setLoadingPolicy(true);
void getAdminCreditPolicy()
.then(({ policy }) => {
if (!active) return;
policyForm.setFieldsValue({
signupBonus: policy.signupBonusMicrocredits / 1_000_000,
checkinBonus: policy.checkinBonusMicrocredits / 1_000_000,
defaultMultiplier: policy.defaultMultiplierBasisPoints / 10_000,
modelMultipliers: Object.entries(policy.modelMultiplierBasisPoints).map(([model, value]) => ({
model,
multiplier: value / 10_000,
})),
});
})
.catch((error) => {
if (active) message.error(error instanceof Error ? error.message : "读取积分策略失败");
})
.finally(() => {
if (active) setLoadingPolicy(false);
});
return () => {
active = false;
};
}, [activeOperation, message, policyForm]);
useEffect(() => {
if (activeOperation !== "adjustment") return;
adjustmentForm.resetFields();
setPendingAdjustment(null);
setAdjustmentSearch("");
setAdjustmentUsers(users);
}, [activeOperation, adjustmentForm, users]);
useEffect(() => {
if (activeOperation !== "adjustment") return;
const requestId = ++userSearchRequestRef.current;
setSearchingUsers(true);
void listAdminUsers({ keyword: debouncedAdjustmentSearch.trim() || undefined, page: 1, pageSize: 50 })
.then((result) => {
if (requestId !== userSearchRequestRef.current) return;
const selectedId = adjustmentForm.getFieldValue("userId");
setAdjustmentUsers((current) => {
const selected = current.find((user) => user.id === selectedId);
if (selected && !result.users.some((user) => user.id === selected.id)) return [selected, ...result.users];
return result.users;
});
})
.catch((error) => {
if (requestId === userSearchRequestRef.current) message.error(error instanceof Error ? error.message : "搜索用户失败");
})
.finally(() => {
if (requestId === userSearchRequestRef.current) setSearchingUsers(false);
});
}, [activeOperation, adjustmentForm, debouncedAdjustmentSearch, message]);
const savePolicy = async (values: PolicyFormValues) => {
const modelMultiplierBasisPoints: Record<string, number> = {};
for (const row of values.modelMultipliers || []) {
const model = String(row.model || "").trim();
const multiplier = Number(row.multiplier);
if (!model || !Number.isFinite(multiplier) || multiplier < 0.0001 || multiplier > 100) {
message.error("请完整填写模型标识与 0.0001–100 之间的倍率");
return;
}
if (model in modelMultiplierBasisPoints) {
message.error(`模型“${model}”配置了重复倍率`);
return;
}
modelMultiplierBasisPoints[model] = Math.round(multiplier * 10_000);
}
setSavingPolicy(true);
try {
await updateAdminCreditPolicy({
signupBonusMicrocredits: toMicrocredits(values.signupBonus),
checkinBonusMicrocredits: toMicrocredits(values.checkinBonus),
defaultMultiplierBasisPoints: Math.round(values.defaultMultiplier * 10_000),
modelMultiplierBasisPoints,
});
message.success("积分策略已保存,将应用于后续创建的计费订单");
onOperationChange(null);
} catch (error) {
message.error(error instanceof Error ? error.message : "保存积分策略失败");
} finally {
setSavingPolicy(false);
}
};
const previewAdjustment = (values: AdjustmentFormValues) => {
const amount = Number(values.amount);
if (!Number.isFinite(amount) || amount === 0) {
message.error("积分变化不能为 0");
return;
}
try {
toMicrocredits(amount);
} catch (error) {
message.error(error instanceof Error ? error.message : "积分变化超出可处理范围");
return;
}
const user = adjustmentUsers.find((item) => item.id === values.userId);
if (amount < 0 && hasCreditBalance(user) && user.availableMicrocredits + toMicrocredits(amount) < 0) {
message.error("扣减后可用积分不能低于 0");
return;
}
setPendingAdjustment({ ...values, amount, note: values.note.trim() });
};
const applyAdjustment = async () => {
if (!pendingAdjustment) return;
setAdjusting(true);
try {
const result = await adjustAdminUserCredits(pendingAdjustment.userId, {
amountMicrocredits: toMicrocredits(pendingAdjustment.amount),
note: pendingAdjustment.note,
});
setAdjustmentUsers((current) =>
current.map((user) =>
user.id === result.account.userId
? {
...user,
availableMicrocredits: result.account.availableMicrocredits,
reservedMicrocredits: result.account.reservedMicrocredits,
}
: user,
),
);
adjustmentForm.resetFields();
setPendingAdjustment(null);
onOperationChange(null);
message.success(`用户积分已调整,当前可用积分 ${formatCredits(result.account.availableMicrocredits)}`);
} catch (error) {
message.error(error instanceof Error ? error.message : "调整积分失败");
} finally {
setAdjusting(false);
}
};
const resolveBilling = async () => {
if (!resolutionTarget) return;
let values: ResolutionFormValues;
try {
values = await resolutionForm.validateFields();
} catch {
return;
}
const note = values.note.trim();
setResolving(true);
try {
if (resolutionTarget.kind === "single") {
await resolveAdminBillingOrder(resolutionTarget.order.id, { action: resolutionTarget.action, note });
message.success(resolutionTarget.action === "settle" ? "计费订单已结算" : "预授权积分已退回");
} else {
const result = await resolveAdminBillingOrders({
ids: resolutionTarget.orders.map((order) => order.id),
action: resolutionTarget.action,
note,
});
if (result.failed.length > 0) {
const failedIds = new Set(result.failed.map((item) => item.id));
const failedOrders = resolutionTarget.orders.filter((order) => failedIds.has(order.id));
const detail = result.failed[0]?.message ? `:${result.failed[0].message}` : "";
if (result.resolvedCount > 0) message.warning(`已处理 ${result.resolvedCount} 条,仍有 ${result.failed.length} 条失败${detail}`);
else message.error(`所选 ${result.failed.length} 条订单均处理失败${detail}`);
setResolutionTarget({ ...resolutionTarget, orders: failedOrders.length > 0 ? failedOrders : resolutionTarget.orders });
await reload(page, pageSize);
return;
}
message.success(resolutionTarget.action === "settle" ? `已结算 ${result.resolvedCount} 条订单` : `已退回 ${result.resolvedCount} 条订单的预授权积分`);
}
setResolutionTarget(null);
resolutionForm.resetFields();
await reload(page, pageSize);
} catch (error) {
message.error(error instanceof Error ? error.message : "处理计费订单失败");
} finally {
setResolving(false);
}
};
const openSingleResolution = (order: BillingOrder, action: BillingResolutionAction) => {
setResolutionTarget({ kind: "single", order, action });
resolutionForm.resetFields();
};
const openBatchResolution = (action: BillingResolutionAction) => {
const selectedOrders = orders.filter((order) => selectedOrderIds.includes(order.id) && canResolveBillingOrder(order));
if (selectedOrders.length === 0) return;
setResolutionTarget({ kind: "batch", orders: selectedOrders, action });
resolutionForm.resetFields();
};
const columns: ColumnsType<BillingOrder> = [
{ title: "创建时间", dataIndex: "createdAt", width: 170, align: "center", render: formatTime },
{
title: "用户",
dataIndex: "userId",
width: 150,
render: (id: string) => (
<div>
<div className="font-medium">{userLabels.get(id) || "未知用户"}</div>
<div className="mt-0.5 truncate text-xs text-foreground/50" title={id}>
{id}
</div>
</div>
),
},
{
title: "模型 / 场景",
width: 220,
render: (_, order) => (
<div>
<div className="font-medium">{order.model}</div>
<div className="mt-0.5 text-xs text-foreground/50">{order.scene || order.capability}</div>
</div>
),
},
{
title: "预授权积分",
width: 125,
align: "center",
render: (_, order) => <span className="font-medium tabular-nums">{formatCredits(getReservedAmount(order))}</span>,
},
{
title: "实际结算 / 用量",
width: 205,
align: "center",
render: (_, order) =>
order.billingMode === "token" ? (
<div className="text-xs leading-5">
<div className="font-medium tabular-nums">{order.status === "settled" ? `${formatCredits(order.actualAmountMicrocredits)} 积分` : "等待用量结算"}</div>
<div className="text-foreground/50">
输入 {order.inputTokens} · 输出 {order.outputTokens} · 缓存 {order.cachedTokens}
</div>
</div>
) : (
<span className="tabular-nums">{order.status === "settled" ? formatCredits(order.actualAmountMicrocredits) : "--"}</span>
),
},
{
title: "状态",
dataIndex: "status",
width: 110,
align: "center",
render: (value: BillingOrder["status"]) => <AdminStatusBadge label={billingStatusLabels[value]} tone={value === "settled" ? "success" : value === "refunded" ? "neutral" : "warning"} />,
},
{ title: "上游请求", dataIndex: "providerRequestId", width: 180, ellipsis: true, render: (value) => value || "未获取" },
{ title: "核对原因", dataIndex: "error", width: 260, ellipsis: true, render: (value) => value || "费用状态不明确" },
{
title: "操作",
width: 190,
align: "center",
fixed: "right",
render: (_, order) =>
!canResolveBillingOrder(order) ? (
<span className="text-xs text-foreground/45">处理完成</span>
) : (
<div className="flex justify-center">
<AdminRowActions primary={{ label: "确认结算", onClick: () => openSingleResolution(order, "settle") }} actions={[{ key: "refund", label: "退回预授权", danger: true, onClick: () => openSingleResolution(order, "refund") }]} />
</div>
),
},
];
const hasFilters = Boolean(keyword || orderStatus !== "review");
const resolutionOrders = resolutionTarget?.kind === "batch" ? resolutionTarget.orders : resolutionTarget ? [resolutionTarget.order] : [];
const resolutionReservedTotal = resolutionOrders.reduce((sum, order) => sum + getReservedAmount(order), 0);
const resolutionTitle = resolutionTarget ? (resolutionTarget.action === "settle" ? (resolutionTarget.kind === "batch" ? "批量确认结算" : "确认结算计费订单") : resolutionTarget.kind === "batch" ? "批量退回预授权积分" : "确认退回预授权积分") : "";
return (
<div className="admin-credit-operations flex min-h-0 flex-1 flex-col">
<section className="admin-credit-operations-table-region flex min-h-0 flex-1" aria-labelledby="credit-review-heading">
<h2 id="credit-review-heading" className="sr-only">
异常计费核对
</h2>
<AdminDataTable
toolbar={
<Input
allowClear
aria-label="搜索计费订单"
className="app-list-search"
prefix={<Search className="size-4 text-foreground/40" />}
value={keyword}
placeholder="搜索用户、模型、场景或请求号"
onChange={(event) => {
setKeyword(event.target.value);
setPage(1);
}}
/>
}
toolbarActive={hasFilters}
toolbarFilters={
<Select
aria-label="筛选计费队列"
className="w-40"
value={orderStatus}
onChange={(value) => {
setOrderStatus(value);
setPage(1);
}}
options={[
{ label: "待核对队列", value: "review" },
{ label: "全部历史", value: "all" },
{ label: "费用待核对", value: "uncertain" },
{ label: "运行中", value: "running" },
{ label: "已预授权", value: "reserved" },
{ label: "已结算", value: "settled" },
{ label: "已退款", value: "refunded" },
]}
/>
}
onReset={() => {
setKeyword("");
setOrderStatus("review");
setPage(1);
}}
trailing={
<Button type="text" size="small" icon={<RefreshCw className="size-3.5" />} loading={loading} onClick={() => void reload()}>
刷新
</Button>
}
batchActions={
<AdminBatchBar count={selectedOrderIds.length} onClear={() => setSelectedOrderIds([])}>
<Button size="small" type="primary" icon={<BadgeCheck className="size-3.5" />} onClick={() => openBatchResolution("settle")}>
批量确认结算
</Button>
<Button size="small" danger icon={<Undo2 className="size-3.5" />} onClick={() => openBatchResolution("refund")}>
批量退回预授权
</Button>
</AdminBatchBar>
}
table={{
className: "app-data-table",
rowKey: "id",
size: "small",
loading,
pagination: false,
columns,
dataSource: orders,
rowSelection: {
selectedRowKeys: selectedOrderIds,
preserveSelectedRowKeys: false,
onChange: (keys) => setSelectedOrderIds(keys.map(String)),
getCheckboxProps: (order) => ({
disabled: !canResolveBillingOrder(order),
name: `${order.model} ${order.scene || order.capability}`,
}),
},
scroll: { x: 1510 },
}}
empty={<AdminTableEmpty filtered={hasFilters} title={!hasFilters ? "当前没有待核对订单" : undefined} description={!hasFilters ? "新的异常计费订单会自动出现在这里。" : undefined} />}
footer={
<PaginationBar
alwaysShow
current={page}
pageSize={pageSize}
total={total}
onChange={(nextPage, nextPageSize) => {
setPage(nextPageSize !== pageSize ? 1 : nextPage);
setPageSize(nextPageSize);
}}
/>
}
/>
</section>
<Drawer
title="积分策略"
open={activeOperation === "policy"}
size="min(700px, 100vw)"
onClose={() => {
if (!savingPolicy) onOperationChange(null);
}}
rootClassName="admin-drawer admin-credit-drawer"
destroyOnHidden
mask={{ closable: !savingPolicy }}
keyboard={!savingPolicy}
footer={
<div className="flex justify-end gap-2">
<Button disabled={savingPolicy} onClick={() => onOperationChange(null)}>
取消
</Button>
<Button type="primary" loading={savingPolicy} disabled={loadingPolicy} onClick={() => policyForm.submit()}>
保存策略
</Button>
</div>
}
>
<div className="admin-credit-drawer-intro">
<strong>后续订单计费规则</strong>
<p>修改只影响保存后创建的新订单,不会追溯调整历史订单。</p>
</div>
{loadingPolicy ? (
<div className="admin-credit-drawer-loading" role="status">
正在读取积分策略…
</div>
) : (
<Form form={policyForm} layout="vertical" requiredMark={false} initialValues={{ modelMultipliers: [] }} onFinish={(values) => void savePolicy(values)}>
<section className="admin-credit-drawer-section">
<div className="admin-credit-drawer-section-heading">
<h3>基础规则</h3>
<p>积分支持最多 6 位小数,倍率支持最多 4 位小数。</p>
</div>
<div className="admin-credit-policy-grid">
<Form.Item
name="signupBonus"
label="注册赠送积分"
rules={[
{ required: true, message: "请填写注册积分" },
{ type: "number", min: 0, max: 1_000_000, message: "请输入 0–1,000,000" },
]}
>
<InputNumber className="w-full" min={0} max={1_000_000} precision={6} />
</Form.Item>
<Form.Item
name="checkinBonus"
label="每日签到积分"
rules={[
{ required: true, message: "请填写签到积分" },
{ type: "number", min: 0, max: 100_000, message: "请输入 0–100,000" },
]}
>
<InputNumber className="w-full" min={0} max={100_000} precision={6} />
</Form.Item>
<Form.Item
name="defaultMultiplier"
label="默认计费倍率"
rules={[
{ required: true, message: "请填写默认倍率" },
{ type: "number", min: 0.0001, max: 100, message: "请输入 0.0001–100" },
]}
>
<InputNumber className="w-full" min={0.0001} max={100} precision={4} />
</Form.Item>
</div>
</section>
<section className="admin-credit-drawer-section">
<Form.List name="modelMultipliers">
{(fields, { add, remove }) => (
<>
<div className="admin-credit-drawer-section-heading admin-credit-drawer-section-heading-with-action">
<div>
<h3>模型独立倍率</h3>
<p>仅为需要覆盖默认倍率的模型添加规则。</p>
</div>
<Button type="text" size="small" icon={<Plus className="size-3.5" />} onClick={() => add({ model: "", multiplier: 1 })}>
添加模型
</Button>
</div>
{fields.length > 0 ? (
<div className="admin-credit-multiplier-list">
<div className="admin-credit-multiplier-header" aria-hidden="true">
<span>模型标识</span>
<span>倍率</span>
<span>操作</span>
</div>
{fields.map((field, index) => (
<div className="admin-credit-multiplier-row" key={field.key}>
<Form.Item name={[field.name, "model"]} rules={[{ required: true, whitespace: true, message: "请填写模型标识" }]}>
<Input aria-label={`第 ${index + 1} 行模型标识`} placeholder="例如 gpt-image-1" />
</Form.Item>
<Form.Item
name={[field.name, "multiplier"]}
rules={[
{ required: true, message: "请填写倍率" },
{ type: "number", min: 0.0001, max: 100, message: "请输入 0.0001–100" },
]}
>
<InputNumber aria-label={`第 ${index + 1} 行倍率`} className="w-full" min={0.0001} max={100} precision={4} />
</Form.Item>
<Button type="text" danger className="admin-credit-multiplier-remove" icon={<Trash2 className="size-4" />} aria-label={`删除第 ${index + 1} 条模型倍率`} onClick={() => remove(field.name)} />
</div>
))}
</div>
) : (
<div className="admin-credit-multiplier-empty">暂无模型独立倍率,所有模型使用默认倍率。</div>
)}
</>
)}
</Form.List>
</section>
</Form>
)}
</Drawer>
<Drawer
title="人工调账"
open={activeOperation === "adjustment"}
size="min(580px, 100vw)"
onClose={() => {
if (adjusting || pendingAdjustment) return;
onOperationChange(null);
}}
rootClassName="admin-drawer admin-credit-drawer"
destroyOnHidden
mask={{ closable: !adjusting && !pendingAdjustment }}
keyboard={!adjusting && !pendingAdjustment}
footer={
<div className="flex justify-end gap-2">
<Button disabled={adjusting} onClick={() => onOperationChange(null)}>
取消
</Button>
<Button type="primary" disabled={adjusting} onClick={() => adjustmentForm.submit()}>
核对并继续
</Button>
</div>
}
>
<div className="admin-credit-drawer-intro is-warning">
<strong>账务写入操作</strong>
<p>提交后会立即写入积分流水和管理员审计记录,请填写可追溯的处理依据。</p>
</div>
<Form form={adjustmentForm} layout="vertical" requiredMark={false} onFinish={previewAdjustment}>
<section className="admin-credit-drawer-section">
<Form.Item name="userId" label="目标用户" rules={[{ required: true, message: "请选择用户" }]}>
<Select
showSearch
filterOption={false}
loading={searchingUsers}
aria-label="搜索并选择调账用户"
placeholder="搜索用户名、显示名称或邮箱"
onSearch={setAdjustmentSearch}
options={adjustmentUsers.map((user) => ({
label: `${user.displayName || user.username} · @${user.username}`,
value: user.id,
}))}
/>
</Form.Item>
{selectedAdjustmentUser ? <CreditAccountSummary user={selectedAdjustmentUser} /> : null}
<Form.Item
name="amount"
label="积分变化"
extra="正数增加,负数扣减;扣减只能使用可用积分。"
rules={[
{ required: true, message: "请填写积分变化" },
{
validator: (_, value) => (typeof value === "number" && Number.isFinite(value) && value !== 0 ? Promise.resolve() : Promise.reject(new Error("积分变化不能为 0"))),
},
]}
>
<InputNumber className="w-full" precision={6} prefix={<Coins className="size-3.5 text-foreground/45" />} placeholder="例如 10 或 -2" />
</Form.Item>
<Form.Item name="note" label="调整原因" rules={[{ required: true, whitespace: true, message: "请填写工单号或处理依据" }]}>
<Input.TextArea rows={4} maxLength={500} showCount placeholder="例如:工单 YC-20260828,补偿失败任务费用" />
</Form.Item>
</section>
</Form>
</Drawer>
<Modal
title={pendingAdjustment?.amount && pendingAdjustment.amount < 0 ? "确认扣减用户积分" : "确认增加用户积分"}
open={Boolean(pendingAdjustment)}
okText={pendingAdjustment?.amount && pendingAdjustment.amount < 0 ? "确认扣减" : "确认增加"}
cancelText="返回修改"
onCancel={() => {
if (!adjusting) setPendingAdjustment(null);
}}
onOk={() => void applyAdjustment()}
confirmLoading={adjusting}
mask={{ closable: !adjusting }}
closable={!adjusting}
destroyOnHidden
rootClassName="admin-modal-root"
okButtonProps={{ danger: Boolean(pendingAdjustment && pendingAdjustment.amount < 0) }}
>
{pendingAdjustment ? (
<div className="admin-operation-confirmation">
<p className="admin-operation-confirmation-copy">请再次核对用户、积分变化和处理依据。确认后将立即写入账务流水。</p>
<dl className="admin-operation-confirmation-grid">
<div>
<dt>目标用户</dt>
<dd>{formatUserLabel(pendingAdjustmentUser, pendingAdjustment.userId)}</dd>
</div>
<div>
<dt>积分变化</dt>
<dd className={pendingAdjustment.amount < 0 ? "is-negative" : "is-positive"}>
{pendingAdjustment.amount > 0 ? "+" : ""}
{formatCredits(toMicrocredits(pendingAdjustment.amount))}
</dd>
</div>
{hasCreditBalance(pendingAdjustmentUser) ? (
<>
<div>
<dt>当前可用</dt>
<dd>{formatCredits(pendingAdjustmentUser.availableMicrocredits)}</dd>
</div>
<div>
<dt>预计可用</dt>
<dd>{formatCredits(pendingAdjustmentUser.availableMicrocredits + toMicrocredits(pendingAdjustment.amount))}</dd>
</div>
</>
) : null}
<div className="is-wide">
<dt>处理依据</dt>
<dd>{pendingAdjustment.note}</dd>
</div>
</dl>
</div>
) : null}
</Modal>
<Modal
title={resolutionTitle}
open={Boolean(resolutionTarget)}
okText={resolutionTarget?.action === "settle" ? "确认结算" : "退回预授权"}
cancelText="取消"
onCancel={() => {
if (resolving) return;
setResolutionTarget(null);
resolutionForm.resetFields();
}}
onOk={() => void resolveBilling()}
confirmLoading={resolving}
mask={{ closable: !resolving }}
closable={!resolving}
destroyOnHidden
rootClassName="admin-modal-root"
okButtonProps={{ danger: resolutionTarget?.action === "refund" }}
>
{resolutionTarget ? (
<div className="admin-operation-confirmation">
<p className="admin-operation-confirmation-copy">{resolutionTarget.action === "settle" ? "结算会依据订单计费方式确认实际费用;Token 订单可能退回差额,也可能补扣可用积分。" : "退回操作只会释放尚未结算订单的预授权积分。"}</p>
<dl className="admin-operation-confirmation-grid">
<div>
<dt>订单数量</dt>
<dd>{resolutionOrders.length} 条</dd>
</div>
<div>
<dt>预授权总额</dt>
<dd>{formatCredits(resolutionReservedTotal)} 积分</dd>
</div>
{resolutionTarget.kind === "single" ? (
<>
<div>
<dt>用户</dt>
<dd>{userLabels.get(resolutionTarget.order.userId) || resolutionTarget.order.userId}</dd>
</div>
<div>
<dt>模型 / 场景</dt>
<dd>
{resolutionTarget.order.model} · {resolutionTarget.order.scene || resolutionTarget.order.capability}
</dd>
</div>
<div className="is-wide">
<dt>订单编号</dt>
<dd className="admin-monospace">{resolutionTarget.order.id}</dd>
</div>
</>
) : null}
</dl>
</div>
) : null}
<Form form={resolutionForm} layout="vertical" requiredMark={false}>
<Form.Item name="note" label="核对依据" rules={[{ required: true, whitespace: true, message: "请填写供应商账单、任务状态或处理依据" }]}>
<Input.TextArea rows={4} maxLength={500} showCount placeholder="例如:供应商后台确认任务未产生费用" />
</Form.Item>
</Form>
</Modal>
</div>
);
}
function CreditAccountSummary({ user }: { user: AdjustmentUser }) {
if (!hasCreditBalance(user)) {
return <div className="admin-credit-account-summary is-unavailable">选择远程搜索结果后可核对当前可用与冻结积分。</div>;
}
return (
<dl className="admin-credit-account-summary">
<div>
<dt>当前可用</dt>
<dd>{formatCredits(user.availableMicrocredits)}</dd>
</div>
<div>
<dt>当前冻结</dt>
<dd>{formatCredits(user.reservedMicrocredits)}</dd>
</div>
</dl>
);
}
function canResolveBillingOrder(order: BillingOrder) {
return order.status === "uncertain" || order.status === "running" || order.status === "reserved";
}
function getReservedAmount(order: BillingOrder) {
return order.reservedAmountMicrocredits || order.amountMicrocredits;
}
function hasCreditBalance(user?: AdjustmentUser): user is AdjustmentUser & Pick<AdminUser, "availableMicrocredits" | "reservedMicrocredits"> {
return Boolean(user && typeof user.availableMicrocredits === "number" && typeof user.reservedMicrocredits === "number");
}
function formatUserLabel(user: AdjustmentUser | undefined, fallback: string) {
return user ? `${user.displayName || user.username} · @${user.username}` : fallback;
}
function toMicrocredits(value: number) {
const result = Math.round(Number(value) * 1_000_000);
if (!Number.isSafeInteger(result)) throw new Error("积分变化超出可处理范围");
return result;
}
function formatTime(value?: string) {
return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "--";
}