fix: restore mobile report scrolling and published product edits
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled

Report pages now scroll inside the chat shell lock, and admin product save forks a draft or retires a published plan instead of rejecting with a generic constraint error.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-18 17:28:22 +08:00
parent 3c9b0bb32b
commit f4a2ba86ae
13 changed files with 398 additions and 8 deletions
+9 -1
View File
@@ -37,7 +37,11 @@ const publishSchema = z.object({
action: z.literal("publish"),
id: z.string().uuid(),
}).strict();
const mutationSchema = z.discriminatedUnion("action", [saveSchema, publishSchema]);
const deleteSchema = z.object({
action: z.literal("delete"),
id: z.string().uuid(),
}).strict();
const mutationSchema = z.discriminatedUnion("action", [saveSchema, publishSchema, deleteSchema]);
type ProductRow = {
id: string; code: string; version: number; name: string; description: string;
@@ -92,6 +96,10 @@ export async function POST(request: Request) {
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 } });
}
if (body.data.action === "delete") {
const rows = await queryAdminRows<{ id: string }>("select public.admin_delete_product($1,$2,$3,$4) id", [session.user.id, body.data.id, "admin_console_delete_product", rid]);
return NextResponse.json({ data: { id: rows[0]!.id, requestId: rid } });
}
const value = body.data;
const rows = await queryAdminRows<{ id: string }>(`
select public.admin_save_product_draft($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14::jsonb,$15,$16) id
+4 -2
View File
@@ -1756,7 +1756,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.payment-qr-badge svg { display: block; width: 100%; height: 100%; }
/* Global report centre — quiet archive, independent from chat sessions. */
.report-center-shell { width: 100%; min-height: 100dvh; overflow-x: hidden; padding: 0 clamp(20px, 5vw, 72px) 72px; background: var(--color-canvas-soft); color: var(--color-ink); }
.report-center-shell { width: 100%; height: 100%; min-height: 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; padding: 0 clamp(20px, 5vw, 72px) 72px; background: var(--color-canvas-soft); color: var(--color-ink); }
.report-center-topbar { max-width: 1080px; min-height: 64px; display: flex; align-items: center; justify-content: space-between; gap: 20px; margin: 0 auto; border-bottom: 1px solid var(--color-border); color: var(--color-ink-tertiary); font-size: 12px; }
.report-center-back { min-height: 44px; display: inline-flex; align-items: center; gap: 7px; color: var(--color-ink-secondary); font-size: 14px; font-weight: 500; text-decoration: none; }
.report-center-back:hover { color: var(--color-action); }
@@ -1808,11 +1808,13 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
--report-paper: #f8f5ee;
--report-rule: #c9c2b7;
width: 100%;
height: 100dvh;
height: 100%;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
touch-action: pan-y;
scrollbar-gutter: stable;
background: var(--report-paper);
}
@@ -119,6 +119,7 @@ export default function ProductManagement() {
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [publishingId, setPublishingId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const canWrite = Boolean(identity?.permissions.includes("billing.products.write"));
const canPublish = Boolean(identity?.permissions.includes("billing.products.publish"));
@@ -184,7 +185,7 @@ export default function ProductManagement() {
entitlements,
}),
});
message.success("商品草稿已保存");
message.success(editing && editing.status !== "draft" ? "已保存为新版本草稿,发布后才会替换在售套餐" : "商品草稿已保存");
setOpen(false);
form.resetFields();
await table.tableQuery.refetch();
@@ -211,6 +212,22 @@ export default function ProductManagement() {
}
}
async function remove(product: Product) {
setDeletingId(product.id);
try {
await adminRequestJson("/api/admin/products", {
method: "POST",
body: JSON.stringify({ action: "delete", id: product.id }),
});
message.success(product.status === "draft" ? "草稿已删除" : "商品已下架");
await table.tableQuery.refetch();
} catch (error) {
message.error(error instanceof Error ? error.message : product.status === "draft" ? "删除草稿失败" : "下架商品失败");
} finally {
setDeletingId(null);
}
}
const columns: TableColumnsType<Product> = [
{
title: "商品",
@@ -227,8 +244,10 @@ export default function ProductManagement() {
title: "操作",
fixed: "right",
render: (_, item) => <Space>
{canWrite && <Button type="link" onClick={() => openProduct(item)}>稿</Button>}
{canPublish && item.status !== "published" && <Button type="link" loading={publishingId === item.id} onClick={() => void publish(item)}></Button>}
{canWrite && <Button type="link" onClick={() => openProduct(item)}>{item.status === "draft" ? "编辑草稿" : "修改"}</Button>}
{canPublish && item.status === "draft" && <Button type="link" loading={publishingId === item.id} onClick={() => void publish(item)}></Button>}
{canWrite && item.status === "draft" && <Button type="link" loading={deletingId === item.id} onClick={() => void remove(item)}></Button>}
{canWrite && item.status === "published" && <Button type="link" loading={deletingId === item.id} onClick={() => void remove(item)}></Button>}
</Space>,
},
];
@@ -249,6 +268,7 @@ export default function ProductManagement() {
<Table {...table.tableProps} columns={columns} rowKey="id" scroll={{ x: "max-content" }} />
</Space>
<Modal title={editing ? `编辑 ${editing.name}` : "新建商品"} open={open} width={860} confirmLoading={saving} okText="保存草稿" cancelText="取消" onOk={() => form.submit()} onCancel={() => setOpen(false)} destroyOnHidden>
{editing && editing.status !== "draft" ? <Text type="secondary">稿</Text> : null}
<Form<ProductForm> form={form} layout="vertical" onFinish={save} requiredMark="optional">
<Row gutter={16}>
<Col xs={24} md={8}><Form.Item name="code" label="商品代码" rules={[{ required: true }, { pattern: /^[a-z][a-z0-9_]{1,79}$/ }]}><Input disabled={Boolean(editing)} /></Form.Item></Col>
@@ -31,9 +31,21 @@ export function adminErrorResponse(error: unknown) {
if (message.includes("模型供应商密钥未配置") || message.includes("model_provider_key_required")) {
return NextResponse.json({ error: "模型供应商密钥未配置" }, { status: 409 });
}
if (message.includes("product_not_found")) {
return NextResponse.json({ error: "商品不存在" }, { status: 404 });
}
if (message.includes("product_code_immutable")) {
return NextResponse.json({ error: "商品代码不可修改" }, { status: 400 });
}
if (message.includes("product_draft_not_found")) {
return NextResponse.json({ error: "只能编辑草稿。已发布商品请下架,或保存为新版本草稿后再发布" }, { status: 400 });
}
if (isPostgresError(error)) {
if (error.code === "42501") return NextResponse.json({ error: "无权执行此操作" }, { status: 403 });
if (error.code === "40001") return NextResponse.json({ error: "资源已被其他管理员修改,请刷新后重试" }, { status: 409 });
if (error.code === "23503") {
return NextResponse.json({ error: "已有订单或订阅引用该商品,不能硬删除,请改为下架" }, { status: 409 });
}
if (error.code === "22023" || error.code === "23514" || error.code === "23505") {
return NextResponse.json({ error: "提交内容不符合业务约束" }, { status: 400 });
}