fix(admin): preserve capability route contract

This commit is contained in:
Jesse
2026-07-28 10:04:36 +08:00
parent 635c91920c
commit 218cee579e
8 changed files with 191 additions and 169 deletions
+4 -4
View File
@@ -32,10 +32,10 @@ export function AdminApp({ children }: { children: ReactNode }) {
notificationProvider={notificationProvider}
resources={[
{ name: "codes", list: "/admin/codes", meta: { label: "兑换码", icon: <GiftOutlined /> } },
{ name: "users", list: "/admin/users", meta: { label: "用户资料", icon: <TeamOutlined /> } },
{ name: "credit-transactions", list: "/admin/credit-transactions", meta: { label: "积分流水", icon: <TransactionOutlined /> } },
{ name: "consultations", list: "/admin/consultations", meta: { label: "咨询请求", icon: <MessageOutlined /> } },
{ name: "audit-logs", list: "/admin/audit-logs", meta: { label: "审计日志", icon: <AuditOutlined /> } },
{ name: "users", list: "/admin/codes?resource=users", meta: { label: "用户资料", icon: <TeamOutlined /> } },
{ name: "credit-transactions", list: "/admin/codes?resource=credit-transactions", meta: { label: "积分流水", icon: <TransactionOutlined /> } },
{ name: "consultations", list: "/admin/codes?resource=consultations", meta: { label: "咨询请求", icon: <MessageOutlined /> } },
{ name: "audit-logs", list: "/admin/codes?resource=audit-logs", meta: { label: "审计日志", icon: <AuditOutlined /> } },
]}
options={{
syncWithLocation: true,
@@ -0,0 +1,40 @@
"use client";
import { Descriptions, Tag, type TableColumnsType } from "antd";
import { formatAdminDate, ResourceTable } from "@/components/admin/resource-table";
type AuditRecord = {
id: string;
actorEmail: string;
actorRole: string;
action: string;
targetId: string;
before: Record<string, unknown> | null;
after: Record<string, unknown> | null;
requestId: string;
createdAt: string;
};
const columns: TableColumnsType<AuditRecord> = [
{ title: "操作者", dataIndex: "actorEmail", sorter: true },
{ title: "角色", dataIndex: "actorRole", render: (value) => <Tag>{value}</Tag> },
{ title: "动作", dataIndex: "action", sorter: true },
{ title: "目标 ID", dataIndex: "targetId" },
{ title: "Request ID", dataIndex: "requestId" },
{ title: "时间", dataIndex: "createdAt", sorter: true, render: formatAdminDate },
];
export default function AuditLogsPage() {
return <ResourceTable<AuditRecord>
resource="audit-logs"
title="审计日志(只读)"
columns={columns}
statusOptions={[
{ label: "生成兑换码", value: "redemption_code.create" },
{ label: "修改兑换码", value: "redemption_code.update" },
{ label: "撤销兑换码", value: "redemption_code.revoke" },
]}
extra={<Descriptions size="small" items={[{ key: "policy", label: "策略", children: "仅保存脱敏前后值;日志只追加" }]} />}
/>;
}
@@ -0,0 +1,167 @@
"use client";
import { useCreate, useDelete, useGetIdentity, usePermissions, useUpdate } from "@refinedev/core";
import { Button, DatePicker, Form, Input, InputNumber, Modal, Space, Tag, Typography, type TableColumnsType } from "antd";
import dayjs from "dayjs";
import { useState } from "react";
import { formatAdminDate, ResourceTable } from "@/components/admin/resource-table";
import type { AdminIdentity } from "@/lib/admin/providers";
type CodeRecord = {
id: string;
code?: string;
mask: string;
credits: number;
expiresAt: string | null;
note: string | null;
createdAt: string;
redeemedEmail: string | null;
redeemedAt: string | null;
revokedAt: string | null;
status: "available" | "expired" | "redeemed" | "revoked";
};
type CreateValues = {
credits: number;
count: number;
expiresAt?: ReturnType<typeof dayjs>;
note?: string;
};
type EditValues = { note?: string; expiresAt?: ReturnType<typeof dayjs> | null };
const statusColors: Record<CodeRecord["status"], string> = {
available: "green",
expired: "orange",
redeemed: "blue",
revoked: "red",
};
export default function CodesPage() {
const { data: role } = usePermissions<"admin" | "viewer">({});
const { data: identity } = useGetIdentity<AdminIdentity>();
const { mutate: createCodes, mutation: createMutation } = useCreate<{ id: string; generated: CodeRecord[] }>();
const { mutate: updateCode, mutation: updateMutation } = useUpdate<CodeRecord>();
const { mutate: revokeCode, mutation: revokeMutation } = useDelete<CodeRecord>();
const [createOpen, setCreateOpen] = useState(false);
const [editRecord, setEditRecord] = useState<CodeRecord | null>(null);
const [generated, setGenerated] = useState<CodeRecord[]>([]);
const [createForm] = Form.useForm<CreateValues>();
const [editForm] = Form.useForm<EditValues>();
const writable = role === "admin";
function submitCreate(values: CreateValues) {
createCodes({
resource: "codes",
values: {
credits: values.credits,
count: values.count,
expiresAt: values.expiresAt?.toISOString() ?? null,
note: values.note?.trim() || null,
},
successNotification: false,
}, {
onSuccess(result) {
setGenerated(result.data.generated);
setCreateOpen(false);
createForm.resetFields();
},
});
}
function submitEdit(values: EditValues) {
if (!editRecord) return;
updateCode({
resource: "codes",
id: editRecord.id,
values: {
note: values.note?.trim() || null,
expiresAt: values.expiresAt?.toISOString() ?? null,
},
}, { onSuccess: () => setEditRecord(null) });
}
function confirmRevoke(record: CodeRecord) {
Modal.confirm({
title: "撤销此兑换码?",
content: `${record.mask} 撤销后不可兑换,且不能恢复。`,
okText: "确认撤销",
okButtonProps: { danger: true },
cancelText: "取消",
onOk: () => new Promise<void>((resolve, reject) => {
revokeCode({ resource: "codes", id: record.id }, {
onSuccess: () => resolve(),
onError: () => reject(new Error("撤销失败")),
});
}),
});
}
const columns: TableColumnsType<CodeRecord> = [
{ title: "兑换码", dataIndex: "mask" },
{ title: "点数", dataIndex: "credits", sorter: true },
{ title: "状态", dataIndex: "status", sorter: true, render: (value) => <Tag color={statusColors[value as CodeRecord["status"]]}>{value}</Tag> },
{ title: "到期时间", dataIndex: "expiresAt", sorter: true, render: formatAdminDate },
{ title: "备注", dataIndex: "note", render: (value) => value || "—" },
{ title: "兑换账户", dataIndex: "redeemedEmail", render: (value) => value || "—" },
{ title: "兑换时间", dataIndex: "redeemedAt", render: formatAdminDate },
{ title: "撤销时间", dataIndex: "revokedAt", render: formatAdminDate },
{ title: "创建时间", dataIndex: "createdAt", sorter: true, render: formatAdminDate },
{
title: "操作",
fixed: "right",
render: (_, record) => writable && record.status !== "redeemed" && record.status !== "revoked" ? (
<Space>
<Button size="small" onClick={() => {
setEditRecord(record);
editForm.setFieldsValue({
note: record.note ?? undefined,
expiresAt: record.expiresAt ? dayjs(record.expiresAt) : null,
});
}}></Button>
<Button danger size="small" loading={revokeMutation.isPending} onClick={() => confirmRevoke(record)}></Button>
</Space>
) : "—",
},
];
return (
<>
<ResourceTable<CodeRecord>
resource="codes"
title={`兑换码${identity ? ` · ${identity.email} (${identity.role})` : ""}`}
columns={columns}
statusOptions={[
{ label: "可用", value: "available" },
{ label: "已过期", value: "expired" },
{ label: "已兑换", value: "redeemed" },
{ label: "已撤销", value: "revoked" },
]}
extra={writable ? <Button type="primary" onClick={() => setCreateOpen(true)}></Button> : <Tag>viewer </Tag>}
/>
<Modal title="批量生成兑换码" open={createOpen} onCancel={() => setCreateOpen(false)} footer={null} destroyOnHidden>
<Form form={createForm} layout="vertical" initialValues={{ credits: 10, count: 1 }} onFinish={submitCreate}>
<Form.Item name="credits" label="每个点数" rules={[{ required: true }]}><InputNumber min={1} max={1_000_000} style={{ width: "100%" }} /></Form.Item>
<Form.Item name="count" label="数量" rules={[{ required: true }]}><InputNumber min={1} max={100} style={{ width: "100%" }} /></Form.Item>
<Form.Item name="expiresAt" label="到期时间"><DatePicker showTime style={{ width: "100%" }} /></Form.Item>
<Form.Item name="note" label="备注"><Input maxLength={500} /></Form.Item>
<Button block type="primary" htmlType="submit" loading={createMutation.isPending}></Button>
</Form>
</Modal>
<Modal title="完整兑换码(仅显示本次)" open={generated.length > 0} onCancel={() => setGenerated([])} footer={<Button onClick={() => setGenerated([])}></Button>}>
<Typography.Paragraph type="warning"></Typography.Paragraph>
{generated.map((record) => <Typography.Paragraph copyable key={record.code}><Typography.Text code>{record.code}</Typography.Text></Typography.Paragraph>)}
</Modal>
<Modal title="编辑未兑换码" open={Boolean(editRecord)} onCancel={() => setEditRecord(null)} footer={null} destroyOnHidden>
<Form form={editForm} layout="vertical" onFinish={submitEdit}>
<Form.Item name="expiresAt" label="到期时间"><DatePicker showTime style={{ width: "100%" }} /></Form.Item>
<Form.Item name="note" label="备注"><Input maxLength={500} /></Form.Item>
<Button block type="primary" htmlType="submit" loading={updateMutation.isPending}></Button>
</Form>
</Modal>
</>
);
}
@@ -0,0 +1,40 @@
"use client";
import { Tag, type TableColumnsType } from "antd";
import { formatAdminDate, ResourceTable } from "@/components/admin/resource-table";
type ConsultationRecord = {
id: string;
email: string | null;
requestId: string;
status: string;
createdAt: string;
updatedAt: string;
};
const colors: Record<string, string> = {
reserved: "gold",
completed: "green",
cancelled: "default",
};
const columns: TableColumnsType<ConsultationRecord> = [
{ title: "用户", dataIndex: "email", render: (value) => value || "—" },
{ title: "请求 ID", dataIndex: "requestId" },
{ title: "状态", dataIndex: "status", sorter: true, render: (value) => <Tag color={colors[value]}>{value}</Tag> },
{ title: "创建时间", dataIndex: "createdAt", sorter: true, render: formatAdminDate },
{ title: "更新时间", dataIndex: "updatedAt", sorter: true, render: formatAdminDate },
];
export default function ConsultationsPage() {
return <ResourceTable<ConsultationRecord>
resource="consultations"
title="咨询请求(只读)"
columns={columns}
statusOptions={[
{ label: "已预扣", value: "reserved" },
{ label: "已完成", value: "completed" },
{ label: "已取消", value: "cancelled" },
]}
/>;
}
@@ -0,0 +1,42 @@
"use client";
import { Tag, type TableColumnsType } from "antd";
import { formatAdminDate, ResourceTable } from "@/components/admin/resource-table";
type TransactionRecord = {
id: string;
email: string | null;
type: string;
amount: number;
balanceAfter: number;
requestId: string;
model: string | null;
inputTokens: number | null;
outputTokens: number | null;
createdAt: string;
};
const columns: TableColumnsType<TransactionRecord> = [
{ title: "用户", dataIndex: "email", render: (value) => value || "—" },
{ title: "类型", dataIndex: "type", sorter: true, render: (value) => <Tag>{value}</Tag> },
{ title: "变动", dataIndex: "amount", sorter: true, render: (value) => value > 0 ? `+${value}` : value },
{ title: "余额", dataIndex: "balanceAfter", sorter: true },
{ title: "请求 ID", dataIndex: "requestId" },
{ title: "模型", dataIndex: "model", render: (value) => value || "—" },
{ title: "输入/输出 token", render: (_, row) => `${row.inputTokens ?? "—"} / ${row.outputTokens ?? "—"}` },
{ title: "时间", dataIndex: "createdAt", sorter: true, render: formatAdminDate },
];
export default function CreditTransactionsPage() {
return <ResourceTable<TransactionRecord>
resource="credit-transactions"
title="积分流水(只读)"
columns={columns}
statusOptions={[
{ label: "兑换", value: "redeem" },
{ label: "预扣", value: "reserve" },
{ label: "退款", value: "refund" },
]}
/>;
}
@@ -0,0 +1,36 @@
"use client";
import { Tag, type TableColumnsType } from "antd";
import { formatAdminDate, ResourceTable } from "@/components/admin/resource-table";
type UserRecord = {
id: string;
email: string;
name: string | null;
role: string;
emailVerified: boolean;
banned: boolean;
createdAt: string;
credits: number;
birthDate: string | null;
birthTimeStatus: string | null;
birthPlace: string | null;
};
const columns: TableColumnsType<UserRecord> = [
{ title: "邮箱", dataIndex: "email", sorter: true },
{ title: "姓名", dataIndex: "name", sorter: true, render: (value) => value || "—" },
{ title: "角色", dataIndex: "role", render: (value) => <Tag>{value}</Tag> },
{ title: "积分", dataIndex: "credits", sorter: true },
{ title: "出生日期", dataIndex: "birthDate", render: (value) => value || "—" },
{ title: "出生时间状态", dataIndex: "birthTimeStatus", render: (value) => value || "—" },
{ title: "出生地", dataIndex: "birthPlace", render: (value) => value || "—" },
{ title: "邮箱验证", dataIndex: "emailVerified", render: (value) => value ? "已验证" : "未验证" },
{ title: "状态", dataIndex: "banned", render: (value) => value ? <Tag color="red"></Tag> : <Tag color="green"></Tag> },
{ title: "注册时间", dataIndex: "createdAt", sorter: true, render: formatAdminDate },
];
export default function UsersPage() {
return <ResourceTable<UserRecord> resource="users" title="用户资料(只读)" columns={columns} />;
}