Files
Jyotisha/frontend/src/components/admin/product-management.tsx
T

274 lines
11 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.
"use client";
import { PlusOutlined } from "@ant-design/icons";
import { useGetIdentity } from "@refinedev/core";
import { useTable } from "@refinedev/antd";
import { List } from "@refinedev/antd";
import {
App,
Button,
Col,
Form,
Input,
InputNumber,
Modal,
Row,
Select,
Space,
Switch,
Table,
Tag,
Typography,
type TableColumnsType,
} from "antd";
import { useState } from "react";
import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers";
import { ReasonActionModal } from "./reason-action-modal";
import { formatAdminDate } from "./resource-table";
const { Text } = Typography;
type Entitlement = {
featureKey: string;
allowanceType: string;
allowanceCount: number | null;
resetPeriod: string;
modelTier?: string | null;
fairUsePolicyId?: string | null;
metadata: Record<string, unknown>;
};
type Product = {
id: string;
code: string;
version: number;
name: string;
description: string;
productType: "credit_pack" | "trial" | "subscription";
billingPeriod: "none" | "day" | "month" | "year";
intervalCount: number;
priceCents: number;
currency: string;
enabled: boolean;
status: string;
sortOrder: number;
oneTimePerUser: boolean;
effectiveFrom: string | null;
updatedAt: string;
entitlements: Entitlement[];
};
type ProductForm = Omit<Product, "id" | "version" | "priceCents" | "status" | "effectiveFrom" | "updatedAt" | "entitlements"> & {
id?: string;
priceYuan: number;
entitlementsJson: string;
};
type PendingProductSave = Record<string, unknown>;
type ProductFilters = { q?: string; status?: string };
const defaultEntitlements: Entitlement[] = [{
featureKey: "chat.standard",
allowanceType: "unlimited",
allowanceCount: null,
resetPeriod: "billing_period",
modelTier: "standard",
fairUsePolicyId: null,
metadata: { minuteLimit: 6, dayLimit: 100 },
}];
export default function ProductManagement() {
const { message } = App.useApp();
const { data: identity } = useGetIdentity<AdminIdentity>();
const table = useTable<Product, { message: string; statusCode: number }, ProductFilters>({
resource: "products",
syncWithLocation: true,
pagination: { pageSize: 20 },
onSearch: ({ q, status }) => [
{ field: "q", operator: "contains", value: q },
{ field: "status", operator: "eq", value: status },
],
});
const [form] = Form.useForm<ProductForm>();
const [editing, setEditing] = useState<Product | null>(null);
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [publishingId, setPublishingId] = useState<string | null>(null);
const [publishTarget, setPublishTarget] = useState<Product | null>(null);
const [pendingSave, setPendingSave] = useState<PendingProductSave | null>(null);
const canWrite = Boolean(identity?.permissions.includes("billing.products.write"));
const canPublish = Boolean(identity?.permissions.includes("billing.products.publish"));
function openProduct(product?: Product) {
setEditing(product ?? null);
form.setFieldsValue(product ? {
id: product.id,
code: product.code,
name: product.name,
description: product.description,
productType: product.productType,
billingPeriod: product.billingPeriod,
intervalCount: product.intervalCount,
priceYuan: product.priceCents / 100,
currency: product.currency,
enabled: product.enabled,
sortOrder: product.sortOrder,
oneTimePerUser: product.oneTimePerUser,
entitlementsJson: JSON.stringify(product.entitlements, null, 2),
} : {
code: "",
name: "",
description: "",
productType: "subscription",
billingPeriod: "month",
intervalCount: 1,
priceYuan: 99,
currency: "CNY",
enabled: false,
sortOrder: 0,
oneTimePerUser: false,
entitlementsJson: JSON.stringify(defaultEntitlements, null, 2),
});
setOpen(true);
}
function prepareSave(values: ProductForm) {
let entitlements: unknown;
try {
entitlements = JSON.parse(values.entitlementsJson);
} catch {
message.error("权益 JSON 格式不正确");
return;
}
setPendingSave({
action: "save",
id: editing?.id ?? null,
code: values.code.trim(),
name: values.name.trim(),
description: values.description?.trim() ?? "",
productType: values.productType,
billingPeriod: values.billingPeriod,
intervalCount: values.intervalCount,
priceCents: Math.round(values.priceYuan * 100),
currency: values.currency.toUpperCase(),
enabled: values.enabled,
sortOrder: values.sortOrder,
oneTimePerUser: values.oneTimePerUser,
entitlements,
});
}
async function save(reason: string) {
if (!pendingSave) return;
setSaving(true);
try {
await adminRequestJson("/api/admin/products", {
method: "POST",
body: JSON.stringify({ ...pendingSave, reason }),
});
message.success("商品草稿已保存");
setPendingSave(null);
setOpen(false);
form.resetFields();
await table.tableQuery.refetch();
} finally {
setSaving(false);
}
}
async function publish(product: Product, reason: string) {
setPublishingId(product.id);
try {
await adminRequestJson("/api/admin/products", {
method: "POST",
body: JSON.stringify({ action: "publish", id: product.id, reason }),
});
message.success("商品已发布");
await table.tableQuery.refetch();
setPublishTarget(null);
} finally {
setPublishingId(null);
}
}
const columns: TableColumnsType<Product> = [
{
title: "商品",
dataIndex: "name",
render: (_, item) => <Space direction="vertical" size={0}><Text strong>{item.name}</Text><Text type="secondary">{item.code} · v{item.version}</Text></Space>,
},
{ title: "类型", dataIndex: "productType", render: (value) => <Tag>{value}</Tag> },
{ title: "周期", render: (_, item) => item.billingPeriod === "none" ? "—" : `${item.intervalCount} ${item.billingPeriod}` },
{ title: "价格", render: (_, item) => ${(item.priceCents / 100).toFixed(2)}` },
{ title: "权益", dataIndex: "entitlements", render: (items: Entitlement[]) => <Space wrap>{items.map((item) => <Tag key={`${item.featureKey}-${item.allowanceType}`}>{item.featureKey}: {item.allowanceType}</Tag>)}</Space> },
{ title: "状态", render: (_, item) => <Space><Tag color={item.status === "published" ? "green" : "gold"}>{item.status}</Tag>{item.enabled ? <Tag color="blue">可售</Tag> : <Tag>停用</Tag>}</Space> },
{ title: "更新时间", dataIndex: "updatedAt", render: formatAdminDate },
{
title: "操作",
fixed: "right",
render: (_, item) => <Space>
{canWrite && <Button type="link" onClick={() => openProduct(item)}>编辑草稿</Button>}
{canPublish && item.status !== "published" && <Button type="link" loading={publishingId === item.id} onClick={() => setPublishTarget(item)}>发布</Button>}
</Space>,
},
];
return <List title="商品与权益" headerButtons={canWrite ? <Button type="primary" icon={<PlusOutlined />} onClick={() => openProduct()}>新建商品</Button> : null}>
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<Form {...table.searchFormProps} layout="inline" style={{ rowGap: 8 }}>
<Form.Item name="q" label="搜索"><Input.Search allowClear placeholder="商品名称或代码" /></Form.Item>
<Form.Item name="status" label="状态">
<Select
allowClear
placeholder="全部状态"
style={{ minWidth: 140 }}
options={["draft", "published", "retired"].map((value) => ({ value, label: value }))}
/>
</Form.Item>
</Form>
<Table {...table.tableProps} columns={columns} rowKey="id" scroll={{ x: "max-content" }} />
</Space>
<Modal title={editing ? `编辑 ${editing.name}` : "新建商品"} open={open} width={860} confirmLoading={saving} okText="保存草稿" cancelText="取消" onOk={() => form.submit()} onCancel={() => setOpen(false)} destroyOnHidden>
<Form<ProductForm> form={form} layout="vertical" onFinish={prepareSave} requiredMark="optional">
<Row gutter={16}>
<Col xs={24} md={8}><Form.Item name="code" label="商品代码" rules={[{ required: true }, { pattern: /^[a-z][a-z0-9_]{1,79}$/ }]}><Input disabled={Boolean(editing)} /></Form.Item></Col>
<Col xs={24} md={8}><Form.Item name="name" label="名称" rules={[{ required: true }, { max: 80 }]}><Input /></Form.Item></Col>
<Col xs={24} md={8}><Form.Item name="priceYuan" label="价格(元)" rules={[{ required: true }]}><InputNumber min={0.01} precision={2} style={{ width: "100%" }} /></Form.Item></Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12} lg={6}><Form.Item name="productType" label="类型" rules={[{ required: true }]}><Select options={["credit_pack", "trial", "subscription"].map((value) => ({ value, label: value }))} /></Form.Item></Col>
<Col xs={24} md={12} lg={6}><Form.Item name="billingPeriod" label="计费周期" rules={[{ required: true }]}><Select options={["none", "day", "month", "year"].map((value) => ({ value, label: value }))} /></Form.Item></Col>
<Col xs={24} md={12} lg={6}><Form.Item name="intervalCount" label="周期数量" rules={[{ required: true }]}><InputNumber min={0} precision={0} style={{ width: "100%" }} /></Form.Item></Col>
<Col xs={24} md={12} lg={6}><Form.Item name="sortOrder" label="排序" rules={[{ required: true }]}><InputNumber precision={0} style={{ width: "100%" }} /></Form.Item></Col>
</Row>
<Form.Item name="description" label="购买页说明" rules={[{ max: 1000 }]}><Input.TextArea rows={2} showCount maxLength={1000} /></Form.Item>
<Form.Item name="entitlementsJson" label="权益 JSON" extra="每项必须包含 featureKey、allowanceType、allowanceCount、resetPeriod、metadata。" rules={[{ required: true }]}><Input.TextArea rows={10} spellCheck={false} /></Form.Item>
<Row gutter={16}>
<Col xs={24} md={8}><Form.Item name="currency" label="币种" rules={[{ required: true }, { len: 3 }]}><Input /></Form.Item></Col>
<Col xs={24} md={8}><Form.Item name="enabled" label="可售" valuePropName="checked"><Switch /></Form.Item></Col>
<Col xs={24} md={8}><Form.Item name="oneTimePerUser" label="每人限购一次" valuePropName="checked"><Switch /></Form.Item></Col>
</Row>
</Form>
</Modal>
<ReasonActionModal
open={Boolean(pendingSave)}
title="保存商品草稿"
okText="验证并保存"
confirmLoading={saving}
reauthPermission="billing.products.write"
onCancel={() => setPendingSave(null)}
onSubmit={save}
/>
<ReasonActionModal
open={Boolean(publishTarget)}
title={`发布商品${publishTarget ? `${publishTarget.name}` : ""}`}
okText="确认发布"
confirmLoading={Boolean(publishingId)}
reauthPermission="billing.products.publish"
onCancel={() => setPublishTarget(null)}
onSubmit={(reason) => publish(publishTarget!, reason)}
/>
</List>;
}