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:
@@ -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)
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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" /> 自动化任务
|
||||||
|
|||||||
@@ -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 }) {
|
|||||||
仅支持 JPG、PNG、WEBP、GIF、AVIF 图片,最多 3 张,每张不超过 3MB。
|
仅支持 JPG、PNG、WEBP、GIF、AVIF 图片,最多 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|||||||
仅支持 JPG、PNG、WEBP、GIF、AVIF 图片,最多 3 张,每张不超过 3MB。
|
仅支持 JPG、PNG、WEBP、GIF、AVIF 图片,最多 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
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";
|
"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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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