Files
Jyotisha/frontend/src/components/admin/billing-operations-resources.tsx
T
Jesse_Chen 945d61fa1c
Independent Staging Quality Gate / validate (push) Failing after 12m38s
Independent Staging Quality Gate / publish (push) Has been skipped
fix(admin): remove redundant confirmations and repair code access
2026-08-16 17:55:15 +08:00

532 lines
14 KiB
TypeScript

"use client";
import { useTable } from "@refinedev/antd";
import { useGetIdentity, useInvalidate } from "@refinedev/core";
import { List } from "@refinedev/antd";
import {
App,
Button,
Form,
InputNumber,
Modal,
Space,
Table,
Tag,
Typography,
type TableColumnsType,
} from "antd";
import { useState } from "react";
import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers";
import { formatAdminDate, ResourceTable } from "./resource-table";
const { Text } = Typography;
type Subscription = {
id: string;
userId: string;
email: string | null;
productCode: string;
productVersion: number;
status: string;
startsAt: string;
endsAt: string;
createdAt: string;
};
type AdjustmentForm = { days: number };
export function SubscriptionsResource() {
const { message } = App.useApp();
const { data: identity } = useGetIdentity<AdminIdentity>();
const table = useTable<Subscription>({
resource: "subscriptions",
syncWithLocation: true,
});
const [form] = Form.useForm<AdjustmentForm>();
const [selected, setSelected] = useState<Subscription | null>(null);
const [saving, setSaving] = useState(false);
const [revokingId, setRevokingId] = useState<string | null>(null);
const canAdjust = Boolean(
identity?.permissions.includes("billing.adjustments.write"),
);
async function adjust(
item: Subscription,
action: "extend" | "revoke",
days: number | undefined,
) {
await adminRequestJson("/api/admin/subscriptions", {
method: "POST",
body: JSON.stringify({
id: item.id,
action,
days,
expectedEndsAt: item.endsAt,
}),
});
await table.tableQuery.refetch();
}
async function extend(values: AdjustmentForm) {
if (!selected) return;
setSaving(true);
try {
await adjust(selected, "extend", values.days);
message.success("订阅已延长");
setSelected(null);
form.resetFields();
} catch (error) {
message.error(error instanceof Error ? error.message : "延长订阅失败");
} finally {
setSaving(false);
}
}
async function revoke(item: Subscription) {
setRevokingId(item.id);
try {
await adjust(item, "revoke", undefined);
message.success("订阅已撤销");
} catch (error) {
message.error(error instanceof Error ? error.message : "撤销订阅失败");
} finally {
setRevokingId(null);
}
}
const columns: TableColumnsType<Subscription> = [
{
title: "用户",
render: (_, item) => (
<Space direction="vertical" size={0}>
<Text>{item.email ?? "未记录邮箱"}</Text>
<Text type="secondary" copyable>
{item.userId}
</Text>
</Space>
),
},
{
title: "商品",
render: (_, item) => `${item.productCode} · v${item.productVersion}`,
},
{
title: "状态",
dataIndex: "status",
render: (value) => (
<Tag
color={
value === "active"
? "green"
: value === "revoked"
? "red"
: "default"
}
>
{value}
</Tag>
),
},
{ title: "开始", dataIndex: "startsAt", render: formatAdminDate },
{ title: "到期", dataIndex: "endsAt", render: formatAdminDate },
{
title: "操作",
fixed: "right",
render: (_, item) => (
<Space>
{canAdjust && (
<Button
type="link"
disabled={item.status !== "active"}
onClick={() => {
setSelected(item);
form.setFieldsValue({ days: 30 });
}}
>
延长
</Button>
)}
{canAdjust && (
<Button
type="link"
danger
disabled={item.status !== "active"}
loading={revokingId === item.id}
onClick={() => void revoke(item)}
>
撤销
</Button>
)}
</Space>
),
},
];
return (
<List title="用户订阅">
<Table
{...table.tableProps}
columns={columns}
rowKey="id"
scroll={{ x: "max-content" }}
/>
<Modal
title="人工延长订阅"
open={Boolean(selected)}
okText="确认延长"
cancelText="取消"
confirmLoading={saving}
onOk={() => form.submit()}
onCancel={() => setSelected(null)}
destroyOnHidden
>
<Form<AdjustmentForm>
form={form}
layout="vertical"
onFinish={extend}
>
<Form.Item name="days" label="延长天数" rules={[{ required: true }]}>
<InputNumber
min={1}
max={3660}
precision={0}
style={{ width: "100%" }}
/>
</Form.Item>
</Form>
</Modal>
</List>
);
}
type Order = {
id: string;
orderNo: string;
userId: string;
email: string | null;
productCode: string | null;
productVersion: number | null;
moneyCents: number;
currency: string;
status: string;
grantType: string | null;
grantStatus: string;
grantError: string | null;
adjustmentVersion: number;
refundStatus: string;
refundAmountCents: number | null;
refundedAt: string | null;
paidAt: string | null;
createdAt: string;
};
export function OrdersResource() {
const { message } = App.useApp();
const { data: identity } = useGetIdentity<AdminIdentity>();
const invalidate = useInvalidate();
const [target, setTarget] = useState<{
orderId: string;
action: "retry_grant" | "compensate" | "record_refund";
} | null>(null);
const canAdjust = Boolean(
identity?.permissions.includes("billing.adjustments.write"),
);
async function adjust(
order: Order,
action: "retry_grant" | "compensate" | "record_refund",
) {
setTarget({ orderId: order.id, action });
try {
const result = await adminRequestJson<{
data: { actionSuccess: boolean };
}>("/api/admin/orders", {
method: "POST",
headers: { "x-request-id": crypto.randomUUID() },
body: JSON.stringify({
id: order.id,
action,
expectedVersion: order.adjustmentVersion,
}),
});
await invalidate({ resource: "orders", invalidates: ["list"] });
if (action === "retry_grant" && !result.data.actionSuccess) {
message.warning("已执行重试,但权益发放仍失败,请查看最新错误");
} else {
message.success(
action === "record_refund"
? "已记录账务全额退款状态"
: "订单权益操作已完成",
);
}
} catch (error) {
message.error(error instanceof Error ? error.message : "订单操作失败");
} finally {
setTarget(null);
}
}
const columns: TableColumnsType<Order> = [
{
title: "订单",
render: (_, item) => (
<Space direction="vertical" size={0}>
<Text copyable>{item.orderNo}</Text>
<Text type="secondary">{formatAdminDate(item.createdAt)}</Text>
</Space>
),
},
{ title: "用户", render: (_, item) => item.email ?? item.userId },
{
title: "商品",
render: (_, item) =>
item.productCode
? `${item.productCode} · v${item.productVersion}`
: "旧积分订单",
},
{
title: "金额",
render: (_, item) =>
`${item.currency} ${(item.moneyCents / 100).toFixed(2)}`,
},
{
title: "支付",
dataIndex: "status",
render: (value) => (
<Tag
color={
value === "paid"
? "green"
: value === "refunded"
? "orange"
: "default"
}
>
{value}
</Tag>
),
},
{
title: "权益发放",
render: (_, item) => (
<Space direction="vertical" size={0}>
<Tag
color={
item.grantStatus === "granted"
? "green"
: item.grantStatus === "failed"
? "red"
: "gold"
}
>
{item.grantStatus}
</Tag>
{item.grantError && <Text type="danger">{item.grantError}</Text>}
</Space>
),
},
{
title: "退款账务",
render: (_, item) =>
item.refundStatus === "recorded" ? (
<Space direction="vertical" size={0}>
<Tag color="orange">已记录</Tag>
<Text type="secondary">{formatAdminDate(item.refundedAt)}</Text>
</Space>
) : (
"—"
),
},
{ title: "支付时间", dataIndex: "paidAt", render: formatAdminDate },
{
title: "领域动作",
fixed: "right",
render: (_, item) => canAdjust ? (
<Space wrap>
<Button
size="small"
disabled={item.grantStatus !== "failed"}
loading={target?.orderId === item.id && target.action === "retry_grant"}
onClick={() => void adjust(item, "retry_grant")}
>
重试发放
</Button>
<Button
size="small"
disabled={
item.grantStatus !== "failed" || item.grantType !== "credits"
}
loading={target?.orderId === item.id && target.action === "compensate"}
onClick={() => void adjust(item, "compensate")}
>
人工补偿
</Button>
<Button
size="small"
danger
disabled={
item.refundStatus !== "none" ||
item.paidAt == null ||
item.status === "refunded"
}
loading={target?.orderId === item.id && target.action === "record_refund"}
onClick={() => void adjust(item, "record_refund")}
>
记录全额退款
</Button>
</Space>
) : null,
},
];
return (
<>
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
“记录全额退款”仅记录账务状态,不调用支付网关。
</Typography.Paragraph>
<ResourceTable<Order>
resource="orders"
title="支付订单与权益发放"
columns={columns}
statusOptions={[
"pending",
"paid",
"failed",
"granted",
"refunded",
"recorded",
].map((value) => ({ value, label: value }))}
/>
</>
);
}
type Usage = {
id: string;
userId: string;
email: string | null;
requestId: string;
featureKey: string;
source: string;
requestedModelId: string | null;
actualModelId: string | null;
modelConfigVersion: number | null;
inputTokens: number;
outputTokens: number;
costMicrousd: number;
durationMs: number | null;
createdAt: string;
};
export function UsageResource() {
const columns: TableColumnsType<Usage> = [
{
title: "请求",
render: (_, item) => (
<Space direction="vertical" size={0}>
<Text copyable>{item.requestId}</Text>
<Text type="secondary">{item.email ?? item.userId}</Text>
</Space>
),
},
{ title: "功能", dataIndex: "featureKey" },
{
title: "资金来源",
dataIndex: "source",
render: (value) => value,
},
{
title: "模型",
render: (_, item) => (
<Space direction="vertical" size={0}>
<Text>{item.actualModelId ?? "—"}</Text>
<Text type="secondary">
请求 {item.requestedModelId ?? "—"} · v
{item.modelConfigVersion ?? "—"}
</Text>
</Space>
),
},
{
title: "Token",
render: (_, item) =>
`${item.inputTokens.toLocaleString()} / ${item.outputTokens.toLocaleString()}`,
},
{
title: "成本",
dataIndex: "costMicrousd",
render: (value: number) => `$${(value / 1_000_000).toFixed(6)}`,
},
{
title: "耗时",
dataIndex: "durationMs",
render: (value) => (value == null ? "—" : `${value} ms`),
},
{ title: "时间", dataIndex: "createdAt", render: formatAdminDate },
];
return (
<ResourceTable<Usage>
resource="usage"
title="用量、Token 与成本"
columns={columns}
statusOptions={[
"subscription",
"credits",
"complimentary",
"chat.standard",
"rectification",
].map((value) => ({ value, label: value }))}
/>
);
}
type ModelRelease = {
id: string;
modelId: string;
fromVersion: number | null;
toVersion: number;
action: string;
actorEmail: string | null;
reason: string;
requestId: string;
createdAt: string;
};
export function ModelReleasesResource() {
const columns: TableColumnsType<ModelRelease> = [
{ title: "模型", dataIndex: "modelId" },
{
title: "版本",
render: (_, item) => `${item.fromVersion ?? "—"}${item.toVersion}`,
},
{
title: "动作",
dataIndex: "action",
render: (value) => value,
},
{
title: "管理员",
dataIndex: "actorEmail",
render: (value) => value ?? "—",
},
{ title: "原因", dataIndex: "reason" },
{
title: "请求 ID",
dataIndex: "requestId",
render: (value) => <Text copyable>{value}</Text>,
},
{ title: "时间", dataIndex: "createdAt", render: formatAdminDate },
];
return (
<ResourceTable<ModelRelease>
resource="model-releases"
title="模型发布与回滚历史"
columns={columns}
statusOptions={["publish", "rollback"].map((value) => ({
value,
label: value,
}))}
/>
);
}