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

302 lines
13 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,
Typography,
type TableColumnsType,
} from "antd";
import { useState } from "react";
import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers";
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[];
};
const productTypeLabels: Record<Product["productType"], string> = {
credit_pack: "点数包",
trial: "体验套餐",
subscription: "订阅套餐",
};
const billingPeriodLabels: Record<Product["billingPeriod"], string> = {
none: "一次性",
day: "按天",
month: "按月",
year: "按年",
};
const allowanceTypeLabels: Record<string, string> = {
access: "使用权限",
unlimited: "不限量",
quota: "限额",
credits: "点数",
};
const resetPeriodLabels: Record<string, string> = {
none: "不重置",
day: "每日",
month: "每月",
billing_period: "每个计费周期",
};
type ProductForm = Omit<Product, "id" | "version" | "priceCents" | "status" | "effectiveFrom" | "updatedAt" | "entitlements"> & {
id?: string;
priceYuan: number;
entitlementsJson: string;
};
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 [deletingId, setDeletingId] = useState<string | 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);
}
async function save(values: ProductForm) {
let entitlements: unknown;
try {
entitlements = JSON.parse(values.entitlementsJson);
} catch {
message.error("权益 JSON 格式不正确");
return;
}
setSaving(true);
try {
await adminRequestJson("/api/admin/products", {
method: "POST",
body: JSON.stringify({
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,
}),
});
message.success(editing && editing.status !== "draft" ? "已保存为新版本草稿,发布后才会替换在售套餐" : "商品草稿已保存");
setOpen(false);
form.resetFields();
await table.tableQuery.refetch();
} catch (error) {
message.error(error instanceof Error ? error.message : "保存商品草稿失败");
} finally {
setSaving(false);
}
}
async function publish(product: Product) {
setPublishingId(product.id);
try {
await adminRequestJson("/api/admin/products", {
method: "POST",
body: JSON.stringify({ action: "publish", id: product.id }),
});
message.success("商品已发布");
await table.tableQuery.refetch();
} catch (error) {
message.error(error instanceof Error ? error.message : "发布商品失败");
} finally {
setPublishingId(null);
}
}
async function remove(product: Product) {
setDeletingId(product.id);
try {
await adminRequestJson("/api/admin/products", {
method: "POST",
body: JSON.stringify({ action: "delete", id: product.id }),
});
message.success(product.status === "draft" ? "草稿已删除" : "商品已下架");
await table.tableQuery.refetch();
} catch (error) {
message.error(error instanceof Error ? error.message : product.status === "draft" ? "删除草稿失败" : "下架商品失败");
} finally {
setDeletingId(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: Product["productType"]) => productTypeLabels[value] },
{ title: "周期", render: (_, item) => item.billingPeriod === "none" ? billingPeriodLabels.none : `${item.intervalCount} 个周期(${billingPeriodLabels[item.billingPeriod]}` },
{ title: "价格", render: (_, item) => ${(item.priceCents / 100).toFixed(2)}` },
{ title: "权益", dataIndex: "entitlements", render: (items: Entitlement[]) => <div className="admin-text-list">{items.map((item) => `${item.featureKey}${allowanceTypeLabels[item.allowanceType] ?? item.allowanceType}${item.allowanceCount === null ? "" : ` ${item.allowanceCount}`} · ${resetPeriodLabels[item.resetPeriod] ?? item.resetPeriod}`).join("") || "—"}</div> },
{ title: "状态", render: (_, item) => <Text>{item.status === "published" ? "已发布" : item.status === "draft" ? "草稿" : item.status} · {item.enabled ? "可售" : "停用"}</Text> },
{ title: "更新时间", dataIndex: "updatedAt", render: formatAdminDate },
{
title: "操作",
fixed: "right",
render: (_, item) => <Space>
{canWrite && <Button type="link" onClick={() => openProduct(item)}>{item.status === "draft" ? "编辑草稿" : "修改"}</Button>}
{canPublish && item.status === "draft" && <Button type="link" loading={publishingId === item.id} onClick={() => void publish(item)}>发布</Button>}
{canWrite && item.status === "draft" && <Button type="link" loading={deletingId === item.id} onClick={() => void remove(item)}>删除</Button>}
{canWrite && item.status === "published" && <Button type="link" loading={deletingId === item.id} onClick={() => void remove(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>
{editing && editing.status !== "draft" ? <Text type="secondary">保存后会生成新版本草稿,不会立刻改动当前在售套餐。要从目录拿掉请用列表里的下架。</Text> : null}
<Form<ProductForm> form={form} layout="vertical" onFinish={save} 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={Object.entries(productTypeLabels).map(([value, label]) => ({ value, label }))} /></Form.Item></Col>
<Col xs={24} md={12} lg={6}><Form.Item name="billingPeriod" label="计费周期" rules={[{ required: true }]}><Select options={Object.entries(billingPeriodLabels).map(([value, label]) => ({ value, label }))} /></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。billingLimit 仅用于异常账号熔断,不是对‘随便聊’的总量承诺;会员总量不设硬配额,主要由 minuteLimit 与 dayLimit 保护真人容量。生时校正与完整报告单独计费,不应作为标准会员的免费权益。"
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>
</List>;
}