feat(admin): add operations resources and audit UI

This commit is contained in:
Jesse_Chen
2026-08-06 20:15:08 +08:00
parent 28e04857fa
commit 5622ab448a
39 changed files with 3770 additions and 293 deletions
+87 -37
View File
@@ -7,18 +7,20 @@ import type {
CrudFilter,
DataProvider,
HttpError,
CreateParams,
DeleteOneParams,
GetListParams,
GetOneParams,
UpdateParams,
CreateParams,
DeleteOneParams,
GetListParams,
GetOneParams,
UpdateParams,
} from "@refinedev/core";
export type AdminIdentity = {
id: string;
email: string;
name: string;
role: "admin";
roles: string[];
permissions: string[];
requiresMfa: boolean;
};
const apiBase = "/api/admin";
@@ -26,11 +28,15 @@ let identityCache: AdminIdentity | null = null;
function logicalFilters(filters: CrudFilter[] | undefined) {
return (filters ?? []).filter(
(filter): filter is Extract<CrudFilter, { field: string }> => "field" in filter,
(filter): filter is Extract<CrudFilter, { field: string }> =>
"field" in filter,
);
}
async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
export async function adminRequestJson<T>(
url: string,
init?: RequestInit,
): Promise<T> {
const response = await fetch(url, {
cache: "no-store",
credentials: "same-origin",
@@ -42,10 +48,13 @@ async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload && typeof payload.error === "string"
? payload.error
: "后台请求失败";
throw { message, statusCode: response.status } satisfies HttpError;
const message =
payload && typeof payload.error === "string"
? payload.error
: "后台请求失败";
throw Object.assign(new Error(message), {
statusCode: response.status,
} satisfies Partial<HttpError>);
}
return payload as T;
}
@@ -65,7 +74,12 @@ function listParams(
search.set("order", sorter.order);
}
for (const filter of logicalFilters(filters)) {
if (filter.value === undefined || filter.value === null || filter.value === "") continue;
if (
filter.value === undefined ||
filter.value === null ||
filter.value === ""
)
continue;
if (filter.field === "q" || filter.field === "status") {
search.set(filter.field, String(filter.value));
}
@@ -74,29 +88,44 @@ function listParams(
}
export const adminDataProvider: DataProvider = {
async getList<TData extends BaseRecord>({ resource, pagination, sorters, filters }: GetListParams) {
async getList<TData extends BaseRecord>({
resource,
pagination,
sorters,
filters,
}: GetListParams) {
const search = listParams(pagination, sorters, filters);
return requestJson<{ data: TData[]; total: number }>(
return adminRequestJson<{ data: TData[]; total: number }>(
`${apiBase}/${resource}?${search}`,
);
},
async getOne<TData extends BaseRecord>({ resource, id }: GetOneParams) {
return requestJson<{ data: TData }>(`${apiBase}/${resource}/${id}`);
return adminRequestJson<{ data: TData }>(`${apiBase}/${resource}/${id}`);
},
async create<TData extends BaseRecord, TVariables>({ resource, variables }: CreateParams<TVariables>) {
return requestJson<{ data: TData }>(`${apiBase}/${resource}`, {
async create<TData extends BaseRecord, TVariables>({
resource,
variables,
}: CreateParams<TVariables>) {
return adminRequestJson<{ data: TData }>(`${apiBase}/${resource}`, {
method: "POST",
body: JSON.stringify(variables),
});
},
async update<TData extends BaseRecord, TVariables>({ resource, id, variables }: UpdateParams<TVariables>) {
return requestJson<{ data: TData }>(`${apiBase}/${resource}/${id}`, {
async update<TData extends BaseRecord, TVariables>({
resource,
id,
variables,
}: UpdateParams<TVariables>) {
return adminRequestJson<{ data: TData }>(`${apiBase}/${resource}/${id}`, {
method: "PATCH",
body: JSON.stringify(variables),
});
},
async deleteOne<TData extends BaseRecord, TVariables>({ resource, id }: DeleteOneParams<TVariables>) {
return requestJson<{ data: TData }>(`${apiBase}/${resource}/${id}`, {
async deleteOne<TData extends BaseRecord, TVariables>({
resource,
id,
}: DeleteOneParams<TVariables>) {
return adminRequestJson<{ data: TData }>(`${apiBase}/${resource}/${id}`, {
method: "DELETE",
});
},
@@ -105,7 +134,9 @@ export const adminDataProvider: DataProvider = {
async function loadIdentity(): Promise<AdminIdentity> {
if (identityCache) return identityCache;
const payload = await requestJson<{ user: AdminIdentity }>(`${apiBase}/session`);
const payload = await adminRequestJson<{ user: AdminIdentity }>(
`${apiBase}/session`,
);
identityCache = payload.user;
return identityCache;
}
@@ -142,30 +173,49 @@ export const adminAuthProvider: AuthProvider = {
return { error: error as HttpError };
},
async getPermissions() {
return (await loadIdentity()).role;
return (await loadIdentity()).permissions;
},
async getIdentity() {
return loadIdentity();
},
};
const readOnlyResources = new Set([
"users",
"credit-transactions",
"consultations",
"audit-logs",
]);
const resourcePermissions: Record<string, { read: string; write?: string }> = {
administrators: {
read: "admin.users.read",
write: "admin.users.manage_roles",
},
roles: { read: "admin.users.read" },
customers: { read: "admin.customers.read" },
codes: { read: "billing.orders.read", write: "billing.adjustments.write" },
"credit-transactions": { read: "billing.orders.read" },
consultations: { read: "billing.orders.read" },
"audit-logs": { read: "audit.read" },
payments: { read: "billing.orders.read" },
packages: { read: "billing.products.read", write: "billing.products.write" },
products: { read: "billing.products.read", write: "billing.products.write" },
subscriptions: {
read: "billing.orders.read",
write: "billing.adjustments.write",
},
orders: { read: "billing.orders.read", write: "billing.adjustments.write" },
usage: { read: "billing.orders.read" },
models: { read: "models.read", write: "models.write" },
"model-releases": { read: "models.read", write: "models.publish" },
"feature-flags": { read: "admin.access", write: "ops.flags.write" },
security: { read: "admin.access", write: "admin.access" },
};
export const adminAccessControlProvider: AccessControlProvider = {
async can({ resource, action }) {
const role = (await loadIdentity()).role;
if (action === "list" || action === "show") return { can: true };
if (readOnlyResources.has(resource ?? "")) {
return { can: false, reason: "此资源只读" };
}
return role === "admin"
const identity = await loadIdentity();
const required = resourcePermissions[resource ?? ""];
if (!required) return { can: false, reason: "未知管理资源" };
const permission =
action === "list" || action === "show" ? required.read : required.write;
return permission && identity.permissions.includes(permission)
? { can: true }
: { can: false, reason: "无管理员权限" };
: { can: false, reason: "无此操作权限" };
},
options: {
buttons: { enableAccessControl: true, hideIfUnauthorized: true },