156 lines
6.3 KiB
TypeScript
156 lines
6.3 KiB
TypeScript
"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, Form, Input, InputNumber, Modal, 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 FeatureFlag = {
|
|
id: string;
|
|
flagKey: string;
|
|
version: number;
|
|
enabled: boolean;
|
|
rolloutPercentage: number;
|
|
config: Record<string, unknown>;
|
|
status: string;
|
|
createdAt: string;
|
|
publishedAt: string | null;
|
|
};
|
|
|
|
type FlagFilters = { q?: string; status?: string };
|
|
|
|
type FlagForm = {
|
|
flagKey: string;
|
|
enabled: boolean;
|
|
rolloutPercentage: number;
|
|
configJson: string;
|
|
};
|
|
|
|
export default function FeatureFlagsManagement() {
|
|
const { message } = App.useApp();
|
|
const { data: identity } = useGetIdentity<AdminIdentity>();
|
|
const table = useTable<FeatureFlag, { message: string; statusCode: number }, FlagFilters>({
|
|
resource: "feature-flags",
|
|
syncWithLocation: true,
|
|
pagination: { pageSize: 20 },
|
|
onSearch: ({ q, status }) => [
|
|
{ field: "q", operator: "contains", value: q },
|
|
{ field: "status", operator: "eq", value: status },
|
|
],
|
|
});
|
|
const [form] = Form.useForm<FlagForm>();
|
|
const [editing, setEditing] = useState<FeatureFlag | null>(null);
|
|
const [open, setOpen] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [publishingId, setPublishingId] = useState<string | null>(null);
|
|
const canWrite = Boolean(identity?.permissions.includes("ops.flags.write"));
|
|
|
|
function edit(item?: FeatureFlag) {
|
|
setEditing(item ?? null);
|
|
form.setFieldsValue(item ? {
|
|
flagKey: item.flagKey,
|
|
enabled: item.enabled,
|
|
rolloutPercentage: item.rolloutPercentage,
|
|
configJson: JSON.stringify(item.config, null, 2),
|
|
} : {
|
|
flagKey: "",
|
|
enabled: false,
|
|
rolloutPercentage: 0,
|
|
configJson: "{}",
|
|
});
|
|
setOpen(true);
|
|
}
|
|
|
|
async function save(values: FlagForm) {
|
|
setSaving(true);
|
|
try {
|
|
let config: unknown;
|
|
try {
|
|
config = JSON.parse(values.configJson);
|
|
} catch {
|
|
throw new Error("配置 JSON 格式不正确");
|
|
}
|
|
await adminRequestJson("/api/admin/feature-flags", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
action: "save",
|
|
id: editing?.id ?? null,
|
|
flagKey: values.flagKey.trim(),
|
|
enabled: values.enabled,
|
|
rolloutPercentage: values.rolloutPercentage,
|
|
config,
|
|
expectedVersion: editing?.version ?? null,
|
|
}),
|
|
});
|
|
message.success("功能开关草稿已保存");
|
|
setOpen(false);
|
|
await table.tableQuery.refetch();
|
|
} catch (error) {
|
|
message.error(error instanceof Error ? error.message : "保存失败");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
async function publish(item: FeatureFlag) {
|
|
setPublishingId(item.id);
|
|
try {
|
|
await adminRequestJson("/api/admin/feature-flags", {
|
|
method: "POST",
|
|
body: JSON.stringify({ action: "publish", id: item.id, expectedVersion: item.version }),
|
|
});
|
|
message.success("功能开关已发布");
|
|
await table.tableQuery.refetch();
|
|
} catch (error) {
|
|
message.error(error instanceof Error ? error.message : "发布失败");
|
|
} finally {
|
|
setPublishingId(null);
|
|
}
|
|
}
|
|
|
|
const columns: TableColumnsType<FeatureFlag> = [
|
|
{ title: "开关", render: (_, item) => <Space direction="vertical" size={0}><Text strong>{item.flagKey}</Text><Text type="secondary">v{item.version}</Text></Space> },
|
|
{ title: "状态", render: (_, item) => <Text>{item.status === "published" ? "已发布" : item.status === "draft" ? "草稿" : item.status} · {item.enabled ? "已开启" : "已关闭"}</Text> },
|
|
{ title: "灰度", dataIndex: "rolloutPercentage", render: (value) => `${value}%` },
|
|
{ title: "配置", dataIndex: "config", render: (value) => <Text code>{JSON.stringify(value)}</Text> },
|
|
{ title: "发布时间", dataIndex: "publishedAt", render: formatAdminDate },
|
|
{
|
|
title: "操作",
|
|
fixed: "right",
|
|
render: (_, item) => <Space>{canWrite && <Button type="link" onClick={() => edit(item)}>编辑草稿</Button>}{canWrite && item.status !== "published" && <Button type="link" loading={publishingId === item.id} onClick={() => void publish(item)}>发布</Button>}</Space>,
|
|
},
|
|
];
|
|
|
|
return <List title="功能开关" headerButtons={canWrite ? <Button type="primary" icon={<PlusOutlined />} onClick={() => edit()}>新增开关</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.flagKey}` : "新增功能开关"} open={open} okText="保存草稿" cancelText="取消" confirmLoading={saving} onOk={() => form.submit()} onCancel={() => setOpen(false)} destroyOnHidden>
|
|
<Form<FlagForm> form={form} layout="vertical" onFinish={save}>
|
|
<Form.Item name="flagKey" label="开关键" rules={[{ required: true }, { pattern: /^[a-z][a-z0-9._-]{1,99}$/ }]}><Input disabled={Boolean(editing)} /></Form.Item>
|
|
<Form.Item name="rolloutPercentage" label="灰度百分比" rules={[{ required: true }]}><InputNumber min={0} max={100} precision={0} style={{ width: "100%" }} /></Form.Item>
|
|
<Form.Item name="enabled" label="启用" valuePropName="checked"><Switch /></Form.Item>
|
|
<Form.Item name="configJson" label="配置 JSON" rules={[{ required: true }]}><Input.TextArea rows={6} spellCheck={false} /></Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
</List>;
|
|
}
|