532 lines
14 KiB
TypeScript
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,
|
|
}))}
|
|
/>
|
|
);
|
|
}
|