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

@@ -756,6 +756,7 @@ model AppConfig {
emailVerificationRequired Boolean @default(false) emailVerificationRequired Boolean @default(false)
requireInviteCode Boolean @default(false) requireInviteCode Boolean @default(false)
supportContact String? supportContact String?
supportOpenTicketLimit Int @default(2)
maintenanceNotice String? maintenanceNotice String?
siteNotice String? siteNotice String?
autoReminderDispatchEnabled Boolean @default(true) autoReminderDispatchEnabled Boolean @default(true)

View File

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

View File

@@ -1,10 +1,12 @@
"use server"; "use server";
import { Prisma } from "@prisma/client";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { z } from "zod"; import { z } from "zod";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/require-auth"; import { requireAuth } from "@/lib/require-auth";
import { actorFromSession, recordAuditLog } from "@/services/audit"; import { actorFromSession, recordAuditLog } from "@/services/audit";
import { getAppConfig } from "@/services/app-config";
import { createNotification } from "@/services/notifications"; import { createNotification } from "@/services/notifications";
import { import {
createSupportAttachments, createSupportAttachments,
@@ -42,30 +44,50 @@ export async function createSupportTicket(formData: FormData) {
const body = riskEvent const body = riskEvent
? data.body + "\n\n关联订阅风控事件" + riskEvent.id + "\n系统判定" + riskEvent.message ? data.body + "\n\n关联订阅风控事件" + riskEvent.id + "\n系统判定" + riskEvent.message
: data.body; : data.body;
const config = await getAppConfig();
const supportOpenTicketLimit = Math.max(1, config.supportOpenTicketLimit);
const ticket = await prisma.supportTicket.create({ const ticket = await prisma.$transaction(
data: { async (tx) => {
userId: session.user.id, const openTicketCount = await tx.supportTicket.count({
subject: data.subject, where: {
category: data.category || (riskEvent ? "订阅风控" : null), userId: session.user.id,
priority: riskEvent ? "HIGH" : data.priority, status: { not: "CLOSED" },
status: "OPEN",
lastReplyAt: new Date(),
replies: {
create: {
authorUserId: session.user.id,
isAdmin: false,
body,
}, },
}, });
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: { { isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
replies: { );
orderBy: { createdAt: "desc" },
take: 1,
},
},
});
const firstReply = ticket.replies[0]; const firstReply = ticket.replies[0];
if (firstReply && attachments.length > 0) { if (firstReply && attachments.length > 0) {
await createSupportAttachments({ await createSupportAttachments({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation"; 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 { Button, buttonVariants } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -16,6 +16,7 @@ interface AppConfig {
siteUrl: string | null; siteUrl: string | null;
subscriptionUrl: string | null; subscriptionUrl: string | null;
supportContact: string | null; supportContact: string | null;
supportOpenTicketLimit: number;
maintenanceNotice: string | null; maintenanceNotice: string | null;
siteNotice: string | null; siteNotice: string | null;
allowRegistration: boolean; allowRegistration: boolean;
@@ -65,6 +66,7 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
async function handleSubmit(event: FormEvent<HTMLFormElement>) { async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (saving) return;
const form = event.currentTarget; const form = event.currentTarget;
setSaving(true); setSaving(true);
@@ -85,6 +87,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
} }
async function handleTestEmail() { async function handleTestEmail() {
if (testingEmail) return;
const form = document.getElementById("app-settings-form") as HTMLFormElement | null; const form = document.getElementById("app-settings-form") as HTMLFormElement | null;
if (!form) return; if (!form) return;
@@ -158,6 +162,29 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</div> </div>
</section> </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"> <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"> <div className="flex items-center gap-2 text-sm font-semibold">
<Clock3 className="size-4 text-primary" /> <Clock3 className="size-4 text-primary" />

View File

@@ -1,6 +1,6 @@
import { Paperclip, Send } from "lucide-react"; import { Paperclip, Send } from "lucide-react";
import { replySupportAsAdmin } from "@/actions/admin/support"; 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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
@@ -42,7 +42,7 @@ export function AdminSupportReplyForm({ ticketId }: { ticketId: string }) {
JPGPNGWEBPGIFAVIF 3 3MB JPGPNGWEBPGIFAVIF 3 3MB
</p> </p>
</div> </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> </form>
); );
} }

View File

@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { Eye } from "lucide-react";
import { DataTableShell } from "@/components/admin/data-table-shell"; import { DataTableShell } from "@/components/admin/data-table-shell";
import { import {
DataTable, DataTable,
@@ -14,6 +15,7 @@ import {
SupportTicketStatusBadge, SupportTicketStatusBadge,
} from "@/components/support/ticket-badges"; } from "@/components/support/ticket-badges";
import { AdminSupportTicketActions } from "@/components/support/admin-ticket-actions"; import { AdminSupportTicketActions } from "@/components/support/admin-ticket-actions";
import { buttonVariants } from "@/components/ui/button";
import { formatDate } from "@/lib/utils"; import { formatDate } from "@/lib/utils";
import type { AdminSupportTicketRow } from "../support-data"; import type { AdminSupportTicketRow } from "../support-data";
@@ -63,7 +65,14 @@ export function AdminSupportTable({ tickets }: AdminSupportTableProps) {
<time dateTime={ticket.updatedAt.toISOString()}>{formatDate(ticket.updatedAt)}</time> <time dateTime={ticket.updatedAt.toISOString()}>{formatDate(ticket.updatedAt)}</time>
</DataTableCell> </DataTableCell>
<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} /> <AdminSupportTicketActions ticketId={ticket.id} />
</div> </div>
</DataTableCell> </DataTableCell>

View File

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

View File

@@ -1,6 +1,6 @@
import { BellRing } from "lucide-react"; import { BellRing } from "lucide-react";
import { runReminderTask } from "@/actions/admin/tasks"; import { runReminderTask } from "@/actions/admin/tasks";
import { Button } from "@/components/ui/button"; import { PendingSubmitButton } from "@/components/shared/pending-submit-button";
export function TaskLaunchPanel() { export function TaskLaunchPanel() {
return ( return (
@@ -13,7 +13,7 @@ export function TaskLaunchPanel() {
<p className="font-semibold"></p> <p className="font-semibold"></p>
<p className="mt-1 text-xs leading-5 text-muted-foreground"></p> <p className="mt-1 text-xs leading-5 text-muted-foreground"></p>
</div> </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> </form>
</div> </div>
); );

View File

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

View File

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

View File

@@ -1,12 +1,15 @@
"use client"; "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 { Plus, X } from "lucide-react";
import { toast } from "sonner";
import { createSupportTicket } from "@/actions/user/support"; import { createSupportTicket } from "@/actions/user/support";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { getErrorMessage } from "@/lib/errors";
const ATTACHMENT_ACCEPT = "image/jpeg,image/png,image/webp,image/gif,image/avif"; const ATTACHMENT_ACCEPT = "image/jpeg,image/png,image/webp,image/gif,image/avif";
@@ -20,16 +23,66 @@ type SupportTicketPreset = {
export function CreateSupportTicketForm({ export function CreateSupportTicketForm({
defaultOpen = false, defaultOpen = false,
openTicketCount = 0,
openTicketLimit = 2,
preset, preset,
}: { }: {
defaultOpen?: boolean; defaultOpen?: boolean;
openTicketCount?: number;
openTicketLimit?: number;
preset?: SupportTicketPreset; preset?: SupportTicketPreset;
}) { }) {
const router = useRouter();
const [open, setOpen] = useState(defaultOpen); 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) { if (!open) {
return ( return (
<Button onClick={() => setOpen(true)} size="lg"> <Button id="new-ticket" onClick={() => setOpen(true)} size="lg">
<Plus className="size-4" /> <Plus className="size-4" />
</Button> </Button>
@@ -40,60 +93,69 @@ export function CreateSupportTicketForm({
<form <form
id="new-ticket" id="new-ticket"
action={createSupportTicket} action={createSupportTicket}
onSubmit={(event) => void handleSubmit(event)}
aria-busy={submitting}
className="surface-card space-y-5 rounded-[2rem] p-5 sm:p-6" 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> <h3 className="text-lg font-semibold">{preset?.riskEventId ? "订阅风控复核工单" : "新建工单"}</h3>
{!preset?.riskEventId && ( {!preset?.riskEventId && (
<button <button
type="button" type="button"
onClick={() => setOpen(false)} aria-label="收起新建工单表单"
className="flex size-9 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" disabled={submitting}
> onClick={() => setOpen(false)}
<X className="size-4" /> 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"
</button> >
<X className="size-4" />
</button>
)} )}
</div> </div>
<div className="grid gap-5 md:grid-cols-3">
<div className="space-y-2 md:col-span-2"> <fieldset disabled={submitting} className="space-y-5 disabled:opacity-70">
<Label htmlFor="subject"></Label> <div className="grid gap-5 md:grid-cols-3">
<Input id="subject" name="subject" placeholder="一句话描述遇到的问题" defaultValue={preset?.subject} required /> <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>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="priority"></Label> <Label htmlFor="category"></Label>
<select <Input id="category" name="category" placeholder="例如:支付 / 节点 / 流媒体 / 账户" defaultValue={preset?.category} />
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>
</div> </div>
</div> <div className="space-y-2">
<div className="space-y-2"> <Label htmlFor="body"></Label>
<Label htmlFor="category"></Label> <Textarea id="body" name="body" rows={5} placeholder="补充问题背景、错误提示或你已经尝试过的步骤" defaultValue={preset?.body} required />
<Input id="category" name="category" placeholder="例如:支付 / 节点 / 流媒体 / 账户" defaultValue={preset?.category} /> </div>
</div> <div className="space-y-2">
<div className="space-y-2"> <Label htmlFor="attachments"> 3 3MB</Label>
<Label htmlFor="body"></Label> <Input
<Textarea id="body" name="body" rows={5} placeholder="补充问题背景、错误提示或你已经尝试过的步骤" defaultValue={preset?.body} required /> id="attachments"
</div> name="attachments"
<div className="space-y-2"> type="file"
<Label htmlFor="attachments"> 3 3MB</Label> multiple
<Input accept={ATTACHMENT_ACCEPT}
id="attachments" />
name="attachments" </div>
type="file" {preset?.riskEventId && <input type="hidden" name="riskEventId" value={preset.riskEventId} />}
multiple <Button type="submit" size="lg" disabled={submitting}>
accept={ATTACHMENT_ACCEPT} {submitting ? "提交中..." : "提交工单"}
/> </Button>
</div> </fieldset>
{preset?.riskEventId && <input type="hidden" name="riskEventId" value={preset.riskEventId} />}
<Button type="submit" size="lg"></Button>
</form> </form>
); );
} }

View File

@@ -1,6 +1,6 @@
import { Paperclip, Send } from "lucide-react"; import { Paperclip, Send } from "lucide-react";
import { replySupportTicket } from "@/actions/user/support"; 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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
@@ -46,7 +46,7 @@ export function SupportTicketReplyForm({ ticketId }: SupportTicketReplyFormProps
JPGPNGWEBPGIFAVIF 3 3MB JPGPNGWEBPGIFAVIF 3 3MB
</p> </p>
</div> </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> </form>
); );
} }

View File

@@ -1,6 +1,6 @@
import type { SupportTicket } from "@prisma/client"; import type { SupportTicket } from "@prisma/client";
import Link from "next/link"; 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 { DataTableShell } from "@/components/shared/data-table-shell";
import { import {
DataTable, DataTable,
@@ -67,7 +67,14 @@ export function UserSupportTicketTable({ tickets }: UserSupportTicketTableProps)
<time dateTime={ticket.updatedAt.toISOString()}>{formatDate(ticket.updatedAt)}</time> <time dateTime={ticket.updatedAt.toISOString()}>{formatDate(ticket.updatedAt)}</time>
</DataTableCell> </DataTableCell>
<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} /> <UserSupportTicketActions ticketId={ticket.id} status={ticket.status} />
</div> </div>
</DataTableCell> </DataTableCell>

View File

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

View File

@@ -1,4 +1,5 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { BatchActionButtonClient } from "@/components/admin/batch-action-button-client";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface BatchActionBarProps { interface BatchActionBarProps {
@@ -36,25 +37,6 @@ export function BatchActionBar({
); );
} }
export function BatchActionButton({ export function BatchActionButton(props: BatchActionButtonProps) {
value, return <BatchActionButtonClient {...props} />;
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>
);
} }

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