fix(admin): remove manual reasons and repair code creation
This commit is contained in:
@@ -27,7 +27,6 @@ const mutationSchema = z.object({
|
||||
email: z.string().trim().email().optional(),
|
||||
userId: z.string().uuid().optional(),
|
||||
roleCode: z.enum(["owner", "model_admin", "billing_admin", "operations", "support", "auditor"]),
|
||||
reason: z.string().trim().min(1).max(500),
|
||||
}).refine((value) => Boolean(value.email || value.userId), {
|
||||
message: "email_or_user_id_required",
|
||||
});
|
||||
@@ -90,7 +89,7 @@ async function mutate(request: Request, assign: boolean) {
|
||||
if (!targetUserId) return NextResponse.json({ error: "用户不存在" }, { status: 404 });
|
||||
const rows = await queryAdminRows<{ user_id: string; role_code: string; assigned: boolean }>(
|
||||
"select * from public.admin_manage_role($1, $2, $3, $4, $5, $6)",
|
||||
[session.user.id, targetUserId, parsed.data.roleCode, assign, parsed.data.reason, requestId(request)],
|
||||
[session.user.id, targetUserId, parsed.data.roleCode, assign, "admin_console_manage_role", requestId(request)],
|
||||
);
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
} catch (error) {
|
||||
|
||||
@@ -12,14 +12,10 @@ import {
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const paramsSchema = z.object({ id: z.string().uuid() });
|
||||
const revokeCodeSchema = z.object({
|
||||
reason: z.string().trim().min(1).max(500),
|
||||
});
|
||||
const updateCodeSchema = z
|
||||
.object({
|
||||
note: z.string().trim().max(500).nullable().optional(),
|
||||
expiresAt: z.string().datetime({ offset: true }).nullable().optional(),
|
||||
reason: z.string().trim().min(1).max(500),
|
||||
})
|
||||
.refine((value) => "note" in value || "expiresAt" in value, {
|
||||
message: "至少提供一个可修改字段",
|
||||
@@ -52,7 +48,7 @@ export async function PATCH(
|
||||
p_note: body.note ?? null,
|
||||
p_set_expires_at: "expiresAt" in body,
|
||||
p_expires_at: body.expiresAt ?? null,
|
||||
p_reason: body.reason,
|
||||
p_reason: "admin_console_update_redemption_code",
|
||||
},
|
||||
);
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
@@ -71,15 +67,12 @@ export async function DELETE(
|
||||
"billing.adjustments.write",
|
||||
);
|
||||
const parsed = paramsSchema.safeParse(await context.params);
|
||||
const parsedBody = revokeCodeSchema.safeParse(
|
||||
await request.json().catch(() => null),
|
||||
);
|
||||
if (!parsed.success || !parsedBody.success) return invalidQueryResponse();
|
||||
if (!parsed.success) return invalidQueryResponse();
|
||||
const rows = await runCodeRpc(
|
||||
"admin_revoke_redemption_code",
|
||||
session,
|
||||
requestId(request),
|
||||
{ p_code_id: parsed.data.id, p_reason: parsedBody.data.reason },
|
||||
{ p_code_id: parsed.data.id, p_reason: "admin_console_revoke_redemption_code" },
|
||||
);
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
} catch (error) {
|
||||
|
||||
@@ -28,7 +28,6 @@ const createCodesSchema = z.object({
|
||||
count: z.number().int().min(1).max(100),
|
||||
expiresAt: z.string().datetime({ offset: true }).nullable().optional(),
|
||||
note: z.string().trim().max(500).nullable().optional(),
|
||||
reason: z.string().trim().min(1).max(500),
|
||||
});
|
||||
|
||||
type CodeRow = {
|
||||
@@ -147,7 +146,7 @@ export async function POST(request: Request) {
|
||||
"admin_create_redemption_codes",
|
||||
session,
|
||||
operationRequestId,
|
||||
{ p_codes: records, p_reason: parsed.data.reason },
|
||||
{ p_codes: records, p_reason: "admin_console_create_redemption_codes" },
|
||||
);
|
||||
const byMask = new Map<string, RedemptionCodeRecord>(
|
||||
stored.map((record) => [record.mask, record]),
|
||||
|
||||
@@ -14,7 +14,6 @@ export const runtime = "nodejs";
|
||||
const resetSchema = z.object({
|
||||
userId: z.string().uuid(),
|
||||
confirmation: z.literal("RESET"),
|
||||
reason: z.string().trim().min(1).max(500),
|
||||
});
|
||||
|
||||
type ResetRow = {
|
||||
@@ -40,7 +39,7 @@ export async function POST(request: Request) {
|
||||
[
|
||||
session.user.id,
|
||||
parsed.data.userId,
|
||||
parsed.data.reason,
|
||||
"admin_console_reset_customer",
|
||||
requestId(request),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -5,9 +5,9 @@ import { pageOffset, queryAdminRows } from "@/lib/admin/database";
|
||||
import { adminErrorResponse, invalidQueryResponse, parseListQuery, requestId, requireAdminMutation } from "@/lib/admin/http";
|
||||
export const runtime="nodejs";
|
||||
const schema=z.discriminatedUnion("action",[
|
||||
z.object({action:z.literal("save"),id:z.string().uuid().nullable().optional(),flagKey:z.string().regex(/^[a-z][a-z0-9._-]{1,99}$/),enabled:z.boolean(),rolloutPercentage:z.number().int().min(0).max(100),config:z.record(z.string(),z.unknown()).default({}),expectedVersion:z.number().int().positive().nullable().optional(),reason:z.string().trim().min(1).max(500)}).strict(),
|
||||
z.object({action:z.literal("publish"),id:z.string().uuid(),expectedVersion:z.number().int().positive(),reason:z.string().trim().min(1).max(500)}).strict(),
|
||||
z.object({action:z.literal("save"),id:z.string().uuid().nullable().optional(),flagKey:z.string().regex(/^[a-z][a-z0-9._-]{1,99}$/),enabled:z.boolean(),rolloutPercentage:z.number().int().min(0).max(100),config:z.record(z.string(),z.unknown()).default({}),expectedVersion:z.number().int().positive().nullable().optional()}).strict(),
|
||||
z.object({action:z.literal("publish"),id:z.string().uuid(),expectedVersion:z.number().int().positive()}).strict(),
|
||||
]);
|
||||
type Row={id:string;flag_key:string;version:number;enabled:boolean;rollout_percentage:number;config:Record<string,unknown>;status:string;created_at:Date;published_at:Date|null;total_count:string};
|
||||
export async function GET(request:Request){try{await requirePermission("admin.access");const p=parseListQuery(request);if(!p.success)return invalidQueryResponse(p.error.flatten());const q=p.data.q?`%${p.data.q}%`:null;const rows=await queryAdminRows<Row>(`select f.*,count(*) over()::text total_count from public.feature_flags f where ($1::text is null or f.flag_key ilike $1) and ($2::text is null or f.status=$2) order by f.created_at desc limit $3 offset $4`,[q,p.data.status??null,p.data.pageSize,pageOffset(p.data.page,p.data.pageSize)]);return NextResponse.json({data:rows.map(r=>({id:r.id,flagKey:r.flag_key,version:r.version,enabled:r.enabled,rolloutPercentage:r.rollout_percentage,config:r.config,status:r.status,createdAt:r.created_at.toISOString(),publishedAt:r.published_at?.toISOString()??null})),total:Number(rows[0]?.total_count??0)});}catch(e){return adminErrorResponse(e)}}
|
||||
export async function POST(request:Request){try{const b=schema.safeParse(await request.json().catch(()=>null));if(!b.success)return invalidQueryResponse(b.error.flatten());const session=await requireAdminMutation(request,"ops.flags.write");const rid=requestId(request);if(b.data.action==="publish"){const rows=await queryAdminRows<{id:string}>("select public.admin_publish_feature_flag($1,$2,$3,$4,$5) id",[session.user.id,b.data.id,b.data.expectedVersion,b.data.reason,rid]);return NextResponse.json({data:{id:rows[0]!.id,requestId:rid}})}const v=b.data;const rows=await queryAdminRows<{id:string}>("select public.admin_save_feature_flag($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9) id",[session.user.id,v.id??null,v.flagKey,v.enabled,v.rolloutPercentage,JSON.stringify(v.config),v.expectedVersion??null,v.reason,rid]);return NextResponse.json({data:{id:rows[0]!.id,requestId:rid}})}catch(e){return adminErrorResponse(e)}}
|
||||
export async function POST(request:Request){try{const b=schema.safeParse(await request.json().catch(()=>null));if(!b.success)return invalidQueryResponse(b.error.flatten());const session=await requireAdminMutation(request,"ops.flags.write");const rid=requestId(request);if(b.data.action==="publish"){const rows=await queryAdminRows<{id:string}>("select public.admin_publish_feature_flag($1,$2,$3,$4,$5) id",[session.user.id,b.data.id,b.data.expectedVersion,"admin_console_publish_feature_flag",rid]);return NextResponse.json({data:{id:rows[0]!.id,requestId:rid}})}const v=b.data;const rows=await queryAdminRows<{id:string}>("select public.admin_save_feature_flag($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9) id",[session.user.id,v.id??null,v.flagKey,v.enabled,v.rolloutPercentage,JSON.stringify(v.config),v.expectedVersion??null,"admin_console_save_feature_flag",rid]);return NextResponse.json({data:{id:rows[0]!.id,requestId:rid}})}catch(e){return adminErrorResponse(e)}}
|
||||
|
||||
@@ -17,7 +17,6 @@ const adjustmentSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
action: z.enum(["retry_grant", "compensate", "record_refund"]),
|
||||
expectedVersion: z.number().int().min(0),
|
||||
reason: z.string().trim().min(1).max(500),
|
||||
});
|
||||
|
||||
type Row = {
|
||||
@@ -125,7 +124,7 @@ export async function POST(request: Request) {
|
||||
parsed.data.id,
|
||||
parsed.data.action,
|
||||
parsed.data.expectedVersion,
|
||||
parsed.data.reason,
|
||||
"admin_console_adjust_order",
|
||||
requestId(request),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -32,12 +32,10 @@ const saveSchema = z.object({
|
||||
sortOrder: z.number().int(),
|
||||
oneTimePerUser: z.boolean(),
|
||||
entitlements: z.array(entitlementSchema).min(1),
|
||||
reason: z.string().trim().min(1).max(500),
|
||||
}).strict();
|
||||
const publishSchema = z.object({
|
||||
action: z.literal("publish"),
|
||||
id: z.string().uuid(),
|
||||
reason: z.string().trim().min(1).max(500),
|
||||
}).strict();
|
||||
const mutationSchema = z.discriminatedUnion("action", [saveSchema, publishSchema]);
|
||||
|
||||
@@ -91,7 +89,7 @@ export async function POST(request: Request) {
|
||||
const session = await requireAdminMutation(request, permission);
|
||||
const rid = requestId(request);
|
||||
if (body.data.action === "publish") {
|
||||
const rows = await queryAdminRows<{ id: string }>("select public.admin_publish_product($1,$2,$3,$4) id", [session.user.id, body.data.id, body.data.reason, rid]);
|
||||
const rows = await queryAdminRows<{ id: string }>("select public.admin_publish_product($1,$2,$3,$4) id", [session.user.id, body.data.id, "admin_console_publish_product", rid]);
|
||||
return NextResponse.json({ data: { id: rows[0]!.id, requestId: rid } });
|
||||
}
|
||||
const value = body.data;
|
||||
@@ -99,7 +97,7 @@ export async function POST(request: Request) {
|
||||
select public.admin_save_product_draft($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14::jsonb,$15,$16) id
|
||||
`, [session.user.id, value.id ?? null, value.code, value.name, value.description, value.productType,
|
||||
value.billingPeriod, value.intervalCount, value.priceCents, value.currency, value.enabled, value.sortOrder,
|
||||
value.oneTimePerUser, JSON.stringify(value.entitlements), value.reason, rid]);
|
||||
value.oneTimePerUser, JSON.stringify(value.entitlements), "admin_console_save_product", rid]);
|
||||
return NextResponse.json({ data: { id: rows[0]!.id, requestId: rid } });
|
||||
} catch (error) { return adminErrorResponse(error); }
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ const mutationSchema = z.object({
|
||||
action: z.enum(["extend", "revoke"]),
|
||||
days: z.number().int().min(1).max(3660).optional(),
|
||||
expectedEndsAt: z.string().datetime(),
|
||||
reason: z.string().trim().min(1).max(500),
|
||||
}).strict();
|
||||
type Row = { id:string; user_id:string; email:string|null; product_code:string; product_version:number; status:string; starts_at:Date; ends_at:Date; created_at:Date; total_count:string };
|
||||
export async function GET(request: Request) {
|
||||
@@ -29,7 +28,7 @@ export async function POST(request: Request){
|
||||
try{
|
||||
const body=mutationSchema.safeParse(await request.json().catch(()=>null)); if(!body.success)return invalidQueryResponse(body.error.flatten());
|
||||
const session=await requireAdminMutation(request,"billing.adjustments.write"); const rid=requestId(request);
|
||||
const rows=await queryAdminRows<{id:string}>("select public.admin_adjust_subscription($1,$2,$3,$4,$5,$6,$7) id",[session.user.id,body.data.id,body.data.action,body.data.days??null,body.data.expectedEndsAt,body.data.reason,rid]);
|
||||
const rows=await queryAdminRows<{id:string}>("select public.admin_adjust_subscription($1,$2,$3,$4,$5,$6,$7) id",[session.user.id,body.data.id,body.data.action,body.data.days??null,body.data.expectedEndsAt,"admin_console_adjust_subscription",rid]);
|
||||
return NextResponse.json({data:{id:rows[0]!.id,requestId:rid}});
|
||||
}catch(error){return adminErrorResponse(error);}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { App, Button, Card, Form, Input, Modal, Select, Space, Table, Tag, Typog
|
||||
import { useState } from "react";
|
||||
|
||||
import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers";
|
||||
import { ReasonActionModal } from "./reason-action-modal";
|
||||
import { ConfirmActionModal } from "./confirm-action-modal";
|
||||
import { formatAdminDate } from "./resource-table";
|
||||
|
||||
const roleOptions = [
|
||||
@@ -70,7 +70,7 @@ export default function AdministratorsResource() {
|
||||
});
|
||||
}
|
||||
|
||||
async function submitRoleAction(reason: string) {
|
||||
async function submitRoleAction() {
|
||||
if (!pendingAction) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
@@ -80,7 +80,6 @@ export default function AdministratorsResource() {
|
||||
userId: pendingAction.userId,
|
||||
email: pendingAction.email,
|
||||
roleCode: pendingAction.roleCode,
|
||||
reason,
|
||||
}),
|
||||
});
|
||||
message.success(pendingAction.action === "assign" ? "管理员角色已分配" : "管理员角色已撤销");
|
||||
@@ -100,7 +99,7 @@ export default function AdministratorsResource() {
|
||||
extra={canManage ? <Button type="primary" icon={<PlusOutlined />} onClick={() => openAssignment()}>按邮箱分配角色</Button> : <Typography.Text type="secondary">只读权限</Typography.Text>}
|
||||
>
|
||||
<Typography.Paragraph type="secondary">
|
||||
六类系统角色可在此分配和撤销。每次变更都需要填写操作原因并写入审计日志;最后一位 Owner 受服务端保护,不能被撤销。
|
||||
六类系统角色可在此分配和撤销。系统会自动记录管理员、动作和请求 ID;最后一位 Owner 受服务端保护,不能被撤销。
|
||||
</Typography.Paragraph>
|
||||
<Form {...searchFormProps} layout="inline" style={{ marginBottom: 16 }}>
|
||||
<Form.Item name="q" label="搜索">
|
||||
@@ -133,7 +132,7 @@ export default function AdministratorsResource() {
|
||||
<Modal
|
||||
title={assignmentUser ? `为 ${assignmentUser.email} 分配角色` : "按邮箱分配管理员角色"}
|
||||
open={assignmentOpen}
|
||||
okText="继续验证"
|
||||
okText="继续"
|
||||
cancelText="取消"
|
||||
onOk={() => assignmentForm.submit()}
|
||||
onCancel={() => setAssignmentOpen(false)}
|
||||
@@ -151,14 +150,14 @@ export default function AdministratorsResource() {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
<ReasonActionModal
|
||||
<ConfirmActionModal
|
||||
open={Boolean(pendingAction)}
|
||||
title={pendingAction?.action === "revoke" ? `撤销角色:${pendingAction.label}` : `分配角色:${pendingAction?.label ?? ""}`}
|
||||
okText={pendingAction?.action === "revoke" ? "确认撤销" : "确认分配"}
|
||||
danger={pendingAction?.action === "revoke"}
|
||||
confirmLoading={saving}
|
||||
onCancel={() => setPendingAction(null)}
|
||||
onSubmit={submitRoleAction}
|
||||
onConfirm={submitRoleAction}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
import { useState } from "react";
|
||||
|
||||
import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers";
|
||||
import { ReasonActionModal } from "./reason-action-modal";
|
||||
import { ConfirmActionModal } from "./confirm-action-modal";
|
||||
import { formatAdminDate, ResourceTable } from "./resource-table";
|
||||
|
||||
const { Text } = Typography;
|
||||
@@ -58,7 +58,6 @@ export function SubscriptionsResource() {
|
||||
item: Subscription,
|
||||
action: "extend" | "revoke",
|
||||
days: number | undefined,
|
||||
reason: string,
|
||||
) {
|
||||
await adminRequestJson("/api/admin/subscriptions", {
|
||||
method: "POST",
|
||||
@@ -67,7 +66,6 @@ export function SubscriptionsResource() {
|
||||
action,
|
||||
days,
|
||||
expectedEndsAt: item.endsAt,
|
||||
reason,
|
||||
}),
|
||||
});
|
||||
await table.tableQuery.refetch();
|
||||
@@ -77,11 +75,11 @@ export function SubscriptionsResource() {
|
||||
setExtendDays(values.days);
|
||||
}
|
||||
|
||||
async function extend(reason: string) {
|
||||
async function extend() {
|
||||
if (!selected || extendDays === null) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await adjust(selected, "extend", extendDays, reason);
|
||||
await adjust(selected, "extend", extendDays);
|
||||
message.success("订阅已延长");
|
||||
setExtendDays(null);
|
||||
setSelected(null);
|
||||
@@ -91,10 +89,10 @@ export function SubscriptionsResource() {
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(item: Subscription, reason: string) {
|
||||
async function revoke(item: Subscription) {
|
||||
setRevokingId(item.id);
|
||||
try {
|
||||
await adjust(item, "revoke", undefined, reason);
|
||||
await adjust(item, "revoke", undefined);
|
||||
message.success("订阅已撤销");
|
||||
setRevokeTarget(null);
|
||||
} finally {
|
||||
@@ -182,7 +180,7 @@ export function SubscriptionsResource() {
|
||||
<Modal
|
||||
title="人工延长订阅"
|
||||
open={Boolean(selected && extendDays === null)}
|
||||
okText="继续验证"
|
||||
okText="继续"
|
||||
cancelText="取消"
|
||||
onOk={() => form.submit()}
|
||||
onCancel={() => setSelected(null)}
|
||||
@@ -203,22 +201,22 @@ export function SubscriptionsResource() {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
<ReasonActionModal
|
||||
<ConfirmActionModal
|
||||
open={Boolean(selected && extendDays !== null)}
|
||||
title="延长订阅权益"
|
||||
okText="确认延长"
|
||||
confirmLoading={saving}
|
||||
onCancel={() => setExtendDays(null)}
|
||||
onSubmit={extend}
|
||||
onConfirm={extend}
|
||||
/>
|
||||
<ReasonActionModal
|
||||
<ConfirmActionModal
|
||||
open={Boolean(revokeTarget)}
|
||||
title="撤销订阅权益"
|
||||
okText="确认撤销"
|
||||
danger
|
||||
confirmLoading={Boolean(revokingId)}
|
||||
onCancel={() => setRevokeTarget(null)}
|
||||
onSubmit={(reason) => revoke(revokeTarget!, reason)}
|
||||
onConfirm={() => revoke(revokeTarget!)}
|
||||
/>
|
||||
</List>
|
||||
);
|
||||
@@ -258,7 +256,7 @@ export function OrdersResource() {
|
||||
identity?.permissions.includes("billing.adjustments.write"),
|
||||
);
|
||||
|
||||
async function adjust(reason: string) {
|
||||
async function adjust() {
|
||||
if (!target) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
@@ -271,7 +269,6 @@ export function OrdersResource() {
|
||||
id: target.order.id,
|
||||
action: target.action,
|
||||
expectedVersion: target.order.adjustmentVersion,
|
||||
reason,
|
||||
}),
|
||||
});
|
||||
await invalidate({ resource: "orders", invalidates: ["list"] });
|
||||
@@ -422,14 +419,14 @@ export function OrdersResource() {
|
||||
"recorded",
|
||||
].map((value) => ({ value, label: value }))}
|
||||
/>
|
||||
<ReasonActionModal
|
||||
<ConfirmActionModal
|
||||
open={Boolean(target)}
|
||||
title={modalTitle}
|
||||
okText="确认执行"
|
||||
danger={target?.action === "record_refund"}
|
||||
confirmLoading={saving}
|
||||
onCancel={() => setTarget(null)}
|
||||
onSubmit={adjust}
|
||||
onConfirm={adjust}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
import dayjs from "dayjs";
|
||||
import { useState } from "react";
|
||||
|
||||
import { ReasonActionModal } from "@/components/admin/reason-action-modal";
|
||||
import { ConfirmActionModal } from "@/components/admin/confirm-action-modal";
|
||||
import {
|
||||
formatAdminDate,
|
||||
ResourceTable,
|
||||
@@ -84,7 +84,7 @@ export default function CodesPage() {
|
||||
identity?.permissions.includes("billing.adjustments.write"),
|
||||
);
|
||||
|
||||
async function submitCreate(reason: string) {
|
||||
async function submitCreate() {
|
||||
if (!pendingCreate) return;
|
||||
const result = await createCodes({
|
||||
resource: "codes",
|
||||
@@ -93,7 +93,6 @@ export default function CodesPage() {
|
||||
count: pendingCreate.count,
|
||||
expiresAt: pendingCreate.expiresAt?.toISOString() ?? null,
|
||||
note: pendingCreate.note?.trim() || null,
|
||||
reason,
|
||||
},
|
||||
successNotification: false,
|
||||
});
|
||||
@@ -103,7 +102,7 @@ export default function CodesPage() {
|
||||
createForm.resetFields();
|
||||
}
|
||||
|
||||
async function submitEdit(reason: string) {
|
||||
async function submitEdit() {
|
||||
if (!editRecord || !pendingEdit) return;
|
||||
await updateCode({
|
||||
resource: "codes",
|
||||
@@ -111,20 +110,18 @@ export default function CodesPage() {
|
||||
values: {
|
||||
note: pendingEdit.note?.trim() || null,
|
||||
expiresAt: pendingEdit.expiresAt?.toISOString() ?? null,
|
||||
reason,
|
||||
},
|
||||
});
|
||||
setPendingEdit(null);
|
||||
setEditRecord(null);
|
||||
}
|
||||
|
||||
async function revoke(record: CodeRecord, reason: string) {
|
||||
async function revoke(record: CodeRecord) {
|
||||
setRevoking(true);
|
||||
try {
|
||||
await adminRequestJson(`/api/admin/codes/${record.id}`, {
|
||||
method: "DELETE",
|
||||
headers: { "x-request-id": crypto.randomUUID() },
|
||||
body: JSON.stringify({ reason }),
|
||||
});
|
||||
await invalidate({ resource: "codes", invalidates: ["list"] });
|
||||
message.success("兑换码已撤销");
|
||||
@@ -316,30 +313,30 @@ export default function CodesPage() {
|
||||
</Button>
|
||||
</Form>
|
||||
</Modal>
|
||||
<ReasonActionModal
|
||||
<ConfirmActionModal
|
||||
open={Boolean(pendingCreate)}
|
||||
title="生成兑换码"
|
||||
okText="确认生成"
|
||||
confirmLoading={createMutation.isPending}
|
||||
onCancel={() => setPendingCreate(null)}
|
||||
onSubmit={submitCreate}
|
||||
onConfirm={submitCreate}
|
||||
/>
|
||||
<ReasonActionModal
|
||||
<ConfirmActionModal
|
||||
open={Boolean(pendingEdit)}
|
||||
title="编辑未兑换码"
|
||||
okText="确认保存"
|
||||
confirmLoading={updateMutation.isPending}
|
||||
onCancel={() => setPendingEdit(null)}
|
||||
onSubmit={submitEdit}
|
||||
onConfirm={submitEdit}
|
||||
/>
|
||||
<ReasonActionModal
|
||||
<ConfirmActionModal
|
||||
open={Boolean(revokeRecord)}
|
||||
title="撤销兑换码"
|
||||
okText="确认撤销"
|
||||
danger
|
||||
confirmLoading={revoking}
|
||||
onCancel={() => setRevokeRecord(null)}
|
||||
onSubmit={(reason) => revoke(revokeRecord!, reason)}
|
||||
onConfirm={() => revoke(revokeRecord!)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, Modal, Typography } from "antd";
|
||||
import { useState } from "react";
|
||||
|
||||
type ConfirmActionModalProps = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
okText: string;
|
||||
danger?: boolean;
|
||||
confirmLoading?: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
export function ConfirmActionModal({
|
||||
open,
|
||||
title,
|
||||
okText,
|
||||
danger = false,
|
||||
confirmLoading = false,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: ConfirmActionModalProps) {
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [actionError, setActionError] = useState<string>();
|
||||
|
||||
async function confirm() {
|
||||
setActionError(undefined);
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await onConfirm();
|
||||
} catch (error) {
|
||||
setActionError(error instanceof Error ? error.message : "操作失败,请稍后再试");
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={title}
|
||||
okText={okText}
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger }}
|
||||
confirmLoading={confirmLoading || actionLoading}
|
||||
onCancel={onCancel}
|
||||
onOk={() => void confirm()}
|
||||
afterOpenChange={() => setActionError(undefined)}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Typography.Paragraph type="secondary">
|
||||
确认后将立即执行该操作,系统会自动记录管理员、动作和请求 ID。
|
||||
</Typography.Paragraph>
|
||||
{actionError ? <Alert type="error" showIcon message={actionError} /> : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { App, Button, Form, Input, InputNumber, Modal, Select, Space, Switch, Ta
|
||||
import { useState } from "react";
|
||||
|
||||
import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers";
|
||||
import { ReasonActionModal } from "./reason-action-modal";
|
||||
import { ConfirmActionModal } from "./confirm-action-modal";
|
||||
import { formatAdminDate } from "./resource-table";
|
||||
|
||||
const { Text } = Typography;
|
||||
@@ -32,7 +32,6 @@ type FlagForm = {
|
||||
enabled: boolean;
|
||||
rolloutPercentage: number;
|
||||
configJson: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export default function FeatureFlagsManagement() {
|
||||
@@ -62,13 +61,11 @@ export default function FeatureFlagsManagement() {
|
||||
enabled: item.enabled,
|
||||
rolloutPercentage: item.rolloutPercentage,
|
||||
configJson: JSON.stringify(item.config, null, 2),
|
||||
reason: "",
|
||||
} : {
|
||||
flagKey: "",
|
||||
enabled: false,
|
||||
rolloutPercentage: 0,
|
||||
configJson: "{}",
|
||||
reason: "",
|
||||
});
|
||||
setOpen(true);
|
||||
}
|
||||
@@ -92,7 +89,6 @@ export default function FeatureFlagsManagement() {
|
||||
rolloutPercentage: values.rolloutPercentage,
|
||||
config,
|
||||
expectedVersion: editing?.version ?? null,
|
||||
reason: values.reason.trim(),
|
||||
}),
|
||||
});
|
||||
message.success("功能开关草稿已保存");
|
||||
@@ -105,12 +101,12 @@ export default function FeatureFlagsManagement() {
|
||||
}
|
||||
}
|
||||
|
||||
async function publish(item: FeatureFlag, reason: string) {
|
||||
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, reason }),
|
||||
body: JSON.stringify({ action: "publish", id: item.id, expectedVersion: item.version }),
|
||||
});
|
||||
message.success("功能开关已发布");
|
||||
await table.tableQuery.refetch();
|
||||
@@ -154,16 +150,15 @@ export default function FeatureFlagsManagement() {
|
||||
<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.Item name="reason" label="修改原因" rules={[{ required: true }, { max: 500 }]}><Input.TextArea rows={2} /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
<ReasonActionModal
|
||||
<ConfirmActionModal
|
||||
open={Boolean(publishTarget)}
|
||||
title={`发布功能开关${publishTarget ? `:${publishTarget.flagKey}` : ""}`}
|
||||
okText="确认发布"
|
||||
confirmLoading={Boolean(publishingId)}
|
||||
onCancel={() => setPublishTarget(null)}
|
||||
onSubmit={(reason) => publish(publishTarget!, reason)}
|
||||
onConfirm={() => publish(publishTarget!)}
|
||||
/>
|
||||
</List>;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
import type { Dayjs } from "dayjs";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { ReasonActionModal } from "@/components/admin/reason-action-modal";
|
||||
import { ConfirmActionModal } from "@/components/admin/confirm-action-modal";
|
||||
import type { AdminIdentity } from "@/lib/admin/providers";
|
||||
|
||||
const { Text } = Typography;
|
||||
@@ -277,13 +277,13 @@ export default function PaymentManagement() {
|
||||
</Space>
|
||||
</Card>
|
||||
</Space>
|
||||
<ReasonActionModal
|
||||
<ConfirmActionModal
|
||||
open={Boolean(pendingEpaySettings)}
|
||||
title="保存易支付设置"
|
||||
okText="确认保存"
|
||||
confirmLoading={epaySaving}
|
||||
onCancel={() => setPendingEpaySettings(null)}
|
||||
onSubmit={saveEpaySettings}
|
||||
onConfirm={saveEpaySettings}
|
||||
/>
|
||||
</List>
|
||||
);
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
import { useState } from "react";
|
||||
|
||||
import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers";
|
||||
import { ReasonActionModal } from "./reason-action-modal";
|
||||
import { ConfirmActionModal } from "./confirm-action-modal";
|
||||
import { formatAdminDate } from "./resource-table";
|
||||
|
||||
const { Text } = Typography;
|
||||
@@ -185,13 +185,13 @@ export default function ProductManagement() {
|
||||
});
|
||||
}
|
||||
|
||||
async function save(reason: string) {
|
||||
async function save() {
|
||||
if (!pendingSave) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await adminRequestJson("/api/admin/products", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...pendingSave, reason }),
|
||||
body: JSON.stringify(pendingSave),
|
||||
});
|
||||
message.success("商品草稿已保存");
|
||||
setPendingSave(null);
|
||||
@@ -203,12 +203,12 @@ export default function ProductManagement() {
|
||||
}
|
||||
}
|
||||
|
||||
async function publish(product: Product, reason: string) {
|
||||
async function publish(product: Product) {
|
||||
setPublishingId(product.id);
|
||||
try {
|
||||
await adminRequestJson("/api/admin/products", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ action: "publish", id: product.id, reason }),
|
||||
body: JSON.stringify({ action: "publish", id: product.id }),
|
||||
});
|
||||
message.success("商品已发布");
|
||||
await table.tableQuery.refetch();
|
||||
@@ -277,21 +277,21 @@ export default function ProductManagement() {
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
<ReasonActionModal
|
||||
<ConfirmActionModal
|
||||
open={Boolean(pendingSave)}
|
||||
title="保存商品草稿"
|
||||
okText="确认保存"
|
||||
confirmLoading={saving}
|
||||
onCancel={() => setPendingSave(null)}
|
||||
onSubmit={save}
|
||||
onConfirm={save}
|
||||
/>
|
||||
<ReasonActionModal
|
||||
<ConfirmActionModal
|
||||
open={Boolean(publishTarget)}
|
||||
title={`发布商品${publishTarget ? `:${publishTarget.name}` : ""}`}
|
||||
okText="确认发布"
|
||||
confirmLoading={Boolean(publishingId)}
|
||||
onCancel={() => setPublishTarget(null)}
|
||||
onSubmit={(reason) => publish(publishTarget!, reason)}
|
||||
onConfirm={() => publish(publishTarget!)}
|
||||
/>
|
||||
</List>;
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, Form, Input, Modal } from "antd";
|
||||
import { useState } from "react";
|
||||
|
||||
type ReasonActionModalProps = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
okText: string;
|
||||
danger?: boolean;
|
||||
confirmLoading?: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmit: (reason: string) => Promise<void> | void;
|
||||
};
|
||||
|
||||
type ReasonFormValues = {
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export function ReasonActionModal({
|
||||
open,
|
||||
title,
|
||||
okText,
|
||||
danger = false,
|
||||
confirmLoading = false,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: ReasonActionModalProps) {
|
||||
const [form] = Form.useForm<ReasonFormValues>();
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [actionError, setActionError] = useState<string>();
|
||||
|
||||
async function submit(values: ReasonFormValues) {
|
||||
setActionError(undefined);
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await onSubmit(values.reason.trim());
|
||||
} catch (error) {
|
||||
setActionError(error instanceof Error ? error.message : "操作失败,请稍后再试");
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={title}
|
||||
okText={okText}
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger }}
|
||||
confirmLoading={confirmLoading || actionLoading}
|
||||
onCancel={onCancel}
|
||||
onOk={() => form.submit()}
|
||||
afterOpenChange={(visible) => {
|
||||
if (visible) form.resetFields();
|
||||
setActionError(undefined);
|
||||
}}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical" preserve={false} onFinish={submit}>
|
||||
<Form.Item
|
||||
label="操作原因"
|
||||
name="reason"
|
||||
rules={[
|
||||
{ required: true, whitespace: true, message: "请输入操作原因" },
|
||||
{ max: 500, message: "操作原因最多 500 字" },
|
||||
]}
|
||||
>
|
||||
<Input.TextArea rows={3} maxLength={500} showCount autoFocus />
|
||||
</Form.Item>
|
||||
{actionError ? <Alert type="error" showIcon message={actionError} /> : null}
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useGetIdentity } from "@refinedev/core";
|
||||
import { App, Button, Space, Typography, type TableColumnsType } from "antd";
|
||||
import { useState } from "react";
|
||||
|
||||
import { ReasonActionModal } from "./reason-action-modal";
|
||||
import { ConfirmActionModal } from "./confirm-action-modal";
|
||||
|
||||
import { formatAdminDate, ResourceTable } from "@/components/admin/resource-table";
|
||||
import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers";
|
||||
@@ -58,7 +58,7 @@ export default function UsersPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function resetAccount(reason: string) {
|
||||
async function resetAccount() {
|
||||
if (!resetTarget) return;
|
||||
setResetting(true);
|
||||
try {
|
||||
@@ -69,7 +69,6 @@ export default function UsersPage() {
|
||||
body: JSON.stringify({
|
||||
userId: resetTarget.id,
|
||||
confirmation: "RESET",
|
||||
reason,
|
||||
}),
|
||||
},
|
||||
);
|
||||
@@ -127,14 +126,14 @@ export default function UsersPage() {
|
||||
|
||||
return <>
|
||||
<ResourceTable<UserRecord> resource="customers" title="用户资料(列表始终脱敏)" columns={columns} />
|
||||
<ReasonActionModal
|
||||
<ConfirmActionModal
|
||||
open={Boolean(resetTarget)}
|
||||
title={`确认重置 ${resetTarget?.email ?? "该账号"} 的资料与会话?登录身份、管理员角色、积分及账务审计记录会保留。`}
|
||||
okText="确认重置"
|
||||
danger
|
||||
confirmLoading={resetting}
|
||||
onCancel={() => setResetTarget(null)}
|
||||
onSubmit={resetAccount}
|
||||
onConfirm={resetAccount}
|
||||
/>
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ export async function runCodeRpc<T extends keyof CodeRpcArgs>(
|
||||
`select * from public.admin_create_redemption_codes(
|
||||
$1::uuid,$2::text,$3::text,$4::text,$5::jsonb,$6::text
|
||||
)`,
|
||||
[...common, input.p_codes, input.p_reason],
|
||||
[...common, JSON.stringify(input.p_codes), input.p_reason],
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user