fix: prevent duplicate support ticket submissions

This commit is contained in:
JetSprow
2026-04-29 16:38:31 +10:00
parent 2a3c9959bd
commit 16573c67c3
24 changed files with 327 additions and 120 deletions

View File

@@ -19,6 +19,7 @@ const settingsSchema = z.object({
supportContact: z.string().trim().optional(),
maintenanceNotice: z.string().trim().optional(),
siteNotice: z.string().trim().optional(),
supportOpenTicketLimit: z.coerce.number().int().min(1).max(20).optional(),
allowRegistration: z.string().optional(),
emailVerificationRequired: z.string().optional(),
requireInviteCode: z.string().optional(),
@@ -98,6 +99,7 @@ function buildSettingsUpdate(parsed: z.infer<typeof settingsSchema>, current: Aw
siteUrl: normalizeSiteUrl(parsed.siteUrl) || null,
subscriptionUrl: normalizeSiteUrl(parsed.subscriptionUrl) || null,
supportContact: parsed.supportContact || null,
supportOpenTicketLimit: parsed.supportOpenTicketLimit ?? current.supportOpenTicketLimit,
maintenanceNotice: parsed.maintenanceNotice || null,
siteNotice: parsed.siteNotice || null,
allowRegistration: parsed.allowRegistration === "true",
@@ -174,6 +176,8 @@ function revalidateSettingsViews() {
revalidatePath("/subscriptions");
revalidatePath("/admin/nodes");
revalidatePath("/account");
revalidatePath("/support");
revalidatePath("/admin/support");
revalidatePath("/admin/commerce");
revalidatePath("/admin/subscription-risk");
revalidatePath("/admin/subscriptions");

View File

@@ -1,10 +1,12 @@
"use server";
import { Prisma } from "@prisma/client";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/require-auth";
import { actorFromSession, recordAuditLog } from "@/services/audit";
import { getAppConfig } from "@/services/app-config";
import { createNotification } from "@/services/notifications";
import {
createSupportAttachments,
@@ -42,30 +44,50 @@ export async function createSupportTicket(formData: FormData) {
const body = riskEvent
? data.body + "\n\n关联订阅风控事件" + riskEvent.id + "\n系统判定" + riskEvent.message
: data.body;
const config = await getAppConfig();
const supportOpenTicketLimit = Math.max(1, config.supportOpenTicketLimit);
const ticket = await prisma.supportTicket.create({
data: {
userId: session.user.id,
subject: data.subject,
category: data.category || (riskEvent ? "订阅风控" : null),
priority: riskEvent ? "HIGH" : data.priority,
status: "OPEN",
lastReplyAt: new Date(),
replies: {
create: {
authorUserId: session.user.id,
isAdmin: false,
body,
const ticket = await prisma.$transaction(
async (tx) => {
const openTicketCount = await tx.supportTicket.count({
where: {
userId: session.user.id,
status: { not: "CLOSED" },
},
},
});
if (openTicketCount >= supportOpenTicketLimit) {
throw new Error(
`你当前已有 ${openTicketCount} 个未关闭工单,最多只能同时保留 ${supportOpenTicketLimit} 个。请先关闭已处理工单或等待客服处理。`,
);
}
return tx.supportTicket.create({
data: {
userId: session.user.id,
subject: data.subject,
category: data.category || (riskEvent ? "订阅风控" : null),
priority: riskEvent ? "HIGH" : data.priority,
status: "OPEN",
lastReplyAt: new Date(),
replies: {
create: {
authorUserId: session.user.id,
isAdmin: false,
body,
},
},
},
include: {
replies: {
orderBy: { createdAt: "desc" },
take: 1,
},
},
});
},
include: {
replies: {
orderBy: { createdAt: "desc" },
take: 1,
},
},
});
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
);
const firstReply = ticket.replies[0];
if (firstReply && attachments.length > 0) {
await createSupportAttachments({

View File

@@ -10,6 +10,7 @@ import {
createAnnouncement,
updateAnnouncement,
} from "@/actions/admin/announcements";
import { PendingSubmitButton } from "@/components/shared/pending-submit-button";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -210,9 +211,9 @@ export function AnnouncementForm({
</select>
</div>
<Button type="submit" className="w-full">
<PendingSubmitButton className="w-full" pendingLabel="保存中...">
</Button>
</PendingSubmitButton>
</form>
</DialogContent>
</Dialog>
@@ -343,9 +344,9 @@ export function CreateAnnouncementButton({
</select>
</div>
<Button type="submit" className="w-full">
<PendingSubmitButton className="w-full" pendingLabel="发布中...">
</Button>
</PendingSubmitButton>
</form>
</DialogContent>
</Dialog>

View File

@@ -4,7 +4,7 @@ import { createCoupon, createPromotionRule } from "@/actions/admin/commerce";
import { DetailItem, DetailList } from "@/components/admin/detail-list";
import { ActiveStatusBadge, StatusBadge } from "@/components/admin/status-badge";
import { PageHeader, PageShell, SectionHeader } from "@/components/shared/page-shell";
import { Button } from "@/components/ui/button";
import { PendingSubmitButton } from "@/components/shared/pending-submit-button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -73,7 +73,7 @@ export default async function AdminCommercePage() {
<option value="false"></option>
</select>
</div>
<Button type="submit" className="w-full"></Button>
<PendingSubmitButton className="w-full" pendingLabel="创建中..."></PendingSubmitButton>
</form>
<form action={createPromotionRule} className="form-panel space-y-4">
@@ -96,7 +96,7 @@ export default async function AdminCommercePage() {
<Label htmlFor="promotion-sort"></Label>
<Input id="promotion-sort" name="sortOrder" type="number" defaultValue={100} />
</div>
<Button type="submit" className="w-full"></Button>
<PendingSubmitButton className="w-full" pendingLabel="创建中..."></PendingSubmitButton>
</form>
</section>
</TabsContent>

View File

@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { PendingSubmitButton } from "@/components/shared/pending-submit-button";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -98,9 +99,9 @@ export function NodeForm({
<p className="text-xs leading-5 text-muted-foreground">
线使 Token 3x-ui API
</p>
<Button type="submit" size="lg" className="w-full">
<PendingSubmitButton size="lg" className="w-full" pendingLabel={isEdit ? "保存中..." : "创建中..."}>
{isEdit ? "保存并同步入站" : "创建并同步入站"}
</Button>
</PendingSubmitButton>
</form>
</DialogContent>
</Dialog>

View File

@@ -2,6 +2,7 @@
import { useState } from "react";
import type { StreamingService } from "@prisma/client";
import { PendingSubmitButton } from "@/components/shared/pending-submit-button";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -80,9 +81,9 @@ export function ServiceForm({
<Label></Label>
<Input name="description" defaultValue={service?.description ?? ""} />
</div>
<Button type="submit" size="lg" className="w-full">
<PendingSubmitButton size="lg" className="w-full" pendingLabel={isEdit ? "保存中..." : "创建中..."}>
{isEdit ? "保存" : "创建"}
</Button>
</PendingSubmitButton>
</form>
</DialogContent>
</Dialog>

View File

@@ -27,6 +27,7 @@ export default async function AdminSettingsPage() {
siteUrl: config.siteUrl,
subscriptionUrl: config.subscriptionUrl,
supportContact: config.supportContact,
supportOpenTicketLimit: config.supportOpenTicketLimit,
maintenanceNotice: config.maintenanceNotice,
siteNotice: config.siteNotice,
allowRegistration: config.allowRegistration,

View File

@@ -2,7 +2,7 @@
import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
import { Bell, Clock3, Gift, Mail, Send, Settings2, ShieldAlert, ShieldCheck } from "lucide-react";
import { Bell, Clock3, Gift, LifeBuoy, Mail, Send, Settings2, ShieldAlert, ShieldCheck } from "lucide-react";
import { Button, buttonVariants } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -16,6 +16,7 @@ interface AppConfig {
siteUrl: string | null;
subscriptionUrl: string | null;
supportContact: string | null;
supportOpenTicketLimit: number;
maintenanceNotice: string | null;
siteNotice: string | null;
allowRegistration: boolean;
@@ -65,6 +66,7 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (saving) return;
const form = event.currentTarget;
setSaving(true);
@@ -85,6 +87,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
}
async function handleTestEmail() {
if (testingEmail) return;
const form = document.getElementById("app-settings-form") as HTMLFormElement | null;
if (!form) return;
@@ -158,6 +162,29 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</div>
</section>
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3">
<div className="flex items-center gap-2 text-sm font-semibold">
<LifeBuoy className="size-4 text-primary" />
</div>
<div className="grid gap-5 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="supportOpenTicketLimit"></Label>
<Input
id="supportOpenTicketLimit"
name="supportOpenTicketLimit"
type="number"
min={1}
max={20}
step={1}
defaultValue={config.supportOpenTicketLimit}
/>
<p className="text-xs leading-5 text-muted-foreground">
2
</p>
</div>
</div>
</section>
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3">
<div className="flex items-center gap-2 text-sm font-semibold">
<Clock3 className="size-4 text-primary" />

View File

@@ -1,6 +1,6 @@
import { Paperclip, Send } from "lucide-react";
import { replySupportAsAdmin } from "@/actions/admin/support";
import { Button } from "@/components/ui/button";
import { PendingSubmitButton } from "@/components/shared/pending-submit-button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
@@ -42,7 +42,7 @@ export function AdminSupportReplyForm({ ticketId }: { ticketId: string }) {
JPGPNGWEBPGIFAVIF 3 3MB
</p>
</div>
<Button type="submit" size="lg" className="w-full sm:w-auto"></Button>
<PendingSubmitButton size="lg" className="w-full sm:w-auto" pendingLabel="发送中..."></PendingSubmitButton>
</form>
);
}

View File

@@ -1,4 +1,5 @@
import Link from "next/link";
import { Eye } from "lucide-react";
import { DataTableShell } from "@/components/admin/data-table-shell";
import {
DataTable,
@@ -14,6 +15,7 @@ import {
SupportTicketStatusBadge,
} from "@/components/support/ticket-badges";
import { AdminSupportTicketActions } from "@/components/support/admin-ticket-actions";
import { buttonVariants } from "@/components/ui/button";
import { formatDate } from "@/lib/utils";
import type { AdminSupportTicketRow } from "../support-data";
@@ -63,7 +65,14 @@ export function AdminSupportTable({ tickets }: AdminSupportTableProps) {
<time dateTime={ticket.updatedAt.toISOString()}>{formatDate(ticket.updatedAt)}</time>
</DataTableCell>
<DataTableCell>
<div className="flex justify-end">
<div className="flex flex-wrap justify-end gap-2">
<Link
href={`/admin/support/${ticket.id}`}
className={buttonVariants({ variant: "outline", size: "sm" })}
>
<Eye className="size-3.5" />
</Link>
<AdminSupportTicketActions ticketId={ticket.id} />
</div>
</DataTableCell>

View File

@@ -1,5 +1,5 @@
import { updateSupportTicketMeta } from "@/actions/admin/support";
import { Button } from "@/components/ui/button";
import { PendingSubmitButton } from "@/components/shared/pending-submit-button";
import { Label } from "@/components/ui/label";
import type { AdminSupportTicketDetail } from "../support-data";
@@ -38,7 +38,7 @@ export function SupportTicketMetaForm({ ticket }: { ticket: AdminSupportTicketDe
<option value="URGENT"></option>
</select>
</div>
<Button type="submit" variant="outline" size="lg"></Button>
<PendingSubmitButton variant="outline" size="lg" pendingLabel="更新中..."></PendingSubmitButton>
</form>
);
}

View File

@@ -1,6 +1,6 @@
import { BellRing } from "lucide-react";
import { runReminderTask } from "@/actions/admin/tasks";
import { Button } from "@/components/ui/button";
import { PendingSubmitButton } from "@/components/shared/pending-submit-button";
export function TaskLaunchPanel() {
return (
@@ -13,7 +13,7 @@ export function TaskLaunchPanel() {
<p className="font-semibold"></p>
<p className="mt-1 text-xs leading-5 text-muted-foreground"></p>
</div>
<Button type="submit" size="sm" variant="outline" className="mt-auto w-full"></Button>
<PendingSubmitButton size="sm" variant="outline" className="mt-auto w-full" pendingLabel="派发中..."></PendingSubmitButton>
</form>
</div>
);

View File

@@ -11,7 +11,7 @@ import {
DataTableRow,
} from "@/components/shared/data-table";
import { TaskStatusBadge, taskKindLabels } from "@/components/shared/domain-badges";
import { Button } from "@/components/ui/button";
import { PendingSubmitButton } from "@/components/shared/pending-submit-button";
import { formatDate } from "@/lib/utils";
import type { AdminTaskRunRow } from "../tasks-data";
@@ -85,7 +85,7 @@ export function TaskRunsTable({ tasks }: TaskRunsTableProps) {
await retryTaskRun(task.id);
}}
>
<Button type="submit" size="sm" variant="outline"></Button>
<PendingSubmitButton size="sm" variant="outline" pendingLabel="重试中..."></PendingSubmitButton>
</form>
)}
</div>

View File

@@ -2,6 +2,7 @@
import { useState } from "react";
import type { User } from "@prisma/client";
import { PendingSubmitButton } from "@/components/shared/pending-submit-button";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -82,9 +83,9 @@ export function UserForm({
<option value="ADMIN"></option>
</select>
</div>
<Button type="submit" className="w-full">
<PendingSubmitButton className="w-full" pendingLabel={isEdit ? "保存中..." : "创建中..."}>
{isEdit ? "保存" : "创建"}
</Button>
</PendingSubmitButton>
</form>
</DialogContent>
</Dialog>

View File

@@ -1,12 +1,15 @@
"use client";
import { useState } from "react";
import { useRef, useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
import { Plus, X } from "lucide-react";
import { toast } from "sonner";
import { createSupportTicket } from "@/actions/user/support";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { getErrorMessage } from "@/lib/errors";
const ATTACHMENT_ACCEPT = "image/jpeg,image/png,image/webp,image/gif,image/avif";
@@ -20,16 +23,66 @@ type SupportTicketPreset = {
export function CreateSupportTicketForm({
defaultOpen = false,
openTicketCount = 0,
openTicketLimit = 2,
preset,
}: {
defaultOpen?: boolean;
openTicketCount?: number;
openTicketLimit?: number;
preset?: SupportTicketPreset;
}) {
const router = useRouter();
const [open, setOpen] = useState(defaultOpen);
const [submitting, setSubmitting] = useState(false);
const submittingRef = useRef(false);
const effectiveOpenTicketLimit = Math.max(1, openTicketLimit);
const limitReached = openTicketCount >= effectiveOpenTicketLimit;
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (submittingRef.current || limitReached) return;
const form = event.currentTarget;
const formData = new FormData(form);
submittingRef.current = true;
setSubmitting(true);
try {
await createSupportTicket(formData);
toast.success("工单已提交");
form.reset();
setOpen(false);
router.refresh();
} catch (error) {
toast.error(getErrorMessage(error, "提交工单失败"));
} finally {
submittingRef.current = false;
setSubmitting(false);
}
}
if (limitReached) {
return (
<div id="new-ticket" className="surface-card space-y-3 rounded-[2rem] p-5 sm:p-6">
<div className="flex items-center gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-amber-500/20 bg-amber-500/10 text-amber-700 dark:text-amber-300">
<Plus className="size-4" />
</span>
<div>
<h3 className="text-lg font-semibold"></h3>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
{openTicketCount}/{effectiveOpenTicketLimit}
</p>
</div>
</div>
</div>
);
}
if (!open) {
return (
<Button onClick={() => setOpen(true)} size="lg">
<Button id="new-ticket" onClick={() => setOpen(true)} size="lg">
<Plus className="size-4" />
</Button>
@@ -40,60 +93,69 @@ export function CreateSupportTicketForm({
<form
id="new-ticket"
action={createSupportTicket}
onSubmit={(event) => void handleSubmit(event)}
aria-busy={submitting}
className="surface-card space-y-5 rounded-[2rem] p-5 sm:p-6"
>
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-3">
<h3 className="text-lg font-semibold">{preset?.riskEventId ? "订阅风控复核工单" : "新建工单"}</h3>
{!preset?.riskEventId && (
<button
type="button"
onClick={() => setOpen(false)}
className="flex size-9 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<X className="size-4" />
</button>
<button
type="button"
aria-label="收起新建工单表单"
disabled={submitting}
onClick={() => setOpen(false)}
className="flex size-9 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
>
<X className="size-4" />
</button>
)}
</div>
<div className="grid gap-5 md:grid-cols-3">
<div className="space-y-2 md:col-span-2">
<Label htmlFor="subject"></Label>
<Input id="subject" name="subject" placeholder="一句话描述遇到的问题" defaultValue={preset?.subject} required />
<fieldset disabled={submitting} className="space-y-5 disabled:opacity-70">
<div className="grid gap-5 md:grid-cols-3">
<div className="space-y-2 md:col-span-2">
<Label htmlFor="subject"></Label>
<Input id="subject" name="subject" placeholder="一句话描述遇到的问题" defaultValue={preset?.subject} required />
</div>
<div className="space-y-2">
<Label htmlFor="priority"></Label>
<select
id="priority"
name="priority"
defaultValue={preset?.priority ?? "NORMAL"}
className="h-11 w-full px-3 text-sm outline-none disabled:cursor-not-allowed"
>
<option value="LOW"></option>
<option value="NORMAL"></option>
<option value="HIGH"></option>
<option value="URGENT"></option>
</select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="priority"></Label>
<select
id="priority"
name="priority"
defaultValue={preset?.priority ?? "NORMAL"}
className="h-11 w-full px-3 text-sm outline-none"
>
<option value="LOW"></option>
<option value="NORMAL"></option>
<option value="HIGH"></option>
<option value="URGENT"></option>
</select>
<Label htmlFor="category"></Label>
<Input id="category" name="category" placeholder="例如:支付 / 节点 / 流媒体 / 账户" defaultValue={preset?.category} />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="category"></Label>
<Input id="category" name="category" placeholder="例如:支付 / 节点 / 流媒体 / 账户" defaultValue={preset?.category} />
</div>
<div className="space-y-2">
<Label htmlFor="body"></Label>
<Textarea id="body" name="body" rows={5} placeholder="补充问题背景、错误提示或你已经尝试过的步骤" defaultValue={preset?.body} required />
</div>
<div className="space-y-2">
<Label htmlFor="attachments"> 3 3MB</Label>
<Input
id="attachments"
name="attachments"
type="file"
multiple
accept={ATTACHMENT_ACCEPT}
/>
</div>
{preset?.riskEventId && <input type="hidden" name="riskEventId" value={preset.riskEventId} />}
<Button type="submit" size="lg"></Button>
<div className="space-y-2">
<Label htmlFor="body"></Label>
<Textarea id="body" name="body" rows={5} placeholder="补充问题背景、错误提示或你已经尝试过的步骤" defaultValue={preset?.body} required />
</div>
<div className="space-y-2">
<Label htmlFor="attachments"> 3 3MB</Label>
<Input
id="attachments"
name="attachments"
type="file"
multiple
accept={ATTACHMENT_ACCEPT}
/>
</div>
{preset?.riskEventId && <input type="hidden" name="riskEventId" value={preset.riskEventId} />}
<Button type="submit" size="lg" disabled={submitting}>
{submitting ? "提交中..." : "提交工单"}
</Button>
</fieldset>
</form>
);
}

View File

@@ -1,6 +1,6 @@
import { Paperclip, Send } from "lucide-react";
import { replySupportTicket } from "@/actions/user/support";
import { Button } from "@/components/ui/button";
import { PendingSubmitButton } from "@/components/shared/pending-submit-button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
@@ -46,7 +46,7 @@ export function SupportTicketReplyForm({ ticketId }: SupportTicketReplyFormProps
JPGPNGWEBPGIFAVIF 3 3MB
</p>
</div>
<Button type="submit" size="lg" className="w-full sm:w-auto"></Button>
<PendingSubmitButton size="lg" className="w-full sm:w-auto" pendingLabel="发送中..."></PendingSubmitButton>
</form>
);
}

View File

@@ -1,6 +1,6 @@
import type { SupportTicket } from "@prisma/client";
import Link from "next/link";
import { LifeBuoy } from "lucide-react";
import { Eye, LifeBuoy } from "lucide-react";
import { DataTableShell } from "@/components/shared/data-table-shell";
import {
DataTable,
@@ -67,7 +67,14 @@ export function UserSupportTicketTable({ tickets }: UserSupportTicketTableProps)
<time dateTime={ticket.updatedAt.toISOString()}>{formatDate(ticket.updatedAt)}</time>
</DataTableCell>
<DataTableCell>
<div className="flex justify-end">
<div className="flex flex-wrap justify-end gap-2">
<Link
href={`/support/${ticket.id}`}
className={buttonVariants({ variant: "outline", size: "sm" })}
>
<Eye className="size-3.5" />
</Link>
<UserSupportTicketActions ticketId={ticket.id} status={ticket.status} />
</div>
</DataTableCell>

View File

@@ -3,10 +3,11 @@ import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { PageHeader, PageShell } from "@/components/shared/page-shell";
import { prisma } from "@/lib/prisma";
import { getAppConfig } from "@/services/app-config";
import { reasonLabel } from "@/services/subscription-risk-review";
import { CreateSupportTicketForm } from "./_components/create-support-ticket-form";
import { UserSupportTicketTable } from "./_components/user-support-ticket-table";
import { getUserSupportTickets } from "./support-data";
import { getUserOpenSupportTicketCount, getUserSupportTickets } from "./support-data";
export const metadata: Metadata = {
title: "工单售后",
@@ -21,8 +22,10 @@ export default async function SupportPage({
const session = await getServerSession(authOptions);
const resolvedSearchParams = await searchParams;
const riskEventId = typeof resolvedSearchParams.riskEventId === "string" ? resolvedSearchParams.riskEventId : "";
const [tickets, riskEvent] = await Promise.all([
const [tickets, openTicketCount, config, riskEvent] = await Promise.all([
getUserSupportTickets(session!.user.id),
getUserOpenSupportTicketCount(session!.user.id),
getAppConfig(),
riskEventId
? prisma.subscriptionRiskEvent.findFirst({
where: {
@@ -56,7 +59,12 @@ export default async function SupportPage({
title="需要帮助?"
/>
<CreateSupportTicketForm defaultOpen={Boolean(preset)} preset={preset} />
<CreateSupportTicketForm
defaultOpen={Boolean(preset)}
openTicketCount={openTicketCount}
openTicketLimit={config.supportOpenTicketLimit}
preset={preset}
/>
<UserSupportTicketTable tickets={tickets} />
</PageShell>
);

View File

@@ -34,6 +34,15 @@ export async function getUserSupportTickets(userId: string) {
});
}
export async function getUserOpenSupportTicketCount(userId: string) {
return prisma.supportTicket.count({
where: {
userId,
status: { not: "CLOSED" },
},
});
}
export async function getUserSupportTicketDetail({
ticketId,
userId,

View File

@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
import { BatchActionButtonClient } from "@/components/admin/batch-action-button-client";
import { cn } from "@/lib/utils";
interface BatchActionBarProps {
@@ -36,25 +37,6 @@ export function BatchActionBar({
);
}
export function BatchActionButton({
value,
children,
name = "action",
destructive,
className,
}: BatchActionButtonProps) {
return (
<button
type="submit"
name={value == null ? undefined : name}
value={value}
className={cn(
"btn-base rounded-xl border px-3 py-2 text-sm font-semibold focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/20",
destructive ? "btn-danger-3d" : "btn-cream",
className,
)}
>
{children}
</button>
);
export function BatchActionButton(props: BatchActionButtonProps) {
return <BatchActionButtonClient {...props} />;
}

View File

@@ -0,0 +1,40 @@
"use client";
import type { ReactNode } from "react";
import { useFormStatus } from "react-dom";
import { cn } from "@/lib/utils";
interface BatchActionButtonClientProps {
value?: string;
children: ReactNode;
name?: string;
destructive?: boolean;
className?: string;
}
export function BatchActionButtonClient({
value,
children,
name = "action",
destructive,
className,
}: BatchActionButtonClientProps) {
const { pending } = useFormStatus();
return (
<button
type="submit"
name={value == null ? undefined : name}
value={value}
disabled={pending}
aria-busy={pending}
className={cn(
"btn-base rounded-xl border px-3 py-2 text-sm font-semibold focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/20 disabled:pointer-events-none disabled:opacity-50",
destructive ? "btn-danger-3d" : "btn-cream",
className,
)}
>
{pending ? "处理中..." : children}
</button>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, type ComponentProps, type ReactNode } from "react";
import { useRef, useState, type ComponentProps, type ReactNode } from "react";
import { AlertTriangle } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -49,8 +49,12 @@ export function ConfirmActionButton({
}: ConfirmActionButtonProps) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const loadingRef = useRef(false);
async function runAction() {
if (loadingRef.current) return;
loadingRef.current = true;
setLoading(true);
try {
await onConfirm();
@@ -60,6 +64,7 @@ export function ConfirmActionButton({
} catch (error) {
toast.error(getErrorMessage(error, errorMessage));
} finally {
loadingRef.current = false;
setLoading(false);
}
}

View File

@@ -0,0 +1,26 @@
"use client";
import { useFormStatus } from "react-dom";
import { Button } from "@/components/ui/button";
import type { ComponentProps, ReactNode } from "react";
type PendingSubmitButtonProps = ComponentProps<typeof Button> & {
children: ReactNode;
pendingLabel?: ReactNode;
};
export function PendingSubmitButton({
children,
disabled,
pendingLabel = "处理中...",
type = "submit",
...props
}: PendingSubmitButtonProps) {
const { pending } = useFormStatus();
return (
<Button type={type} disabled={disabled || pending} aria-busy={pending} {...props}>
{pending ? pendingLabel : children}
</Button>
);
}