165 lines
6.7 KiB
TypeScript
165 lines
6.7 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 { ConfirmActionModal } from "./confirm-action-modal";
|
||
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 [publishTarget, setPublishTarget] = useState<FeatureFlag | 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();
|
||
setPublishTarget(null);
|
||
} 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={() => setPublishTarget(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>
|
||
<ConfirmActionModal
|
||
open={Boolean(publishTarget)}
|
||
title={`发布功能开关${publishTarget ? `:${publishTarget.flagKey}` : ""}`}
|
||
okText="确认发布"
|
||
confirmLoading={Boolean(publishingId)}
|
||
onCancel={() => setPublishTarget(null)}
|
||
onConfirm={() => publish(publishTarget!)}
|
||
/>
|
||
</List>;
|
||
}
|