fix(admin): remove manual reasons and repair code creation
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled

This commit is contained in:
Jesse_Chen
2026-08-16 16:49:11 +08:00
parent 9df43e8112
commit c712787ec2
24 changed files with 180 additions and 206 deletions
@@ -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) {
+3 -10
View File
@@ -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) {
+1 -2
View File
@@ -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)}}
+1 -2
View File
@@ -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),
],
);
+2 -4
View File
@@ -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}
/>
</>;
}
+1 -1
View File
@@ -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;
}