mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
fix: prevent duplicate support ticket submissions
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" /> 自动化任务
|
||||
|
||||
@@ -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 }) {
|
||||
仅支持 JPG、PNG、WEBP、GIF、AVIF 图片,最多 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
仅支持 JPG、PNG、WEBP、GIF、AVIF 图片,最多 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
40
src/components/admin/batch-action-button-client.tsx
Normal file
40
src/components/admin/batch-action-button-client.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
26
src/components/shared/pending-submit-button.tsx
Normal file
26
src/components/shared/pending-submit-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user