mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
Initial commit
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
import { DataTableShell } from "@/components/admin/data-table-shell";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableBody,
|
||||
DataTableCell,
|
||||
DataTableHead,
|
||||
DataTableHeadCell,
|
||||
DataTableHeaderRow,
|
||||
DataTableRow,
|
||||
} from "@/components/shared/data-table";
|
||||
import {
|
||||
announcementAudienceLabels,
|
||||
announcementDisplayTypeLabels,
|
||||
getAnnouncementAudienceTone,
|
||||
} from "@/components/shared/domain-badges";
|
||||
import { StatusBadge, ActiveStatusBadge } from "@/components/shared/status-badge";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { AnnouncementActions } from "../announcement-actions";
|
||||
import type { AnnouncementOptionUser, AnnouncementRow } from "../announcements-data";
|
||||
|
||||
interface AnnouncementsTableProps {
|
||||
announcements: AnnouncementRow[];
|
||||
users: AnnouncementOptionUser[];
|
||||
}
|
||||
|
||||
function formatWindow(startAt: Date | null, endAt: Date | null) {
|
||||
return `${startAt ? formatDate(startAt) : "立即开始"} ~ ${endAt ? formatDate(endAt) : "长期有效"}`;
|
||||
}
|
||||
|
||||
export function AnnouncementsTable({ announcements, users }: AnnouncementsTableProps) {
|
||||
return (
|
||||
<DataTableShell
|
||||
isEmpty={announcements.length === 0}
|
||||
emptyTitle="暂无公告或消息"
|
||||
emptyDescription="发布公告后,会显示展示范围、时间窗口和启用状态。"
|
||||
>
|
||||
<DataTable aria-label="公告列表" className="min-w-[1040px]">
|
||||
<DataTableHead>
|
||||
<DataTableHeaderRow>
|
||||
<DataTableHeadCell>标题</DataTableHeadCell>
|
||||
<DataTableHeadCell>范围</DataTableHeadCell>
|
||||
<DataTableHeadCell>展示</DataTableHeadCell>
|
||||
<DataTableHeadCell>时间窗口</DataTableHeadCell>
|
||||
<DataTableHeadCell>通知</DataTableHeadCell>
|
||||
<DataTableHeadCell>创建人</DataTableHeadCell>
|
||||
<DataTableHeadCell>状态</DataTableHeadCell>
|
||||
<DataTableHeadCell className="text-right">操作</DataTableHeadCell>
|
||||
</DataTableHeaderRow>
|
||||
</DataTableHead>
|
||||
<DataTableBody>
|
||||
{announcements.map((announcement) => (
|
||||
<DataTableRow key={announcement.id}>
|
||||
<DataTableCell className="max-w-sm">
|
||||
<p className="font-medium">{announcement.title}</p>
|
||||
<p className="mt-1 whitespace-pre-wrap break-words text-xs leading-5 text-muted-foreground">
|
||||
{announcement.body}
|
||||
</p>
|
||||
</DataTableCell>
|
||||
<DataTableCell>
|
||||
<div className="space-y-1">
|
||||
<StatusBadge tone={getAnnouncementAudienceTone(announcement.audience)}>
|
||||
{announcementAudienceLabels[announcement.audience]}
|
||||
</StatusBadge>
|
||||
{announcement.targetUser?.email && (
|
||||
<p className="text-xs text-muted-foreground">{announcement.targetUser.email}</p>
|
||||
)}
|
||||
</div>
|
||||
</DataTableCell>
|
||||
<DataTableCell>
|
||||
<p>{announcementDisplayTypeLabels[announcement.displayType]}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{announcement.dismissible ? "可关闭" : "常驻"}
|
||||
</p>
|
||||
</DataTableCell>
|
||||
<DataTableCell className="max-w-52 text-xs leading-5 text-muted-foreground">
|
||||
{formatWindow(announcement.startAt, announcement.endAt)}
|
||||
</DataTableCell>
|
||||
<DataTableCell>
|
||||
<StatusBadge tone={announcement.sendNotification ? "info" : "neutral"}>
|
||||
{announcement.sendNotification ? "同步" : "不同步"}
|
||||
</StatusBadge>
|
||||
</DataTableCell>
|
||||
<DataTableCell className="max-w-56 whitespace-normal break-all">{announcement.createdBy?.email ?? "系统"}</DataTableCell>
|
||||
<DataTableCell>
|
||||
<ActiveStatusBadge active={announcement.isActive} activeLabel="启用" inactiveLabel="停用" />
|
||||
</DataTableCell>
|
||||
<DataTableCell>
|
||||
<div className="flex justify-end">
|
||||
<AnnouncementActions announcement={announcement} users={users} />
|
||||
</div>
|
||||
</DataTableCell>
|
||||
</DataTableRow>
|
||||
))}
|
||||
</DataTableBody>
|
||||
</DataTable>
|
||||
</DataTableShell>
|
||||
);
|
||||
}
|
||||
76
src/app/(admin)/admin/announcements/announcement-actions.tsx
Normal file
76
src/app/(admin)/admin/announcements/announcement-actions.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import type {
|
||||
AnnouncementAudience,
|
||||
AnnouncementDisplayType,
|
||||
} from "@prisma/client";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
deleteAnnouncement,
|
||||
toggleAnnouncement,
|
||||
} from "@/actions/admin/announcements";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
import { AnnouncementForm } from "./announcement-form";
|
||||
|
||||
interface AnnouncementOptionUser {
|
||||
id: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface AnnouncementActionItem {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
audience: AnnouncementAudience;
|
||||
displayType: AnnouncementDisplayType;
|
||||
targetUserId: string | null;
|
||||
dismissible: boolean;
|
||||
sendNotification: boolean;
|
||||
startAt: Date | string | null;
|
||||
endAt: Date | string | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export function AnnouncementActions({
|
||||
announcement,
|
||||
users,
|
||||
}: {
|
||||
announcement: AnnouncementActionItem;
|
||||
users: AnnouncementOptionUser[];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<AnnouncementForm announcement={announcement} users={users} />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
try {
|
||||
await toggleAnnouncement(announcement.id, !announcement.isActive);
|
||||
toast.success(announcement.isActive ? "公告已停用" : "公告已启用");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "更新状态失败"));
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{announcement.isActive ? "停用" : "启用"}
|
||||
</Button>
|
||||
<ConfirmActionButton
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
title="删除这条公告?"
|
||||
description="公告本体和已经同步的站内通知会一起删除,此操作无法恢复。"
|
||||
confirmLabel="删除公告"
|
||||
successMessage="公告已删除"
|
||||
errorMessage="删除失败"
|
||||
onConfirm={() => deleteAnnouncement(announcement.id)}
|
||||
>
|
||||
删除
|
||||
</ConfirmActionButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
353
src/app/(admin)/admin/announcements/announcement-form.tsx
Normal file
353
src/app/(admin)/admin/announcements/announcement-form.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type {
|
||||
AnnouncementAudience,
|
||||
AnnouncementDisplayType,
|
||||
} from "@prisma/client";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createAnnouncement,
|
||||
updateAnnouncement,
|
||||
} from "@/actions/admin/announcements";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
|
||||
interface AnnouncementOptionUser {
|
||||
id: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface AnnouncementFormData {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
audience: AnnouncementAudience;
|
||||
displayType: AnnouncementDisplayType;
|
||||
targetUserId: string | null;
|
||||
dismissible: boolean;
|
||||
sendNotification: boolean;
|
||||
startAt: Date | string | null;
|
||||
endAt: Date | string | null;
|
||||
}
|
||||
|
||||
function toDateTimeLocalValue(value: Date | string | null) {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const localTime = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
|
||||
return localTime.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
export function AnnouncementForm({
|
||||
users,
|
||||
announcement,
|
||||
triggerLabel,
|
||||
triggerVariant = "outline",
|
||||
}: {
|
||||
users: AnnouncementOptionUser[];
|
||||
announcement: AnnouncementFormData;
|
||||
triggerLabel?: string;
|
||||
triggerVariant?: "default" | "outline" | "ghost";
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [audience, setAudience] = useState<AnnouncementAudience>(announcement.audience);
|
||||
|
||||
async function handleSubmit(formData: FormData) {
|
||||
try {
|
||||
await updateAnnouncement(announcement.id, formData);
|
||||
toast.success("公告已更新");
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "更新公告失败"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (nextOpen) {
|
||||
setAudience(announcement.audience);
|
||||
}
|
||||
setOpen(nextOpen);
|
||||
}}
|
||||
>
|
||||
<DialogTrigger render={<Button variant={triggerVariant} size="sm" />}>
|
||||
{triggerLabel ?? "编辑"}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑公告</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form action={handleSubmit} className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`title-${announcement.id}`}>标题</Label>
|
||||
<Input id={`title-${announcement.id}`} name="title" defaultValue={announcement.title} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`audience-${announcement.id}`}>目标范围</Label>
|
||||
<select
|
||||
id={`audience-${announcement.id}`}
|
||||
name="audience"
|
||||
defaultValue={announcement.audience}
|
||||
onChange={(event) => setAudience(event.target.value as AnnouncementAudience)}
|
||||
className="h-10 w-full px-3 text-sm outline-none"
|
||||
>
|
||||
<option value="PUBLIC">公开(登录/注册页可见)</option>
|
||||
<option value="USERS">全部用户</option>
|
||||
<option value="ADMINS">全部管理员</option>
|
||||
<option value="SPECIFIC_USER">指定用户</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`displayType-${announcement.id}`}>展示方式</Label>
|
||||
<select
|
||||
id={`displayType-${announcement.id}`}
|
||||
name="displayType"
|
||||
defaultValue={announcement.displayType}
|
||||
className="h-10 w-full px-3 text-sm outline-none"
|
||||
>
|
||||
<option value="INLINE">普通公告</option>
|
||||
<option value="BIG">大公告</option>
|
||||
<option value="POPUP">弹窗公告</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`dismissible-${announcement.id}`}>允许用户关闭</Label>
|
||||
<select
|
||||
id={`dismissible-${announcement.id}`}
|
||||
name="dismissible"
|
||||
defaultValue={announcement.dismissible ? "true" : "false"}
|
||||
className="h-10 w-full px-3 text-sm outline-none"
|
||||
>
|
||||
<option value="true">是</option>
|
||||
<option value="false">否</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`targetUserId-${announcement.id}`}>指定用户(可选)</Label>
|
||||
<select
|
||||
id={`targetUserId-${announcement.id}`}
|
||||
name="targetUserId"
|
||||
defaultValue={announcement.targetUserId ?? ""}
|
||||
disabled={audience !== "SPECIFIC_USER"}
|
||||
className="h-10 w-full px-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<option value="">不指定</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.email}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`body-${announcement.id}`}>内容</Label>
|
||||
<Textarea
|
||||
id={`body-${announcement.id}`}
|
||||
name="body"
|
||||
rows={5}
|
||||
defaultValue={announcement.body}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`startAt-${announcement.id}`}>开始时间(可选)</Label>
|
||||
<Input
|
||||
id={`startAt-${announcement.id}`}
|
||||
name="startAt"
|
||||
type="datetime-local"
|
||||
defaultValue={toDateTimeLocalValue(announcement.startAt)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`endAt-${announcement.id}`}>结束时间(可选)</Label>
|
||||
<Input
|
||||
id={`endAt-${announcement.id}`}
|
||||
name="endAt"
|
||||
type="datetime-local"
|
||||
defaultValue={toDateTimeLocalValue(announcement.endAt)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`sendNotification-${announcement.id}`}>同步发送站内通知</Label>
|
||||
<select
|
||||
id={`sendNotification-${announcement.id}`}
|
||||
name="sendNotification"
|
||||
defaultValue={announcement.sendNotification ? "true" : "false"}
|
||||
className="h-10 w-full px-3 text-sm outline-none"
|
||||
>
|
||||
<option value="true">是</option>
|
||||
<option value="false">否</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
保存修改
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreateAnnouncementButton({
|
||||
users,
|
||||
}: {
|
||||
users: AnnouncementOptionUser[];
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [audience, setAudience] = useState<AnnouncementAudience>("USERS");
|
||||
|
||||
async function handleSubmit(formData: FormData) {
|
||||
try {
|
||||
await createAnnouncement(formData);
|
||||
toast.success("公告已发布");
|
||||
setOpen(false);
|
||||
setAudience("USERS");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "发布公告失败"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger render={<Button />}>发布公告</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>发布公告</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form action={handleSubmit} className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-announcement-title">标题</Label>
|
||||
<Input id="create-announcement-title" name="title" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-announcement-audience">目标范围</Label>
|
||||
<select
|
||||
id="create-announcement-audience"
|
||||
name="audience"
|
||||
defaultValue="USERS"
|
||||
onChange={(event) => setAudience(event.target.value as AnnouncementAudience)}
|
||||
className="h-10 w-full px-3 text-sm outline-none"
|
||||
>
|
||||
<option value="PUBLIC">公开(登录/注册页可见)</option>
|
||||
<option value="USERS">全部用户</option>
|
||||
<option value="ADMINS">全部管理员</option>
|
||||
<option value="SPECIFIC_USER">指定用户</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-announcement-displayType">展示方式</Label>
|
||||
<select
|
||||
id="create-announcement-displayType"
|
||||
name="displayType"
|
||||
defaultValue="INLINE"
|
||||
className="h-10 w-full px-3 text-sm outline-none"
|
||||
>
|
||||
<option value="INLINE">普通公告</option>
|
||||
<option value="BIG">大公告</option>
|
||||
<option value="POPUP">弹窗公告</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-announcement-dismissible">允许用户关闭</Label>
|
||||
<select
|
||||
id="create-announcement-dismissible"
|
||||
name="dismissible"
|
||||
defaultValue="true"
|
||||
className="h-10 w-full px-3 text-sm outline-none"
|
||||
>
|
||||
<option value="true">是</option>
|
||||
<option value="false">否</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-announcement-targetUserId">指定用户(可选)</Label>
|
||||
<select
|
||||
id="create-announcement-targetUserId"
|
||||
name="targetUserId"
|
||||
defaultValue=""
|
||||
disabled={audience !== "SPECIFIC_USER"}
|
||||
className="h-10 w-full px-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<option value="">不指定</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.email}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-announcement-body">内容</Label>
|
||||
<Textarea id="create-announcement-body" name="body" rows={5} required />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-announcement-startAt">开始时间(可选)</Label>
|
||||
<Input id="create-announcement-startAt" name="startAt" type="datetime-local" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-announcement-endAt">结束时间(可选)</Label>
|
||||
<Input id="create-announcement-endAt" name="endAt" type="datetime-local" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-announcement-sendNotification">同步发送站内通知</Label>
|
||||
<select
|
||||
id="create-announcement-sendNotification"
|
||||
name="sendNotification"
|
||||
defaultValue="true"
|
||||
className="h-10 w-full px-3 text-sm outline-none"
|
||||
>
|
||||
<option value="true">是</option>
|
||||
<option value="false">否</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
发布
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
63
src/app/(admin)/admin/announcements/announcements-data.ts
Normal file
63
src/app/(admin)/admin/announcements/announcements-data.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { parsePage } from "@/lib/utils";
|
||||
|
||||
const announcementInclude = {
|
||||
targetUser: {
|
||||
select: { email: true },
|
||||
},
|
||||
createdBy: {
|
||||
select: { email: true },
|
||||
},
|
||||
} satisfies Prisma.AnnouncementInclude;
|
||||
|
||||
export type AnnouncementRow = Prisma.AnnouncementGetPayload<{
|
||||
include: typeof announcementInclude;
|
||||
}>;
|
||||
|
||||
export type AnnouncementOptionUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export async function getAnnouncements(
|
||||
searchParams: Record<string, string | string[] | undefined>,
|
||||
) {
|
||||
const { page, skip, pageSize } = parsePage(searchParams, 20);
|
||||
const q = typeof searchParams.q === "string" ? searchParams.q.trim() : "";
|
||||
const audience = typeof searchParams.audience === "string" ? searchParams.audience : "";
|
||||
const status = typeof searchParams.status === "string" ? searchParams.status : "";
|
||||
|
||||
const where = {
|
||||
...(audience
|
||||
? { audience: audience as "PUBLIC" | "USERS" | "ADMINS" | "SPECIFIC_USER" }
|
||||
: {}),
|
||||
...(status ? { isActive: status === "active" } : {}),
|
||||
...(q
|
||||
? {
|
||||
OR: [
|
||||
{ title: { contains: q, mode: "insensitive" as const } },
|
||||
{ body: { contains: q, mode: "insensitive" as const } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
} satisfies Prisma.AnnouncementWhereInput;
|
||||
|
||||
const [announcements, total, users] = await Promise.all([
|
||||
prisma.announcement.findMany({
|
||||
where,
|
||||
include: announcementInclude,
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.announcement.count({ where }),
|
||||
prisma.user.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 100,
|
||||
select: { id: true, email: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
return { announcements, total, users, page, pageSize, filters: { q, audience, status } };
|
||||
}
|
||||
62
src/app/(admin)/admin/announcements/page.tsx
Normal file
62
src/app/(admin)/admin/announcements/page.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { Metadata } from "next";
|
||||
import { AdminFilterBar } from "@/components/admin/filter-bar";
|
||||
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||
import { Pagination } from "@/components/shared/pagination";
|
||||
import { AnnouncementsTable } from "./_components/announcements-table";
|
||||
import { CreateAnnouncementButton } from "./announcement-form";
|
||||
import { getAnnouncements } from "./announcements-data";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "公告与消息",
|
||||
description: "发布全站公告与定向通知。",
|
||||
};
|
||||
|
||||
export default async function AnnouncementsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const { announcements, total, users, page, pageSize, filters } = await getAnnouncements(
|
||||
await searchParams,
|
||||
);
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
eyebrow="用户支持"
|
||||
title="公告与消息"
|
||||
actions={<CreateAnnouncementButton users={users} />}
|
||||
/>
|
||||
|
||||
<AdminFilterBar
|
||||
q={filters.q}
|
||||
searchPlaceholder="搜索标题或内容"
|
||||
selects={[
|
||||
{
|
||||
name: "audience",
|
||||
value: filters.audience,
|
||||
options: [
|
||||
{ label: "全部范围", value: "" },
|
||||
{ label: "公开", value: "PUBLIC" },
|
||||
{ label: "全部用户", value: "USERS" },
|
||||
{ label: "全部管理员", value: "ADMINS" },
|
||||
{ label: "指定用户", value: "SPECIFIC_USER" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
value: filters.status,
|
||||
options: [
|
||||
{ label: "全部状态", value: "" },
|
||||
{ label: "启用", value: "active" },
|
||||
{ label: "停用", value: "inactive" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<AnnouncementsTable announcements={announcements} users={users} />
|
||||
<Pagination total={total} pageSize={pageSize} page={page} />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { AuditLog } from "@prisma/client";
|
||||
import { DataTableShell } from "@/components/admin/data-table-shell";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableBody,
|
||||
DataTableCell,
|
||||
DataTableHead,
|
||||
DataTableHeadCell,
|
||||
DataTableHeaderRow,
|
||||
DataTableRow,
|
||||
} from "@/components/shared/data-table";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
|
||||
export function AuditLogsTable({ logs }: { logs: AuditLog[] }) {
|
||||
return (
|
||||
<DataTableShell
|
||||
isEmpty={logs.length === 0}
|
||||
emptyTitle="暂无审计日志"
|
||||
emptyDescription="后台关键操作发生后,会记录在这里。"
|
||||
>
|
||||
<DataTable aria-label="审计日志列表" className="min-w-[980px]">
|
||||
<DataTableHead>
|
||||
<DataTableHeaderRow>
|
||||
<DataTableHeadCell>时间</DataTableHeadCell>
|
||||
<DataTableHeadCell>操作者</DataTableHeadCell>
|
||||
<DataTableHeadCell>动作</DataTableHeadCell>
|
||||
<DataTableHeadCell>目标</DataTableHeadCell>
|
||||
<DataTableHeadCell>说明</DataTableHeadCell>
|
||||
</DataTableHeaderRow>
|
||||
</DataTableHead>
|
||||
<DataTableBody>
|
||||
{logs.map((log) => (
|
||||
<DataTableRow key={log.id}>
|
||||
<DataTableCell className="whitespace-nowrap text-muted-foreground">
|
||||
{formatDate(log.createdAt)}
|
||||
</DataTableCell>
|
||||
<DataTableCell>
|
||||
<div className="space-y-1">
|
||||
<p>{log.actorEmail || "系统"}</p>
|
||||
<p className="text-xs text-muted-foreground">{log.actorRole || "—"}</p>
|
||||
</div>
|
||||
</DataTableCell>
|
||||
<DataTableCell className="whitespace-nowrap font-medium">{log.action}</DataTableCell>
|
||||
<DataTableCell>
|
||||
<div className="space-y-1">
|
||||
<p>{log.targetType}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{log.targetLabel || log.targetId || "—"}
|
||||
</p>
|
||||
</div>
|
||||
</DataTableCell>
|
||||
<DataTableCell className="max-w-xl whitespace-pre-wrap break-words text-muted-foreground">
|
||||
{log.message}
|
||||
</DataTableCell>
|
||||
</DataTableRow>
|
||||
))}
|
||||
</DataTableBody>
|
||||
</DataTable>
|
||||
</DataTableShell>
|
||||
);
|
||||
}
|
||||
46
src/app/(admin)/admin/audit-logs/audit-logs-data.ts
Normal file
46
src/app/(admin)/admin/audit-logs/audit-logs-data.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { parsePage } from "@/lib/utils";
|
||||
|
||||
export async function getAuditLogs(
|
||||
searchParams: Record<string, string | string[] | undefined>,
|
||||
) {
|
||||
const { page, skip, pageSize } = parsePage(searchParams, 50);
|
||||
const q = typeof searchParams.q === "string" ? searchParams.q.trim() : "";
|
||||
const action = typeof searchParams.action === "string" ? searchParams.action : "";
|
||||
|
||||
const where = {
|
||||
...(action ? { action: { startsWith: action } } : {}),
|
||||
...(q
|
||||
? {
|
||||
OR: [
|
||||
{ action: { contains: q, mode: "insensitive" as const } },
|
||||
{ targetType: { contains: q, mode: "insensitive" as const } },
|
||||
{ targetLabel: { contains: q, mode: "insensitive" as const } },
|
||||
{ actorEmail: { contains: q, mode: "insensitive" as const } },
|
||||
{ message: { contains: q, mode: "insensitive" as const } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
} satisfies Prisma.AuditLogWhereInput;
|
||||
|
||||
const [logs, total] = await Promise.all([
|
||||
prisma.auditLog.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.auditLog.count({ where }),
|
||||
]);
|
||||
|
||||
return { logs, total, page, pageSize, filters: { q, action } };
|
||||
}
|
||||
|
||||
export function buildAuditLogExportHref(filters: { q: string; action: string }) {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.q) params.set("q", filters.q);
|
||||
if (filters.action) params.set("action", filters.action);
|
||||
const query = params.toString();
|
||||
return `/api/admin/export/audit-logs${query ? `?${query}` : ""}`;
|
||||
}
|
||||
63
src/app/(admin)/admin/audit-logs/page.tsx
Normal file
63
src/app/(admin)/admin/audit-logs/page.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Download } from "lucide-react";
|
||||
import { AdminFilterBar } from "@/components/admin/filter-bar";
|
||||
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||
import { Pagination } from "@/components/shared/pagination";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { AuditLogsTable } from "./_components/audit-logs-table";
|
||||
import { buildAuditLogExportHref, getAuditLogs } from "./audit-logs-data";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "审计日志",
|
||||
description: "查询关键后台操作记录并支持日志导出。",
|
||||
};
|
||||
|
||||
export default async function AuditLogsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const { logs, total, page, pageSize, filters } = await getAuditLogs(await searchParams);
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
eyebrow="系统"
|
||||
title="审计日志"
|
||||
actions={
|
||||
<a
|
||||
href={buildAuditLogExportHref(filters)}
|
||||
className={buttonVariants({ variant: "outline" })}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
导出日志
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
|
||||
<AdminFilterBar
|
||||
q={filters.q}
|
||||
searchPlaceholder="搜索动作、目标、操作者、说明"
|
||||
selects={[
|
||||
{
|
||||
name: "action",
|
||||
value: filters.action,
|
||||
options: [
|
||||
{ label: "全部动作前缀", value: "" },
|
||||
{ label: "user.", value: "user." },
|
||||
{ label: "order.", value: "order." },
|
||||
{ label: "subscription.", value: "subscription." },
|
||||
{ label: "plan.", value: "plan." },
|
||||
{ label: "service.", value: "service." },
|
||||
{ label: "node.", value: "node." },
|
||||
{ label: "task.", value: "task." },
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<AuditLogsTable logs={logs} />
|
||||
<Pagination total={total} pageSize={pageSize} page={page} />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
46
src/app/(admin)/admin/backups/page.tsx
Normal file
46
src/app/(admin)/admin/backups/page.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { Metadata } from "next";
|
||||
import { DatabaseBackup, Download } from "lucide-react";
|
||||
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { RestoreBackupForm } from "./restore-form";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "备份与恢复",
|
||||
description: "导出数据库备份并支持 SQL 恢复。",
|
||||
};
|
||||
|
||||
export default function BackupsPage() {
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
eyebrow="系统"
|
||||
title="备份与恢复"
|
||||
/>
|
||||
|
||||
<section className="surface-card surface-lift overflow-hidden rounded-xl p-5">
|
||||
<div className="flex flex-col gap-5 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<DatabaseBackup className="size-4" />
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold tracking-tight">导出数据库</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||
导出为可恢复的 SQL 脚本,适合在升级、迁移和大规模配置调整前做完整备份。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="/api/admin/backup/database"
|
||||
className={buttonVariants({ size: "lg" })}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
下载 SQL 备份
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<RestoreBackupForm />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
63
src/app/(admin)/admin/backups/restore-form.tsx
Normal file
63
src/app/(admin)/admin/backups/restore-form.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { restoreDatabaseBackup } from "@/actions/admin/backups";
|
||||
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";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function RestoreBackupForm() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(formData: FormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await restoreDatabaseBackup(formData);
|
||||
toast.success("数据库恢复已执行,建议检查关键页面和容器日志");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "恢复失败"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={handleSubmit} className="form-panel space-y-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-destructive/15 bg-destructive/10 text-destructive">
|
||||
<AlertTriangle className="size-5" />
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold tracking-tight">恢复数据库</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||||
支持上传 SQL 备份文件或直接粘贴 SQL。恢复会覆盖当前数据库对象,请确认备份来源可信。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-[0.9fr_1.1fr]">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sqlFile">SQL 备份文件</Label>
|
||||
<Input id="sqlFile" name="sqlFile" type="file" accept=".sql,text/plain" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmation">确认口令</Label>
|
||||
<Input id="confirmation" name="confirmation" placeholder="请输入 RESTORE" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sqlText">或粘贴 SQL 内容</Label>
|
||||
<Textarea id="sqlText" name="sqlText" rows={8} placeholder="-- paste sql backup here" />
|
||||
</div>
|
||||
|
||||
<Button type="submit" size="lg" variant="destructive" disabled={loading} className="w-full sm:w-auto">
|
||||
{loading ? "恢复中..." : "执行恢复"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
import { toggleCoupon, togglePromotionRule } from "@/actions/admin/commerce";
|
||||
|
||||
type ToggleKind = "coupon" | "promotion";
|
||||
|
||||
export function CommerceToggleButton({
|
||||
id,
|
||||
active,
|
||||
kind,
|
||||
}: {
|
||||
id: string;
|
||||
active: boolean;
|
||||
kind: ToggleKind;
|
||||
}) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const nextActive = !active;
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={active ? "outline" : "default"}
|
||||
disabled={pending}
|
||||
onClick={() => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
if (kind === "coupon") await toggleCoupon(id, nextActive);
|
||||
if (kind === "promotion") await togglePromotionRule(id, nextActive);
|
||||
toast.success(nextActive ? "已启用" : "已停用");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "操作失败"));
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
{pending ? "处理中..." : active ? "停用" : "启用"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
17
src/app/(admin)/admin/commerce/commerce-data.ts
Normal file
17
src/app/(admin)/admin/commerce/commerce-data.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function getCommerceData() {
|
||||
const [coupons, promotions] = await Promise.all([
|
||||
prisma.coupon.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { _count: { select: { orders: true, grants: true } } },
|
||||
take: 30,
|
||||
}),
|
||||
prisma.promotionRule.findMany({
|
||||
orderBy: [{ sortOrder: "asc" }, { thresholdAmount: "asc" }],
|
||||
take: 30,
|
||||
}),
|
||||
]);
|
||||
|
||||
return { coupons, promotions };
|
||||
}
|
||||
158
src/app/(admin)/admin/commerce/page.tsx
Normal file
158
src/app/(admin)/admin/commerce/page.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Gift, Sparkles } from "lucide-react";
|
||||
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 { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { getCommerceData } from "./commerce-data";
|
||||
import { CommerceToggleButton } from "./_components/commerce-actions";
|
||||
|
||||
const selectClassName = "premium-input w-full appearance-none px-3.5 py-2 text-sm outline-none";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "商业配置",
|
||||
description: "管理优惠券与满减规则。",
|
||||
};
|
||||
|
||||
export default async function AdminCommercePage() {
|
||||
const { coupons, promotions } = await getCommerceData();
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
eyebrow="商业配置"
|
||||
title="优惠与奖励"
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="create" className="space-y-6">
|
||||
<TabsList variant="line" className="surface-card p-1">
|
||||
<TabsTrigger value="create">新建规则</TabsTrigger>
|
||||
<TabsTrigger value="manage">规则列表</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="create">
|
||||
<section className="grid gap-5 xl:grid-cols-2">
|
||||
<form action={createCoupon} className="form-panel space-y-4">
|
||||
<SectionHeader title="新建优惠券" />
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="coupon-code">优惠码</Label>
|
||||
<Input id="coupon-code" name="code" placeholder="WELCOME10" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="coupon-name">名称</Label>
|
||||
<Input id="coupon-name" name="name" placeholder="新人礼遇" required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="coupon-type">优惠类型</Label>
|
||||
<select id="coupon-type" name="discountType" className={selectClassName} defaultValue="AMOUNT_OFF">
|
||||
<option value="AMOUNT_OFF">立减金额</option>
|
||||
<option value="PERCENT_OFF">折扣百分比</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="coupon-value">优惠值</Label>
|
||||
<Input id="coupon-value" name="discountValue" type="number" step="0.01" min="0.01" placeholder="10 或 15" required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Input name="thresholdAmount" type="number" step="0.01" placeholder="满多少可用" />
|
||||
<Input name="totalLimit" type="number" placeholder="总次数" />
|
||||
<Input name="perUserLimit" type="number" placeholder="每人次数" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="coupon-public">用户可见</Label>
|
||||
<select id="coupon-public" name="isPublic" className={selectClassName} defaultValue="true">
|
||||
<option value="true">公开展示</option>
|
||||
<option value="false">仅发放可用</option>
|
||||
</select>
|
||||
</div>
|
||||
<Button type="submit" className="w-full">创建优惠券</Button>
|
||||
</form>
|
||||
|
||||
<form action={createPromotionRule} className="form-panel space-y-4">
|
||||
<SectionHeader title="新建满减" />
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="promotion-name">规则名称</Label>
|
||||
<Input id="promotion-name" name="name" placeholder="满百礼遇" required />
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="promotion-threshold">门槛金额</Label>
|
||||
<Input id="promotion-threshold" name="thresholdAmount" type="number" step="0.01" min="0.01" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="promotion-discount">减免金额</Label>
|
||||
<Input id="promotion-discount" name="discountAmount" type="number" step="0.01" min="0.01" required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="promotion-sort">排序</Label>
|
||||
<Input id="promotion-sort" name="sortOrder" type="number" defaultValue={100} />
|
||||
</div>
|
||||
<Button type="submit" className="w-full">创建满减</Button>
|
||||
</form>
|
||||
</section>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="manage" className="space-y-6">
|
||||
<section className="space-y-4">
|
||||
<SectionHeader title="优惠券" />
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{coupons.map((coupon) => (
|
||||
<article key={coupon.id} className="surface-card rounded-xl p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex size-10 items-center justify-center rounded-[1rem] bg-amber-500/10 text-amber-700 dark:text-amber-300"><Gift className="size-4" /></span>
|
||||
<div>
|
||||
<h3 className="font-semibold">{coupon.name}</h3>
|
||||
<p className="mt-1 font-mono text-sm text-primary">{coupon.code}</p>
|
||||
</div>
|
||||
</div>
|
||||
<CommerceToggleButton kind="coupon" id={coupon.id} active={coupon.isActive} />
|
||||
</div>
|
||||
<DetailList className="mt-4">
|
||||
<DetailItem label="优惠">{coupon.discountType === "PERCENT_OFF" ? `${Number(coupon.discountValue)}%` : `¥${Number(coupon.discountValue).toFixed(2)}`}</DetailItem>
|
||||
<DetailItem label="门槛">{coupon.thresholdAmount == null ? "无门槛" : `满 ¥${Number(coupon.thresholdAmount).toFixed(2)}`}</DetailItem>
|
||||
<DetailItem label="可见性">{coupon.isPublic ? "公开" : "仅发放"}</DetailItem>
|
||||
<DetailItem label="使用">订单 {coupon._count.orders} · 发放 {coupon._count.grants}</DetailItem>
|
||||
</DetailList>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<SectionHeader title="满减规则" />
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{promotions.map((rule) => (
|
||||
<article key={rule.id} className="surface-card rounded-xl p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex size-10 items-center justify-center rounded-[1rem] bg-primary/10 text-primary"><Sparkles className="size-4" /></span>
|
||||
<div>
|
||||
<h3 className="font-semibold">{rule.name}</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">满 ¥{Number(rule.thresholdAmount).toFixed(2)} 减 ¥{Number(rule.discountAmount).toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<CommerceToggleButton kind="promotion" id={rule.id} active={rule.isActive} />
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<ActiveStatusBadge active={rule.isActive} activeLabel="启用中" inactiveLabel="已停用" />
|
||||
<StatusBadge>排序 {rule.sortOrder}</StatusBadge>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
116
src/app/(admin)/admin/dashboard/_components/recent-section.tsx
Normal file
116
src/app/(admin)/admin/dashboard/_components/recent-section.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { ReceiptText, UserRound } from "lucide-react";
|
||||
import { EmptyState } from "@/components/shared/page-shell";
|
||||
import { OrderStatusBadge } from "@/components/shared/domain-badges";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { formatDateShort } from "@/lib/utils";
|
||||
import type { RecentAdminOrder, RecentAdminUser } from "../dashboard-data";
|
||||
|
||||
interface RecentSectionProps {
|
||||
recentOrders: RecentAdminOrder[];
|
||||
recentUsers: RecentAdminUser[];
|
||||
}
|
||||
|
||||
export function RecentSection({ recentOrders, recentUsers }: RecentSectionProps) {
|
||||
return (
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ReceiptText className="size-4 text-primary" /> 最近订单
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recentOrders.length === 0 ? (
|
||||
<EmptyState
|
||||
title="还没有订单"
|
||||
description="用户创建订单后,这里会显示最新购买和支付状态。"
|
||||
className="border-0 bg-transparent py-8"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{recentOrders.map((order) => (
|
||||
<div
|
||||
key={order.id}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/30 px-4 py-3 transition-colors duration-200 hover:border-primary/20 hover:bg-primary/7"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{order.plan.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{order.user.email} · {formatDateShort(order.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="text-sm font-semibold tabular-nums">
|
||||
¥{Number(order.amount).toFixed(2)}
|
||||
</span>
|
||||
<OrderStatusBadge status={order.status} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<UserRound className="size-4 text-primary" /> 最近注册
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recentUsers.length === 0 ? (
|
||||
<EmptyState
|
||||
title="还没有新用户"
|
||||
description="新用户注册后,这里会显示最近加入的账户。"
|
||||
className="border-0 bg-transparent py-8"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{recentUsers.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/30 px-4 py-3 transition-colors duration-200 hover:border-primary/20 hover:bg-primary/7"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{user.name || user.email}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-full bg-background px-2.5 py-1 text-xs text-muted-foreground">
|
||||
{formatDateShort(user.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RecentSectionSkeleton() {
|
||||
return (
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
{[0, 1].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader>
|
||||
<div className="h-5 w-20 animate-pulse rounded bg-muted" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{[0, 1, 2].map((j) => (
|
||||
<div
|
||||
key={j}
|
||||
className="h-14 animate-pulse rounded-[1.15rem] bg-muted/30"
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
src/app/(admin)/admin/dashboard/dashboard-data.ts
Normal file
52
src/app/(admin)/admin/dashboard/dashboard-data.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { Prisma, User } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const recentOrderInclude = {
|
||||
user: true,
|
||||
plan: true,
|
||||
} satisfies Prisma.OrderInclude;
|
||||
|
||||
export type RecentAdminOrder = Prisma.OrderGetPayload<{
|
||||
include: typeof recentOrderInclude;
|
||||
}>;
|
||||
|
||||
export type RecentAdminUser = User;
|
||||
|
||||
export async function getAdminDashboardStats() {
|
||||
const [userCount, activeSubCount, orderCount, nodeCount, revenue] = await Promise.all([
|
||||
prisma.user.count(),
|
||||
prisma.userSubscription.count({ where: { status: "ACTIVE" } }),
|
||||
prisma.order.count({ where: { status: "PAID" } }),
|
||||
prisma.nodeServer.count({ where: { status: "active" } }),
|
||||
prisma.order.aggregate({
|
||||
where: { status: "PAID" },
|
||||
_sum: { amount: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const totalRevenue = Number(revenue._sum.amount ?? 0);
|
||||
|
||||
return [
|
||||
{ label: "总用户", value: userCount },
|
||||
{ label: "活跃订阅", value: activeSubCount },
|
||||
{ label: "已完成订单", value: orderCount },
|
||||
{ label: "在线节点", value: nodeCount },
|
||||
{ label: "总收入", value: `¥${totalRevenue.toFixed(2)}` },
|
||||
];
|
||||
}
|
||||
|
||||
export async function getRecentAdminActivity() {
|
||||
const [recentOrders, recentUsers] = await Promise.all([
|
||||
prisma.order.findMany({
|
||||
include: recentOrderInclude,
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 5,
|
||||
}),
|
||||
prisma.user.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 5,
|
||||
}),
|
||||
]);
|
||||
|
||||
return { recentOrders, recentUsers };
|
||||
}
|
||||
36
src/app/(admin)/admin/dashboard/page.tsx
Normal file
36
src/app/(admin)/admin/dashboard/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { Metadata } from "next";
|
||||
import { MetricCard } from "@/components/shared/metric-card";
|
||||
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||
import { getAdminDashboardStats, getRecentAdminActivity } from "./dashboard-data";
|
||||
import { RecentSection } from "./_components/recent-section";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "仪表盘",
|
||||
description: "查看后台核心指标与近期关键活动。",
|
||||
};
|
||||
|
||||
export default async function AdminDashboard() {
|
||||
const [stats, recentActivity] = await Promise.all([
|
||||
getAdminDashboardStats(),
|
||||
getRecentAdminActivity(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
eyebrow="管理概览"
|
||||
title="仪表盘"
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-5">
|
||||
{stats.map((stat) => (
|
||||
<MetricCard key={stat.label} label={stat.label} value={stat.value} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<RecentSection
|
||||
recentOrders={recentActivity.recentOrders}
|
||||
recentUsers={recentActivity.recentUsers}
|
||||
/>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
26
src/app/(admin)/admin/error.tsx
Normal file
26
src/app/(admin)/admin/error.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export default function AdminError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="py-10 text-center space-y-5">
|
||||
<h1 className="text-xl font-semibold tracking-tight">出了点问题</h1>
|
||||
<p className="text-sm text-destructive">
|
||||
{error.message || "页面加载失败,请稍后重试。"}
|
||||
</p>
|
||||
<Button onClick={reset} className="h-10">重试</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
src/app/(admin)/admin/loading.tsx
Normal file
30
src/app/(admin)/admin/loading.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function AdminLoading() {
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in-up">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-8 w-48 rounded-2xl" />
|
||||
<Skeleton className="h-10 w-28 rounded-2xl" />
|
||||
</div>
|
||||
<div className="surface-card rounded-xl p-3">
|
||||
<div className="border-b border-border/45 p-3">
|
||||
<div className="flex gap-8">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-4 w-16 rounded-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="border-b border-border/30 p-3 last:border-b-0">
|
||||
<div className="flex gap-8">
|
||||
{Array.from({ length: 5 }).map((_, j) => (
|
||||
<Skeleton key={j} className="h-4 w-20 rounded-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import type { NodeDetail } from "../node-detail-data";
|
||||
import { InboundsTab } from "./tabs/inbounds-tab";
|
||||
|
||||
export function NodeDetailTabs({ node }: { node: NodeDetail }) {
|
||||
return (
|
||||
<Tabs defaultValue="inbounds">
|
||||
<TabsList variant="line" className="w-full overflow-x-auto">
|
||||
<TabsTrigger value="inbounds">
|
||||
3x-ui 入站 ({node.inbounds.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="inbounds">
|
||||
<InboundsTab node={node} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { Waypoints } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { EmptyState } from "@/components/shared/page-shell";
|
||||
import { InboundDeleteButton } from "../../../inbound-delete-button";
|
||||
import { InboundDisplayNameForm } from "../../../inbound-display-name-form";
|
||||
import type { NodeDetail } from "../../node-detail-data";
|
||||
|
||||
function getDisplayName(inbound: { tag: string; settings: unknown }) {
|
||||
const settings = inbound.settings;
|
||||
if (settings && typeof settings === "object" && "displayName" in settings) {
|
||||
const value = (settings as { displayName?: unknown }).displayName;
|
||||
if (typeof value === "string" && value.trim()) return value.trim();
|
||||
}
|
||||
return inbound.tag;
|
||||
}
|
||||
|
||||
export function InboundsTab({ node }: { node: NodeDetail }) {
|
||||
if (node.inbounds.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="暂无已同步入站"
|
||||
description="请先在 3x-ui 面板创建入站,然后回到节点列表点击测试并同步入站。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 pt-4">
|
||||
<p className="rounded-lg border border-border bg-muted/30 px-4 py-3 text-xs text-muted-foreground">
|
||||
入站配置由 3x-ui 面板维护;本页仅展示已同步的线路,并允许调整前台展示名称。
|
||||
</p>
|
||||
<div className="grid gap-3">
|
||||
{node.inbounds.map((inbound) => (
|
||||
<Card key={inbound.id}>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-3 pb-2">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<Waypoints className="size-4 shrink-0 text-primary" />
|
||||
<CardTitle className="text-sm">
|
||||
<InboundDisplayNameForm
|
||||
inboundId={inbound.id}
|
||||
defaultValue={getDisplayName(inbound)}
|
||||
/>
|
||||
</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary">{inbound.protocol}</Badge>
|
||||
<Badge variant="outline">:{inbound.port}</Badge>
|
||||
<InboundDeleteButton inboundId={inbound.id} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>客户端: {inbound.clients.length}</span>
|
||||
{inbound.streamSettings && typeof inbound.streamSettings === "object" && (
|
||||
<>
|
||||
{(inbound.streamSettings as Record<string, unknown>).network && (
|
||||
<span>传输: {String((inbound.streamSettings as Record<string, unknown>).network)}</span>
|
||||
)}
|
||||
{(inbound.streamSettings as Record<string, unknown>).security && (
|
||||
<span>安全: {String((inbound.streamSettings as Record<string, unknown>).security)}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/app/(admin)/admin/nodes/[id]/node-detail-data.ts
Normal file
28
src/app/(admin)/admin/nodes/[id]/node-detail-data.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
const nodeDetailInclude = {
|
||||
inbounds: {
|
||||
where: { isActive: true },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
include: {
|
||||
clients: {
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.NodeServerInclude;
|
||||
|
||||
export type NodeDetail = Prisma.NodeServerGetPayload<{
|
||||
include: typeof nodeDetailInclude;
|
||||
}>;
|
||||
|
||||
export async function getNodeDetail(id: string): Promise<NodeDetail> {
|
||||
const node = await prisma.nodeServer.findUnique({
|
||||
where: { id },
|
||||
include: nodeDetailInclude,
|
||||
});
|
||||
if (!node) notFound();
|
||||
return node;
|
||||
}
|
||||
47
src/app/(admin)/admin/nodes/[id]/page.tsx
Normal file
47
src/app/(admin)/admin/nodes/[id]/page.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||
import { StatusBadge } from "@/components/shared/status-badge";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { getNodeDetail } from "./node-detail-data";
|
||||
import { NodeDetailTabs } from "./_components/node-detail-tabs";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "节点详情",
|
||||
};
|
||||
|
||||
export default async function NodeDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const node = await getNodeDetail(id);
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/admin/nodes"
|
||||
className={buttonVariants({ variant: "ghost", size: "icon" })}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Link>
|
||||
<PageHeader
|
||||
eyebrow="基础设施"
|
||||
title={node.name}
|
||||
description={`3x-ui · ${node.panelUrl || "未配置面板"}`}
|
||||
actions={
|
||||
<StatusBadge tone={node.status === "active" ? "success" : "neutral"}>
|
||||
{node.status}
|
||||
</StatusBadge>
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<NodeDetailTabs node={node} />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
132
src/app/(admin)/admin/nodes/_components/node-card-list.tsx
Normal file
132
src/app/(admin)/admin/nodes/_components/node-card-list.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { Server, Waypoints } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { batchTestNodeConnections } from "@/actions/admin/nodes";
|
||||
import { BatchActionBar, BatchActionButton } from "@/components/admin/batch-action-bar";
|
||||
import { EmptyState } from "@/components/shared/page-shell";
|
||||
import { StatusBadge } from "@/components/shared/status-badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { InboundDeleteButton } from "../inbound-delete-button";
|
||||
import { InboundDisplayNameForm } from "../inbound-display-name-form";
|
||||
import { NodeActions } from "../node-actions";
|
||||
import { NodeForm } from "../node-form";
|
||||
import type { NodeServerRow } from "../nodes-data";
|
||||
|
||||
const NODE_BATCH_FORM_ID = "node-batch-form";
|
||||
|
||||
function PanelInfoBar({ node }: { node: NodeServerRow }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 rounded-lg border border-border bg-muted/30 px-4 py-3 text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">3x-ui</span>
|
||||
<span>{node.panelUrl || "未配置面板"}</span>
|
||||
{node.agentToken && <span>探测 Token: 已启用</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeCard({ node, siteUrl }: { node: NodeServerRow; siteUrl: string | null }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-4 pb-2 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<input
|
||||
form={NODE_BATCH_FORM_ID}
|
||||
type="checkbox"
|
||||
name="nodeIds"
|
||||
value={node.id}
|
||||
aria-label={`选择节点 ${node.name}`}
|
||||
className="mt-3 size-5 rounded-lg border-border accent-primary shadow-sm"
|
||||
/>
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
|
||||
<Server className="size-5" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="text-lg">
|
||||
<Link href={`/admin/nodes/${node.id}`} className="hover:underline">
|
||||
{node.name}
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{node.panelUrl || "未配置面板"} · {node._count.inbounds} 个入站
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge tone={node.status === "active" ? "success" : "neutral"}>
|
||||
{node.status}
|
||||
</StatusBadge>
|
||||
<NodeForm
|
||||
node={{
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
panelUrl: node.panelUrl,
|
||||
panelUsername: node.panelUsername,
|
||||
panelPassword: node.panelPassword,
|
||||
}}
|
||||
triggerLabel="编辑"
|
||||
triggerVariant="outline"
|
||||
/>
|
||||
<NodeActions
|
||||
node={{ id: node.id, name: node.name, agentToken: node.agentToken }}
|
||||
siteUrl={siteUrl}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<PanelInfoBar node={node} />
|
||||
{node.inbounds.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{node.inbounds.map((inbound) => (
|
||||
<div
|
||||
key={inbound.id}
|
||||
className="flex min-w-72 flex-wrap items-center gap-2 rounded-lg border border-border bg-background px-3 py-2 text-xs font-medium"
|
||||
>
|
||||
<Waypoints className="size-3.5 shrink-0 text-primary" />
|
||||
<span className="shrink-0 text-muted-foreground">{inbound.protocol} · {inbound.port}</span>
|
||||
<InboundDisplayNameForm
|
||||
inboundId={inbound.id}
|
||||
defaultValue={getInboundDisplayName(inbound)}
|
||||
/>
|
||||
<InboundDeleteButton inboundId={inbound.id} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="rounded-lg border border-dashed border-border bg-muted/20 px-4 py-3 text-xs text-muted-foreground">暂无已同步入站,请在 3x-ui 创建入站后点击同步</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function NodeCardList({ nodes, siteUrl }: { nodes: NodeServerRow[]; siteUrl: string | null }) {
|
||||
return (
|
||||
<>
|
||||
<BatchActionBar id={NODE_BATCH_FORM_ID} action={batchTestNodeConnections}>
|
||||
<BatchActionButton>批量同步入站</BatchActionButton>
|
||||
</BatchActionBar>
|
||||
<div className="grid gap-5">
|
||||
{nodes.map((node) => (
|
||||
<NodeCard key={node.id} node={node} siteUrl={siteUrl} />
|
||||
))}
|
||||
{nodes.length === 0 && (
|
||||
<EmptyState
|
||||
title="暂无节点"
|
||||
description="添加 3x-ui 节点后,可以同步入站并绑定到代理套餐。"
|
||||
action={<NodeForm triggerLabel="添加节点" />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function getInboundDisplayName(inbound: { tag: string; settings: unknown }) {
|
||||
const settings = inbound.settings;
|
||||
if (settings && typeof settings === "object" && "displayName" in settings) {
|
||||
const value = (settings as { displayName?: unknown }).displayName;
|
||||
if (typeof value === "string" && value.trim()) return value.trim();
|
||||
}
|
||||
|
||||
return inbound.tag;
|
||||
}
|
||||
26
src/app/(admin)/admin/nodes/inbound-delete-button.tsx
Normal file
26
src/app/(admin)/admin/nodes/inbound-delete-button.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { deleteInbound } from "@/actions/admin/nodes";
|
||||
import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
|
||||
|
||||
export function InboundDeleteButton({
|
||||
inboundId,
|
||||
}: {
|
||||
inboundId: string;
|
||||
}) {
|
||||
return (
|
||||
<ConfirmActionButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
className="h-7 px-2 text-destructive hover:text-destructive"
|
||||
title="删除这个线路入口?"
|
||||
description="这里只会移除本地同步记录,不会删除 3x-ui 面板中的入站。请确认没有套餐仍依赖它。"
|
||||
confirmLabel="删除入口"
|
||||
successMessage="线路入口已删除"
|
||||
errorMessage="删除线路入口失败"
|
||||
onConfirm={() => deleteInbound(inboundId)}
|
||||
>
|
||||
删除
|
||||
</ConfirmActionButton>
|
||||
);
|
||||
}
|
||||
44
src/app/(admin)/admin/nodes/inbound-display-name-form.tsx
Normal file
44
src/app/(admin)/admin/nodes/inbound-display-name-form.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { updateInboundDisplayName } from "@/actions/admin/nodes";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function InboundDisplayNameForm({
|
||||
inboundId,
|
||||
defaultValue,
|
||||
}: {
|
||||
inboundId: string;
|
||||
defaultValue: string;
|
||||
}) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function handleSubmit(formData: FormData) {
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateInboundDisplayName(inboundId, formData);
|
||||
toast.success("前台名称已更新");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "保存失败"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={handleSubmit} className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<Input
|
||||
name="displayName"
|
||||
defaultValue={defaultValue}
|
||||
placeholder="例如 悉尼 · 日常优选"
|
||||
className="h-8 min-h-8 rounded-xl px-3 text-xs"
|
||||
/>
|
||||
<Button type="submit" size="xs" variant="outline" disabled={saving}>
|
||||
{saving ? "保存中" : "保存"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
180
src/app/(admin)/admin/nodes/node-actions.tsx
Normal file
180
src/app/(admin)/admin/nodes/node-actions.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { KeyRound, Terminal } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { deleteNode, generateAgentToken, revokeAgentToken, testNodeConnection } from "@/actions/admin/nodes";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface NodeActionValue {
|
||||
id: string;
|
||||
name: string;
|
||||
agentToken: string | null;
|
||||
}
|
||||
|
||||
const INSTALL_SCRIPT_URL = "https://raw.githubusercontent.com/JetSprow/J-Board/main/scripts/install-jboard-agent.sh";
|
||||
|
||||
function shellQuote(value: string) {
|
||||
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function getServerUrl() {
|
||||
if (typeof window === "undefined") return "";
|
||||
const { protocol, host, hostname } = window.location;
|
||||
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") return "";
|
||||
return `${protocol}//${host}`;
|
||||
}
|
||||
|
||||
function buildInstallCommand(token: string, siteUrl: string | null) {
|
||||
const serverUrl = siteUrl || getServerUrl() || "https://你的域名";
|
||||
return `curl -fsSL ${INSTALL_SCRIPT_URL} | SERVER_URL=${shellQuote(serverUrl)} AUTH_TOKEN=${shellQuote(token)} bash`;
|
||||
}
|
||||
|
||||
export function NodeActions({ node, siteUrl }: { node: NodeActionValue; siteUrl: string | null }) {
|
||||
const [tokenDialogOpen, setTokenDialogOpen] = useState(false);
|
||||
const [plainToken, setPlainToken] = useState("");
|
||||
const [installCommand, setInstallCommand] = useState("");
|
||||
const hasToken = !!node.agentToken;
|
||||
|
||||
async function handleGenerateToken() {
|
||||
try {
|
||||
const token = await generateAgentToken(node.id);
|
||||
setPlainToken(token);
|
||||
setInstallCommand(buildInstallCommand(token, siteUrl));
|
||||
setTokenDialogOpen(true);
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "生成 Token 失败"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant="ghost" size="sm" />}>...</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await testNodeConnection(node.id);
|
||||
if (res.success) toast.success(res.message);
|
||||
else toast.error(res.message);
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "测试失败"));
|
||||
}
|
||||
}}
|
||||
>
|
||||
测试并同步入站
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleGenerateToken}>
|
||||
{hasToken ? "重新生成探测 Token" : "生成探测 Token"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{hasToken && (
|
||||
<ConfirmActionButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
title="撤销这个探测 Token?"
|
||||
description="撤销后,延迟和线路探测程序将无法继续上报数据。"
|
||||
confirmLabel="撤销 Token"
|
||||
successMessage="探测 Token 已撤销"
|
||||
errorMessage="撤销失败"
|
||||
onConfirm={() => revokeAgentToken(node.id)}
|
||||
>
|
||||
撤销 Token
|
||||
</ConfirmActionButton>
|
||||
)}
|
||||
|
||||
<ConfirmActionButton
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
title="删除这个节点?"
|
||||
description="节点、线路入口和相关探测数据会被清理。请确认没有套餐仍依赖它。"
|
||||
confirmLabel="删除节点"
|
||||
successMessage="节点已删除"
|
||||
errorMessage="删除失败"
|
||||
onConfirm={() => deleteNode(node.id)}
|
||||
>
|
||||
删除
|
||||
</ConfirmActionButton>
|
||||
|
||||
<Dialog open={tokenDialogOpen} onOpenChange={setTokenDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<div className="inline-flex w-fit items-center gap-2 rounded-full border border-primary/15 bg-primary/10 px-3 py-1 text-xs font-semibold tracking-[0.14em] text-primary">
|
||||
<KeyRound className="size-3.5" /> PROBE TOKEN
|
||||
</div>
|
||||
<DialogTitle>探测 Token — {node.name}</DialogTitle>
|
||||
<DialogDescription>请立即复制 Token 或一键安装命令,关闭后将无法再次查看。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-semibold text-muted-foreground">探测 Token</div>
|
||||
<div className="rounded-lg border border-border bg-muted/30 p-3">
|
||||
<code className="block w-full select-all break-all font-mono text-xs text-foreground">
|
||||
{plainToken}
|
||||
</code>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(plainToken);
|
||||
toast.success("Token 已复制");
|
||||
}}
|
||||
>
|
||||
复制 Token
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="inline-flex items-center gap-2 text-xs font-semibold text-muted-foreground">
|
||||
<Terminal className="size-3.5" /> 一键安装探测 Agent
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-muted/30 p-3">
|
||||
<code className="block w-full select-all break-all font-mono text-xs text-foreground">
|
||||
{installCommand}
|
||||
</code>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(installCommand);
|
||||
toast.success("安装命令已复制");
|
||||
}}
|
||||
>
|
||||
复制一键安装命令
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!siteUrl && (
|
||||
<p className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs leading-5 text-amber-700 dark:text-amber-200">
|
||||
建议先到系统设置填写站点域名,否则从本地地址打开后台时命令会带本机地址。
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
此 Agent 仅用于 `/api/agent/latency` 和 `/api/agent/trace` 探测上报;节点客户端开通已改由 3x-ui 面板 API 处理。
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
108
src/app/(admin)/admin/nodes/node-form.tsx
Normal file
108
src/app/(admin)/admin/nodes/node-form.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { createNode, updateNode } from "@/actions/admin/nodes";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface NodeFormValue {
|
||||
id: string;
|
||||
name: string;
|
||||
panelUrl: string | null;
|
||||
panelUsername: string | null;
|
||||
panelPassword: string | null;
|
||||
}
|
||||
|
||||
export function NodeForm({
|
||||
node,
|
||||
triggerLabel,
|
||||
triggerVariant = "default",
|
||||
}: {
|
||||
node?: NodeFormValue;
|
||||
triggerLabel?: string;
|
||||
triggerVariant?: "default" | "outline" | "ghost";
|
||||
}) {
|
||||
const isEdit = Boolean(node);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
async function handleCreate(formData: FormData) {
|
||||
try {
|
||||
const result = await createNode(formData);
|
||||
if (result.success) toast.success(result.message);
|
||||
else toast.warning(result.message);
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "创建失败"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEdit(formData: FormData) {
|
||||
try {
|
||||
const result = await updateNode(node!.id, formData);
|
||||
if (result.success) toast.success(result.message);
|
||||
else toast.warning(result.message);
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "更新失败"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger
|
||||
render={<Button variant={triggerVariant} size={isEdit ? "sm" : "default"} />}
|
||||
>
|
||||
{triggerLabel || (isEdit ? "编辑" : "添加节点")}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? "编辑 3x-ui 节点" : "添加 3x-ui 节点"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
保存后会登录 3x-ui 并同步面板中的入站线路;入站请在 3x-ui 面板内维护。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form action={isEdit ? handleEdit : handleCreate} className="form-panel space-y-5">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label>节点名称</Label>
|
||||
<Input name="name" defaultValue={node?.name ?? ""} placeholder="如 HK-01" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>3x-ui 面板地址</Label>
|
||||
<Input name="panelUrl" defaultValue={node?.panelUrl ?? ""} placeholder="http://1.2.3.4:2053" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label>面板用户名</Label>
|
||||
<Input name="panelUsername" defaultValue={node?.panelUsername ?? ""} required />
|
||||
</div>
|
||||
<div>
|
||||
<Label>面板密码</Label>
|
||||
<Input name="panelPassword" type="password" defaultValue={node?.panelPassword ?? ""} required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
延迟和线路探测仍使用探测 Token;节点开通、暂停、删除客户端均回归 3x-ui 面板 API。
|
||||
</p>
|
||||
<Button type="submit" size="lg" className="w-full">
|
||||
{isEdit ? "保存并同步入站" : "创建并同步入站"}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
57
src/app/(admin)/admin/nodes/nodes-data.ts
Normal file
57
src/app/(admin)/admin/nodes/nodes-data.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { parsePage } from "@/lib/utils";
|
||||
import { getConfiguredSiteUrl } from "@/services/site-url";
|
||||
|
||||
const nodeInclude = {
|
||||
_count: { select: { inbounds: true } },
|
||||
inbounds: {
|
||||
where: { isActive: true },
|
||||
select: {
|
||||
id: true,
|
||||
protocol: true,
|
||||
port: true,
|
||||
tag: true,
|
||||
settings: true,
|
||||
},
|
||||
orderBy: { updatedAt: "desc" },
|
||||
},
|
||||
} satisfies Prisma.NodeServerInclude;
|
||||
|
||||
export type NodeServerRow = Prisma.NodeServerGetPayload<{
|
||||
include: typeof nodeInclude;
|
||||
}>;
|
||||
|
||||
export async function getNodeServers(
|
||||
searchParams: Record<string, string | string[] | undefined>,
|
||||
) {
|
||||
const { page, skip, pageSize } = parsePage(searchParams);
|
||||
const q = typeof searchParams.q === "string" ? searchParams.q.trim() : "";
|
||||
const status = typeof searchParams.status === "string" ? searchParams.status : "";
|
||||
|
||||
const where = {
|
||||
...(status ? { status } : {}),
|
||||
...(q
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: q, mode: "insensitive" as const } },
|
||||
{ panelUrl: { contains: q, mode: "insensitive" as const } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
} satisfies Prisma.NodeServerWhereInput;
|
||||
|
||||
const [nodes, total, siteUrl] = await Promise.all([
|
||||
prisma.nodeServer.findMany({
|
||||
where,
|
||||
include: nodeInclude,
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.nodeServer.count({ where }),
|
||||
getConfiguredSiteUrl(),
|
||||
]);
|
||||
|
||||
return { nodes, total, page, pageSize, filters: { q, status }, siteUrl };
|
||||
}
|
||||
47
src/app/(admin)/admin/nodes/page.tsx
Normal file
47
src/app/(admin)/admin/nodes/page.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { Metadata } from "next";
|
||||
import { AdminFilterBar } from "@/components/admin/filter-bar";
|
||||
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||
import { Pagination } from "@/components/shared/pagination";
|
||||
import { NodeForm } from "./node-form";
|
||||
import { NodeCardList } from "./_components/node-card-list";
|
||||
import { getNodeServers } from "./nodes-data";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "节点管理",
|
||||
description: "维护节点面板连接与可售入站配置。",
|
||||
};
|
||||
|
||||
export default async function NodesPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const { nodes, total, page, pageSize, filters, siteUrl } = await getNodeServers(await searchParams);
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
eyebrow="基础设施"
|
||||
title="节点管理"
|
||||
actions={<NodeForm />}
|
||||
/>
|
||||
<AdminFilterBar
|
||||
q={filters.q}
|
||||
searchPlaceholder="搜索节点名、主机或面板地址"
|
||||
selects={[
|
||||
{
|
||||
name: "status",
|
||||
value: filters.status,
|
||||
options: [
|
||||
{ label: "全部状态", value: "" },
|
||||
{ label: "active", value: "active" },
|
||||
{ label: "inactive", value: "inactive" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<NodeCardList nodes={nodes} siteUrl={siteUrl} />
|
||||
<Pagination total={total} pageSize={pageSize} page={page} />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
22
src/app/(admin)/admin/not-found.tsx
Normal file
22
src/app/(admin)/admin/not-found.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import Link from "next/link";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
|
||||
export default function AdminNotFound() {
|
||||
return (
|
||||
<section className="surface-card mx-auto w-full max-w-md space-y-4 rounded-xl p-6 text-center">
|
||||
<p className="text-xs font-medium tracking-wide text-primary">404</p>
|
||||
<h1 className="text-display text-2xl font-semibold">目标数据不存在</h1>
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
记录可能已被删除,或当前管理账号没有读取权限。
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-2.5">
|
||||
<Link href="/admin/dashboard" className={buttonVariants({ size: "lg" })}>
|
||||
返回仪表盘
|
||||
</Link>
|
||||
<Link href="/admin/support" className={buttonVariants({ variant: "outline", size: "lg" })}>
|
||||
返回工单列表
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
128
src/app/(admin)/admin/orders/_components/orders-table.tsx
Normal file
128
src/app/(admin)/admin/orders/_components/orders-table.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { batchOrderOperation } from "@/actions/admin/orders";
|
||||
import { BatchActionBar, BatchActionButton } from "@/components/admin/batch-action-bar";
|
||||
import { DataTableShell } from "@/components/admin/data-table-shell";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableBody,
|
||||
DataTableCell,
|
||||
DataTableHead,
|
||||
DataTableHeadCell,
|
||||
DataTableHeaderRow,
|
||||
DataTableRow,
|
||||
} from "@/components/shared/data-table";
|
||||
import {
|
||||
OrderReviewStatusBadge,
|
||||
OrderStatusBadge,
|
||||
orderKindLabels,
|
||||
} from "@/components/shared/domain-badges";
|
||||
import { formatDateShort } from "@/lib/utils";
|
||||
import { OrderActions } from "../order-actions";
|
||||
import { OrderReviewActions } from "../order-review-actions";
|
||||
import type { AdminOrderRow } from "../orders-data";
|
||||
|
||||
interface OrdersTableProps {
|
||||
orders: AdminOrderRow[];
|
||||
}
|
||||
|
||||
function formatOrderAmount(amount: { toString(): string }) {
|
||||
return `¥${Number(amount).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatOrderTraffic(trafficGb: number | null) {
|
||||
return trafficGb === null ? "—" : `${trafficGb} GB`;
|
||||
}
|
||||
|
||||
export function OrdersTable({ orders }: OrdersTableProps) {
|
||||
return (
|
||||
<DataTableShell
|
||||
isEmpty={orders.length === 0}
|
||||
emptyTitle="暂无订单"
|
||||
emptyDescription="用户创建订单后,支付和审查状态会出现在这里。"
|
||||
toolbar={
|
||||
<BatchActionBar
|
||||
id="order-batch-form"
|
||||
action={batchOrderOperation}
|
||||
className="rounded-none bg-transparent"
|
||||
>
|
||||
<BatchActionButton value="confirm">批量确认</BatchActionButton>
|
||||
<BatchActionButton value="cancel" destructive>
|
||||
批量取消
|
||||
</BatchActionButton>
|
||||
</BatchActionBar>
|
||||
}
|
||||
>
|
||||
<DataTable aria-label="订单列表" className="min-w-[1180px]">
|
||||
<DataTableHead>
|
||||
<DataTableHeaderRow>
|
||||
<DataTableHeadCell>选择</DataTableHeadCell>
|
||||
<DataTableHeadCell>用户</DataTableHeadCell>
|
||||
<DataTableHeadCell>套餐</DataTableHeadCell>
|
||||
<DataTableHeadCell>类型</DataTableHeadCell>
|
||||
<DataTableHeadCell>金额</DataTableHeadCell>
|
||||
<DataTableHeadCell>流量</DataTableHeadCell>
|
||||
<DataTableHeadCell>支付</DataTableHeadCell>
|
||||
<DataTableHeadCell>状态</DataTableHeadCell>
|
||||
<DataTableHeadCell>审查</DataTableHeadCell>
|
||||
<DataTableHeadCell>备注</DataTableHeadCell>
|
||||
<DataTableHeadCell>时间</DataTableHeadCell>
|
||||
<DataTableHeadCell className="text-right">操作</DataTableHeadCell>
|
||||
</DataTableHeaderRow>
|
||||
</DataTableHead>
|
||||
<DataTableBody>
|
||||
{orders.map((order) => (
|
||||
<DataTableRow key={order.id}>
|
||||
<DataTableCell>
|
||||
<input
|
||||
form="order-batch-form"
|
||||
type="checkbox"
|
||||
name="orderIds"
|
||||
value={order.id}
|
||||
aria-label={`选择订单 ${order.id}`}
|
||||
/>
|
||||
</DataTableCell>
|
||||
<DataTableCell className="max-w-56 whitespace-normal break-all">
|
||||
<p className="font-medium">{order.user.email}</p>
|
||||
<p className="text-xs text-muted-foreground">{order.user.name || "未设置昵称"}</p>
|
||||
</DataTableCell>
|
||||
<DataTableCell className="max-w-52 whitespace-normal break-words font-medium">{order.plan.name}</DataTableCell>
|
||||
<DataTableCell className="text-muted-foreground">{orderKindLabels[order.kind]}</DataTableCell>
|
||||
<DataTableCell className="tabular-nums">{formatOrderAmount(order.amount)}</DataTableCell>
|
||||
<DataTableCell className="text-muted-foreground">{formatOrderTraffic(order.trafficGb)}</DataTableCell>
|
||||
<DataTableCell>
|
||||
<div className="space-y-1">
|
||||
<p>{order.paymentMethod || "—"}</p>
|
||||
<p className="max-w-48 break-all text-xs text-muted-foreground">
|
||||
{order.tradeNo || "—"}
|
||||
</p>
|
||||
</div>
|
||||
</DataTableCell>
|
||||
<DataTableCell>
|
||||
<OrderStatusBadge status={order.status} />
|
||||
</DataTableCell>
|
||||
<DataTableCell>
|
||||
<div className="space-y-2">
|
||||
<OrderReviewStatusBadge status={order.reviewStatus} />
|
||||
<OrderReviewActions orderId={order.id} reviewStatus={order.reviewStatus} />
|
||||
</div>
|
||||
</DataTableCell>
|
||||
<DataTableCell className="max-w-64 text-xs text-muted-foreground">
|
||||
<div className="space-y-1 whitespace-pre-wrap break-words">
|
||||
<p>{order.note || "—"}</p>
|
||||
{order.reviewNote && <p>审查备注:{order.reviewNote}</p>}
|
||||
</div>
|
||||
</DataTableCell>
|
||||
<DataTableCell className="whitespace-nowrap text-muted-foreground">
|
||||
{formatDateShort(order.createdAt)}
|
||||
</DataTableCell>
|
||||
<DataTableCell>
|
||||
<div className="flex justify-end">
|
||||
<OrderActions orderId={order.id} status={order.status} />
|
||||
</div>
|
||||
</DataTableCell>
|
||||
</DataTableRow>
|
||||
))}
|
||||
</DataTableBody>
|
||||
</DataTable>
|
||||
</DataTableShell>
|
||||
);
|
||||
}
|
||||
49
src/app/(admin)/admin/orders/order-actions.tsx
Normal file
49
src/app/(admin)/admin/orders/order-actions.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { confirmOrder, cancelOrder } from "@/actions/admin/orders";
|
||||
import { toast } from "sonner";
|
||||
type AdminOrderActionStatus = "PENDING" | "PAID" | "CANCELLED" | "REFUNDED";
|
||||
|
||||
export function OrderActions({
|
||||
orderId,
|
||||
status,
|
||||
}: {
|
||||
orderId: string;
|
||||
status: AdminOrderActionStatus;
|
||||
}) {
|
||||
if (status !== "PENDING") return null;
|
||||
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await confirmOrder(orderId);
|
||||
toast.success("订单已确认并已处理");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "确认失败"));
|
||||
}
|
||||
}}
|
||||
>
|
||||
确认
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await cancelOrder(orderId);
|
||||
toast.success("已取消");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "取消失败"));
|
||||
}
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
src/app/(admin)/admin/orders/order-review-actions.tsx
Normal file
48
src/app/(admin)/admin/orders/order-review-actions.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { updateOrderReview } from "@/actions/admin/orders";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function OrderReviewActions({
|
||||
orderId,
|
||||
reviewStatus,
|
||||
}: {
|
||||
orderId: string;
|
||||
reviewStatus: "NORMAL" | "FLAGGED" | "RESOLVED";
|
||||
}) {
|
||||
async function handle(status: "FLAGGED" | "RESOLVED" | "NORMAL") {
|
||||
const note =
|
||||
status === "NORMAL"
|
||||
? ""
|
||||
: prompt("请输入异常备注/处理备注(可留空)") ?? "";
|
||||
|
||||
try {
|
||||
await updateOrderReview(orderId, status, note);
|
||||
toast.success("订单审查状态已更新");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "更新失败"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{reviewStatus !== "FLAGGED" && (
|
||||
<Button size="sm" variant="outline" onClick={() => void handle("FLAGGED")}>
|
||||
标记异常
|
||||
</Button>
|
||||
)}
|
||||
{reviewStatus !== "RESOLVED" && (
|
||||
<Button size="sm" variant="outline" onClick={() => void handle("RESOLVED")}>
|
||||
标记解决
|
||||
</Button>
|
||||
)}
|
||||
{reviewStatus !== "NORMAL" && (
|
||||
<Button size="sm" variant="ghost" onClick={() => void handle("NORMAL")}>
|
||||
恢复正常
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
src/app/(admin)/admin/orders/orders-data.ts
Normal file
54
src/app/(admin)/admin/orders/orders-data.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { parsePage } from "@/lib/utils";
|
||||
|
||||
const adminOrderInclude = {
|
||||
user: true,
|
||||
plan: true,
|
||||
} satisfies Prisma.OrderInclude;
|
||||
|
||||
export type AdminOrderRow = Prisma.OrderGetPayload<{
|
||||
include: typeof adminOrderInclude;
|
||||
}>;
|
||||
|
||||
export async function getAdminOrders(
|
||||
searchParams: Record<string, string | string[] | undefined>,
|
||||
) {
|
||||
const { page, skip, pageSize } = parsePage(searchParams);
|
||||
const q = typeof searchParams.q === "string" ? searchParams.q.trim() : "";
|
||||
const status = typeof searchParams.status === "string" ? searchParams.status : "";
|
||||
const kind = typeof searchParams.kind === "string" ? searchParams.kind : "";
|
||||
const reviewStatus =
|
||||
typeof searchParams.reviewStatus === "string" ? searchParams.reviewStatus : "";
|
||||
|
||||
const where = {
|
||||
...(status ? { status: status as "PENDING" | "PAID" | "CANCELLED" | "REFUNDED" } : {}),
|
||||
...(kind ? { kind: kind as "NEW_PURCHASE" | "RENEWAL" | "TRAFFIC_TOPUP" } : {}),
|
||||
...(reviewStatus
|
||||
? { reviewStatus: reviewStatus as "NORMAL" | "FLAGGED" | "RESOLVED" }
|
||||
: {}),
|
||||
...(q
|
||||
? {
|
||||
OR: [
|
||||
{ user: { email: { contains: q, mode: "insensitive" as const } } },
|
||||
{ user: { name: { contains: q, mode: "insensitive" as const } } },
|
||||
{ plan: { name: { contains: q, mode: "insensitive" as const } } },
|
||||
{ tradeNo: { contains: q, mode: "insensitive" as const } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
} satisfies Prisma.OrderWhereInput;
|
||||
|
||||
const [orders, total] = await Promise.all([
|
||||
prisma.order.findMany({
|
||||
where,
|
||||
include: adminOrderInclude,
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.order.count({ where }),
|
||||
]);
|
||||
|
||||
return { orders, total, page, pageSize, filters: { q, status, kind, reviewStatus } };
|
||||
}
|
||||
67
src/app/(admin)/admin/orders/page.tsx
Normal file
67
src/app/(admin)/admin/orders/page.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { Metadata } from "next";
|
||||
import { AdminFilterBar } from "@/components/admin/filter-bar";
|
||||
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||
import { Pagination } from "@/components/shared/pagination";
|
||||
import { OrdersTable } from "./_components/orders-table";
|
||||
import { getAdminOrders } from "./orders-data";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "订单管理",
|
||||
description: "跟踪订单状态、审查结果与支付记录。",
|
||||
};
|
||||
|
||||
export default async function OrdersPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const { orders, total, page, pageSize, filters } = await getAdminOrders(await searchParams);
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
eyebrow="商品与订单"
|
||||
title="订单管理"
|
||||
/>
|
||||
<AdminFilterBar
|
||||
q={filters.q}
|
||||
searchPlaceholder="搜索邮箱、套餐、交易号"
|
||||
selects={[
|
||||
{
|
||||
name: "status",
|
||||
value: filters.status,
|
||||
options: [
|
||||
{ label: "全部状态", value: "" },
|
||||
{ label: "待确认", value: "PENDING" },
|
||||
{ label: "已支付", value: "PAID" },
|
||||
{ label: "已取消", value: "CANCELLED" },
|
||||
{ label: "已退款", value: "REFUNDED" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "kind",
|
||||
value: filters.kind,
|
||||
options: [
|
||||
{ label: "全部类型", value: "" },
|
||||
{ label: "新购", value: "NEW_PURCHASE" },
|
||||
{ label: "续费", value: "RENEWAL" },
|
||||
{ label: "增流量", value: "TRAFFIC_TOPUP" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "reviewStatus",
|
||||
value: filters.reviewStatus,
|
||||
options: [
|
||||
{ label: "全部审查", value: "" },
|
||||
{ label: "正常", value: "NORMAL" },
|
||||
{ label: "异常", value: "FLAGGED" },
|
||||
{ label: "已解决", value: "RESOLVED" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<OrdersTable orders={orders} />
|
||||
<Pagination total={total} pageSize={pageSize} page={page} />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
11
src/app/(admin)/admin/page.tsx
Normal file
11
src/app/(admin)/admin/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "管理后台",
|
||||
description: "管理后台入口页。",
|
||||
};
|
||||
|
||||
export default function AdminIndexPage() {
|
||||
redirect("/admin/dashboard");
|
||||
}
|
||||
120
src/app/(admin)/admin/payments/config-form.tsx
Normal file
120
src/app/(admin)/admin/payments/config-form.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { savePaymentConfig } from "@/actions/admin/payments";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Field {
|
||||
key: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
secret?: boolean;
|
||||
type?: "text" | "checkboxes";
|
||||
options?: { value: string; label: string }[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
provider: string;
|
||||
fields: Field[];
|
||||
currentConfig?: Record<string, string>;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export function PaymentConfigForm({ provider, fields, currentConfig, enabled: initialEnabled }: Props) {
|
||||
const [enabled, setEnabled] = useState(initialEnabled);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Track checkbox field values (comma-separated strings)
|
||||
const [checkboxValues, setCheckboxValues] = useState<Record<string, Set<string>>>(() => {
|
||||
const init: Record<string, Set<string>> = {};
|
||||
for (const field of fields) {
|
||||
if (field.type === "checkboxes") {
|
||||
const raw = currentConfig?.[field.key] || "";
|
||||
init[field.key] = new Set(raw.split(",").map((s) => s.trim()).filter(Boolean));
|
||||
}
|
||||
}
|
||||
return init;
|
||||
});
|
||||
|
||||
function toggleCheckbox(fieldKey: string, value: string) {
|
||||
setCheckboxValues((prev) => {
|
||||
const next = new Set(prev[fieldKey]);
|
||||
if (next.has(value)) next.delete(value);
|
||||
else next.add(value);
|
||||
return { ...prev, [fieldKey]: next };
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const config: Record<string, string> = {};
|
||||
for (const field of fields) {
|
||||
if (field.type === "checkboxes") {
|
||||
config[field.key] = Array.from(checkboxValues[field.key] ?? []).join(",");
|
||||
} else {
|
||||
config[field.key] = (formData.get(field.key) as string) || "";
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await savePaymentConfig(provider, config, enabled);
|
||||
toast.success("保存成功");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "保存失败"));
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="form-panel space-y-5">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{fields.map((field) =>
|
||||
field.type === "checkboxes" ? (
|
||||
<div key={field.key} className="sm:col-span-2">
|
||||
<Label>{field.label}</Label>
|
||||
<div className="mt-3 flex flex-wrap gap-3">
|
||||
{field.options?.map((opt) => (
|
||||
<label key={opt.value} className="choice-card flex cursor-pointer items-center gap-2 px-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 rounded border-border accent-primary"
|
||||
checked={checkboxValues[field.key]?.has(opt.value) ?? false}
|
||||
onChange={() => toggleCheckbox(field.key, opt.value)}
|
||||
/>
|
||||
<span className="text-sm">{opt.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div key={field.key}>
|
||||
<Label>{field.label}</Label>
|
||||
<Input
|
||||
name={field.key}
|
||||
type={field.secret ? "password" : "text"}
|
||||
placeholder={field.placeholder}
|
||||
defaultValue={currentConfig?.[field.key] || ""}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 border-t border-border/50 pt-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
<span className="text-sm">{enabled ? "已启用" : "未启用"}</span>
|
||||
</div>
|
||||
<Button type="submit" size="sm" disabled={saving}>
|
||||
{saving ? "保存中..." : "保存配置"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
48
src/app/(admin)/admin/payments/page.tsx
Normal file
48
src/app/(admin)/admin/payments/page.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { Metadata } from "next";
|
||||
import { CreditCard } from "lucide-react";
|
||||
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||
import { ActiveStatusBadge } from "@/components/shared/status-badge";
|
||||
import { PaymentConfigForm } from "./config-form";
|
||||
import { getPaymentProviderConfigs } from "./payments-data";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "支付配置",
|
||||
description: "配置支付渠道、密钥与启用状态。",
|
||||
};
|
||||
|
||||
export default async function PaymentsPage() {
|
||||
const providerConfigs = await getPaymentProviderConfigs();
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
eyebrow="系统"
|
||||
title="支付配置"
|
||||
/>
|
||||
<div className="grid gap-5">
|
||||
{providerConfigs.map(({ provider, config }) => (
|
||||
<section key={provider.id} className="surface-card overflow-hidden rounded-xl p-4">
|
||||
<div className="mb-4 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<CreditCard className="size-4" />
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold tracking-tight">{provider.name}</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground text-pretty">{provider.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ActiveStatusBadge active={config?.enabled ?? false} activeLabel="已启用" inactiveLabel="未启用" />
|
||||
</div>
|
||||
<PaymentConfigForm
|
||||
provider={provider.id}
|
||||
fields={provider.fields}
|
||||
currentConfig={config?.config as Record<string, string> | undefined}
|
||||
enabled={config?.enabled ?? false}
|
||||
/>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
12
src/app/(admin)/admin/payments/payments-data.ts
Normal file
12
src/app/(admin)/admin/payments/payments-data.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { PAYMENT_PROVIDER_DEFINITIONS } from "@/services/payment/catalog";
|
||||
|
||||
export async function getPaymentProviderConfigs() {
|
||||
const configs = await prisma.paymentConfig.findMany();
|
||||
const configMap = new Map(configs.map((config) => [config.provider, config]));
|
||||
|
||||
return PAYMENT_PROVIDER_DEFINITIONS.map((provider) => ({
|
||||
provider,
|
||||
config: configMap.get(provider.id),
|
||||
}));
|
||||
}
|
||||
48
src/app/(admin)/admin/plans/_components/plans-list.tsx
Normal file
48
src/app/(admin)/admin/plans/_components/plans-list.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { batchPlanOperation } from "@/actions/admin/plans";
|
||||
import { BatchActionBar, BatchActionButton } from "@/components/admin/batch-action-bar";
|
||||
import { EmptyState } from "@/components/shared/page-shell";
|
||||
import { PlanCard } from "../plan-card";
|
||||
import { PlanForm, type StreamingServiceOption } from "../plan-form";
|
||||
import type { AdminPlanRow } from "../plans-data";
|
||||
|
||||
export const PLAN_BATCH_FORM_ID = "plan-batch-form";
|
||||
|
||||
export function PlansList({
|
||||
plans,
|
||||
activeCountMap,
|
||||
services,
|
||||
}: {
|
||||
plans: AdminPlanRow[];
|
||||
activeCountMap: Map<string, number>;
|
||||
services: StreamingServiceOption[];
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<BatchActionBar id={PLAN_BATCH_FORM_ID} action={batchPlanOperation}>
|
||||
<BatchActionButton value="enable">批量上架</BatchActionButton>
|
||||
<BatchActionButton value="disable">批量下架</BatchActionButton>
|
||||
<BatchActionButton value="delete" destructive>
|
||||
批量彻底删除
|
||||
</BatchActionButton>
|
||||
</BatchActionBar>
|
||||
<div className="grid gap-5">
|
||||
{plans.map((plan) => (
|
||||
<PlanCard
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
activeCount={activeCountMap.get(plan.id) ?? 0}
|
||||
services={services}
|
||||
batchFormId={PLAN_BATCH_FORM_ID}
|
||||
/>
|
||||
))}
|
||||
{plans.length === 0 && (
|
||||
<EmptyState
|
||||
title="暂无套餐"
|
||||
description="创建第一个套餐后,用户就可以在商店中购买。"
|
||||
action={<PlanForm services={services} triggerLabel="创建套餐" />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
68
src/app/(admin)/admin/plans/page.tsx
Normal file
68
src/app/(admin)/admin/plans/page.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { Metadata } from "next";
|
||||
import { AdminFilterBar } from "@/components/admin/filter-bar";
|
||||
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||
import { Pagination } from "@/components/shared/pagination";
|
||||
import { PlanForm } from "./plan-form";
|
||||
import { PlansList } from "./_components/plans-list";
|
||||
import { getAdminPlans } from "./plans-data";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "套餐管理",
|
||||
description: "管理代理与流媒体套餐配置及上架状态。",
|
||||
};
|
||||
|
||||
export default async function PlansPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const {
|
||||
plans,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
filters,
|
||||
activeCountMap,
|
||||
serviceOptions,
|
||||
} = await getAdminPlans(await searchParams);
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
eyebrow="商品与订单"
|
||||
title="套餐管理"
|
||||
actions={<PlanForm services={serviceOptions} />}
|
||||
/>
|
||||
<AdminFilterBar
|
||||
q={filters.q}
|
||||
searchPlaceholder="搜索套餐名或描述"
|
||||
selects={[
|
||||
{
|
||||
name: "type",
|
||||
value: filters.type,
|
||||
options: [
|
||||
{ label: "全部类型", value: "" },
|
||||
{ label: "代理套餐", value: "PROXY" },
|
||||
{ label: "流媒体套餐", value: "STREAMING" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
value: filters.status,
|
||||
options: [
|
||||
{ label: "全部状态", value: "" },
|
||||
{ label: "上架中", value: "active" },
|
||||
{ label: "已下架", value: "inactive" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<PlansList
|
||||
plans={plans}
|
||||
activeCountMap={activeCountMap}
|
||||
services={serviceOptions}
|
||||
/>
|
||||
<Pagination total={total} pageSize={pageSize} page={page} />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
64
src/app/(admin)/admin/plans/plan-actions.tsx
Normal file
64
src/app/(admin)/admin/plans/plan-actions.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
|
||||
import { deletePlanPermanently, togglePlan } from "@/actions/admin/plans";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
PlanForm,
|
||||
type PlanFormValue,
|
||||
type StreamingServiceOption,
|
||||
} from "./plan-form";
|
||||
|
||||
export function PlanActions({
|
||||
plan,
|
||||
isActive,
|
||||
services,
|
||||
}: {
|
||||
plan: PlanFormValue;
|
||||
isActive: boolean;
|
||||
services: StreamingServiceOption[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<PlanForm
|
||||
plan={plan}
|
||||
services={services}
|
||||
triggerLabel="编辑"
|
||||
triggerVariant="outline"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await togglePlan(plan.id, !isActive);
|
||||
toast.success(isActive ? "套餐已下架" : "套餐已上架");
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "切换失败";
|
||||
toast.error(message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isActive ? "下架" : "上架"}
|
||||
</Button>
|
||||
<ConfirmActionButton
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
title="彻底删除套餐?"
|
||||
description="关联订阅、本地订单记录和可同步的独占入口会一起处理。此操作无法恢复。"
|
||||
confirmLabel="删除套餐"
|
||||
successMessage="套餐已删除"
|
||||
errorMessage="删除失败"
|
||||
onConfirm={() => deletePlanPermanently(plan.id)}
|
||||
onSuccess={() => router.refresh()}
|
||||
>
|
||||
删除
|
||||
</ConfirmActionButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
166
src/app/(admin)/admin/plans/plan-basics-section.tsx
Normal file
166
src/app/(admin)/admin/plans/plan-basics-section.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type {
|
||||
InboundOption,
|
||||
PlanFormValue,
|
||||
PlanType,
|
||||
StreamingServiceOption,
|
||||
} from "./plan-form-types";
|
||||
|
||||
type FieldId = (name: string) => string;
|
||||
|
||||
interface PlanBasicsFieldsProps {
|
||||
fieldId: FieldId;
|
||||
isEdit: boolean;
|
||||
type: PlanType;
|
||||
setType: Dispatch<SetStateAction<PlanType>>;
|
||||
plan?: PlanFormValue;
|
||||
services: StreamingServiceOption[];
|
||||
streamingServiceId: string;
|
||||
setStreamingServiceId: Dispatch<SetStateAction<string>>;
|
||||
hasStreamingServices: boolean;
|
||||
setInbounds: Dispatch<SetStateAction<InboundOption[]>>;
|
||||
setSelectedInboundIds: Dispatch<SetStateAction<string[]>>;
|
||||
setAllowTrafficTopup: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export function PlanBasicsFields({
|
||||
fieldId,
|
||||
isEdit,
|
||||
type,
|
||||
setType,
|
||||
plan,
|
||||
services,
|
||||
streamingServiceId,
|
||||
setStreamingServiceId,
|
||||
hasStreamingServices,
|
||||
setInbounds,
|
||||
setSelectedInboundIds,
|
||||
setAllowTrafficTopup,
|
||||
}: PlanBasicsFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor={fieldId("type")}>套餐类型</Label>
|
||||
{isEdit ? (
|
||||
<div id={fieldId("type")} className="premium-input flex h-11 items-center px-3 text-sm font-medium">
|
||||
{type === "PROXY" ? "代理节点套餐" : "流媒体套餐"}
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={type}
|
||||
onValueChange={(value) => {
|
||||
const nextType = value as PlanType;
|
||||
setType(nextType);
|
||||
if (nextType !== "PROXY") {
|
||||
setInbounds([]);
|
||||
setSelectedInboundIds([]);
|
||||
setAllowTrafficTopup(false);
|
||||
if (!streamingServiceId && hasStreamingServices) {
|
||||
setStreamingServiceId(services[0].id);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id={fieldId("type")}>
|
||||
<SelectValue placeholder="选择类型">
|
||||
{(value) => value === "PROXY" ? "代理节点套餐" : "流媒体套餐"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="PROXY">代理节点套餐</SelectItem>
|
||||
<SelectItem value="STREAMING">流媒体套餐</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<Label htmlFor={fieldId("name")}>套餐名称</Label>
|
||||
<Input id={fieldId("name")} name="name" defaultValue={plan?.name ?? ""} required />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={fieldId("durationDays")}>有效期(天)</Label>
|
||||
<Input
|
||||
id={fieldId("durationDays")}
|
||||
name="durationDays"
|
||||
type="number"
|
||||
defaultValue={plan?.durationDays ?? 30}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={fieldId("sortOrder")}>排序</Label>
|
||||
<Input
|
||||
id={fieldId("sortOrder")}
|
||||
name="sortOrder"
|
||||
type="number"
|
||||
defaultValue={plan?.sortOrder ?? 100}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor={fieldId("description")}>用户说明</Label>
|
||||
<Textarea
|
||||
id={fieldId("description")}
|
||||
name="description"
|
||||
rows={2}
|
||||
defaultValue={plan?.description ?? ""}
|
||||
placeholder="适合的使用场景、交付方式与体验边界"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlanLimitsFields({
|
||||
fieldId,
|
||||
plan,
|
||||
}: {
|
||||
fieldId: FieldId;
|
||||
plan?: PlanFormValue;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor={fieldId("totalLimit")}>总库存</Label>
|
||||
<Input
|
||||
id={fieldId("totalLimit")}
|
||||
name="totalLimit"
|
||||
type="number"
|
||||
min={1}
|
||||
defaultValue={plan?.totalLimit ?? ""}
|
||||
placeholder="留空=不限量"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={fieldId("perUserLimit")}>每用户限购</Label>
|
||||
<Input
|
||||
id={fieldId("perUserLimit")}
|
||||
name="perUserLimit"
|
||||
type="number"
|
||||
min={1}
|
||||
defaultValue={plan?.perUserLimit ?? ""}
|
||||
placeholder="留空=不限购"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** @deprecated Use PlanBasicsFields + PlanLimitsFields instead */
|
||||
export const PlanBasicsSection = PlanBasicsFields;
|
||||
231
src/app/(admin)/admin/plans/plan-card.tsx
Normal file
231
src/app/(admin)/admin/plans/plan-card.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import { Network, Tv } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ActiveStatusBadge, StatusBadge } from "@/components/admin/status-badge";
|
||||
import { DetailItem, DetailList } from "@/components/admin/detail-list";
|
||||
import {
|
||||
PlanFormValue,
|
||||
type StreamingServiceOption,
|
||||
} from "./plan-form";
|
||||
import { PlanActions } from "./plan-actions";
|
||||
|
||||
type NumericLike = number | { toString(): string } | null | undefined;
|
||||
|
||||
interface PlanListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "PROXY" | "STREAMING";
|
||||
description: string | null;
|
||||
durationDays: number;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
price: NumericLike;
|
||||
nodeId: string | null;
|
||||
inboundId: string | null;
|
||||
streamingServiceId: string | null;
|
||||
pricingMode: "TRAFFIC_SLIDER" | "FIXED_PACKAGE";
|
||||
fixedTrafficGb: number | null;
|
||||
fixedPrice: NumericLike;
|
||||
totalLimit: number | null;
|
||||
perUserLimit: number | null;
|
||||
totalTrafficGb: number | null;
|
||||
allowRenewal: boolean;
|
||||
allowTrafficTopup: boolean;
|
||||
renewalPrice: NumericLike;
|
||||
renewalPricingMode: string;
|
||||
renewalDurationDays: number | null;
|
||||
renewalMinDays: number | null;
|
||||
renewalMaxDays: number | null;
|
||||
renewalTrafficGb: number | null;
|
||||
topupPricingMode: string;
|
||||
topupPricePerGb: NumericLike;
|
||||
topupFixedPrice: NumericLike;
|
||||
minTopupGb: number | null;
|
||||
maxTopupGb: number | null;
|
||||
pricePerGb: NumericLike;
|
||||
minTrafficGb: number | null;
|
||||
maxTrafficGb: number | null;
|
||||
node: { name: string } | null;
|
||||
inbound: { protocol: string; port: number; tag: string } | null;
|
||||
streamingService: { name: string; usedSlots: number; maxSlots: number } | null;
|
||||
inboundOptions: Array<{
|
||||
inboundId: string;
|
||||
inbound: { protocol: string; port: number; tag: string };
|
||||
}>;
|
||||
_count: { subscriptions: number };
|
||||
}
|
||||
|
||||
interface PlanCardProps {
|
||||
plan: PlanListItem;
|
||||
activeCount: number;
|
||||
services: StreamingServiceOption[];
|
||||
batchFormId: string;
|
||||
}
|
||||
|
||||
function toNumber(value: NumericLike): number | null {
|
||||
return value == null ? null : Number(value);
|
||||
}
|
||||
|
||||
function money(value: NumericLike): string {
|
||||
return `¥${Number(value ?? 0).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function renewalSummary(plan: PlanListItem) {
|
||||
if (!plan.allowRenewal) return "续费关闭";
|
||||
if (plan.renewalPricingMode === "PER_DAY") {
|
||||
return `${money(plan.renewalPrice)}/天 · ${plan.renewalMinDays ?? 1}-${plan.renewalMaxDays ?? plan.durationDays} 天`;
|
||||
}
|
||||
return `${money(plan.renewalPrice)} / ${plan.renewalDurationDays ?? plan.durationDays} 天`;
|
||||
}
|
||||
|
||||
function topupSummary(plan: PlanListItem) {
|
||||
if (!plan.allowTrafficTopup) return "增流量关闭";
|
||||
const range = plan.maxTopupGb == null
|
||||
? `最少 ${plan.minTopupGb ?? 1} GB`
|
||||
: `${plan.minTopupGb ?? 1}-${plan.maxTopupGb} GB`;
|
||||
if (plan.topupPricingMode === "FIXED_AMOUNT") {
|
||||
return `${money(plan.topupFixedPrice)} 固定 · ${range}`;
|
||||
}
|
||||
return `${money(plan.topupPricePerGb)}/GB · ${range}`;
|
||||
}
|
||||
|
||||
function buildPlanFormValue(plan: PlanListItem): PlanFormValue {
|
||||
return {
|
||||
id: plan.id,
|
||||
name: plan.name,
|
||||
type: plan.type,
|
||||
description: plan.description,
|
||||
durationDays: plan.durationDays,
|
||||
sortOrder: plan.sortOrder,
|
||||
price: toNumber(plan.price),
|
||||
nodeId: plan.nodeId,
|
||||
inboundId: plan.inboundId,
|
||||
inboundOptionIds: plan.inboundOptions.map((option) => option.inboundId),
|
||||
streamingServiceId: plan.streamingServiceId,
|
||||
pricingMode: plan.pricingMode,
|
||||
fixedTrafficGb: plan.fixedTrafficGb,
|
||||
fixedPrice: toNumber(plan.fixedPrice),
|
||||
totalLimit: plan.totalLimit,
|
||||
perUserLimit: plan.perUserLimit,
|
||||
totalTrafficGb: plan.totalTrafficGb,
|
||||
allowRenewal: plan.allowRenewal,
|
||||
allowTrafficTopup: plan.allowTrafficTopup,
|
||||
renewalPrice: toNumber(plan.renewalPrice),
|
||||
renewalPricingMode: plan.renewalPricingMode === "PER_DAY" ? "PER_DAY" : "FIXED_DURATION",
|
||||
renewalDurationDays: plan.renewalDurationDays,
|
||||
renewalMinDays: plan.renewalMinDays,
|
||||
renewalMaxDays: plan.renewalMaxDays,
|
||||
renewalTrafficGb: plan.renewalTrafficGb,
|
||||
topupPricingMode: plan.topupPricingMode === "FIXED_AMOUNT" ? "FIXED_AMOUNT" : "PER_GB",
|
||||
topupPricePerGb: toNumber(plan.topupPricePerGb),
|
||||
topupFixedPrice: toNumber(plan.topupFixedPrice),
|
||||
minTopupGb: plan.minTopupGb,
|
||||
maxTopupGb: plan.maxTopupGb,
|
||||
pricePerGb: toNumber(plan.pricePerGb),
|
||||
minTrafficGb: plan.minTrafficGb,
|
||||
maxTrafficGb: plan.maxTrafficGb,
|
||||
};
|
||||
}
|
||||
|
||||
export function PlanCard({ plan, activeCount, services, batchFormId }: PlanCardProps) {
|
||||
const remaining = plan.totalLimit == null ? null : Math.max(0, plan.totalLimit - activeCount);
|
||||
const planFormValue = buildPlanFormValue(plan);
|
||||
const Icon = plan.type === "PROXY" ? Network : Tv;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="gap-4">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<input
|
||||
form={batchFormId}
|
||||
type="checkbox"
|
||||
name="planIds"
|
||||
value={plan.id}
|
||||
aria-label={`选择套餐 ${plan.name}`}
|
||||
className="mt-3 size-5 rounded-lg border-border accent-primary shadow-sm"
|
||||
/>
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
|
||||
<Icon className="size-5" />
|
||||
</span>
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<CardTitle className="text-lg text-balance">{plan.name}</CardTitle>
|
||||
<p className="text-sm leading-6 text-muted-foreground text-pretty">
|
||||
{plan.description || "无描述"} · 总订阅 {plan._count.subscriptions}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<PlanActions
|
||||
isActive={plan.isActive}
|
||||
services={services}
|
||||
plan={planFormValue}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-border bg-muted/30 p-3">
|
||||
<StatusBadge tone={plan.type === "PROXY" ? "info" : "warning"}>
|
||||
{plan.type === "PROXY" ? "代理套餐" : "流媒体套餐"}
|
||||
</StatusBadge>
|
||||
<ActiveStatusBadge active={plan.isActive} activeLabel="上架中" inactiveLabel="已下架" />
|
||||
<StatusBadge>{plan.durationDays} 天</StatusBadge>
|
||||
<StatusBadge>
|
||||
{plan.type === "PROXY"
|
||||
? plan.pricingMode === "FIXED_PACKAGE"
|
||||
? `${money(plan.fixedPrice)} / ${plan.fixedTrafficGb ?? 0}GB`
|
||||
: `${money(plan.pricePerGb)}/GB`
|
||||
: money(plan.price)}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{plan.type === "PROXY" ? (
|
||||
<DetailList>
|
||||
<DetailItem label="节点">{plan.node?.name ?? "未绑定"}</DetailItem>
|
||||
<DetailItem label="入站">
|
||||
{plan.inboundOptions.length > 0
|
||||
? plan.inboundOptions
|
||||
.map((option) => `${option.inbound.protocol}:${option.inbound.port}`)
|
||||
.join(" / ")
|
||||
: plan.inbound
|
||||
? `${plan.inbound.protocol}:${plan.inbound.port}`
|
||||
: "未绑定"}
|
||||
</DetailItem>
|
||||
<DetailItem label="售卖方式">
|
||||
{plan.pricingMode === "FIXED_PACKAGE"
|
||||
? `固定 ${plan.fixedTrafficGb ?? 0} GB · ${money(plan.fixedPrice)}`
|
||||
: `自选 ${plan.minTrafficGb ?? 0}-${plan.maxTrafficGb ?? 0} GB`}
|
||||
</DetailItem>
|
||||
<DetailItem label="流量池">
|
||||
{plan.totalTrafficGb == null ? "未配置" : `${plan.totalTrafficGb} GB`}
|
||||
</DetailItem>
|
||||
<DetailItem label="库存">
|
||||
{plan.totalLimit == null
|
||||
? "不限量"
|
||||
: `${activeCount}/${plan.totalLimit}${remaining === 0 ? " (已满)" : ""}`}
|
||||
{plan.perUserLimit != null ? ` · 每人限 ${plan.perUserLimit}` : ""}
|
||||
</DetailItem>
|
||||
<DetailItem label="续费 / 增流量">
|
||||
{renewalSummary(plan)} / {topupSummary(plan)}
|
||||
</DetailItem>
|
||||
</DetailList>
|
||||
) : (
|
||||
<DetailList>
|
||||
<DetailItem label="绑定服务">{plan.streamingService?.name ?? "未绑定"}</DetailItem>
|
||||
<DetailItem label="服务占用">
|
||||
{plan.streamingService
|
||||
? `${plan.streamingService.usedSlots}/${plan.streamingService.maxSlots}`
|
||||
: "-"}
|
||||
</DetailItem>
|
||||
<DetailItem label="续费">
|
||||
{renewalSummary(plan)}
|
||||
</DetailItem>
|
||||
<DetailItem label="库存">
|
||||
{plan.totalLimit == null ? "不限量" : `${activeCount}/${plan.totalLimit}`}
|
||||
{plan.perUserLimit != null ? ` · 每人限 ${plan.perUserLimit}` : ""}
|
||||
</DetailItem>
|
||||
</DetailList>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
6
src/app/(admin)/admin/plans/plan-form-sections.tsx
Normal file
6
src/app/(admin)/admin/plans/plan-form-sections.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
"use client";
|
||||
|
||||
export { PlanBasicsFields, PlanLimitsFields, PlanBasicsSection } from "./plan-basics-section";
|
||||
export { PlanPolicySection } from "./plan-policy-section";
|
||||
export { ProxyNodeFields, ProxyPricingFields, ProxyConfigSection } from "./proxy-config-section";
|
||||
export { StreamingConfigSection } from "./streaming-config-section";
|
||||
57
src/app/(admin)/admin/plans/plan-form-types.ts
Normal file
57
src/app/(admin)/admin/plans/plan-form-types.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
export type PlanType = "STREAMING" | "PROXY";
|
||||
export type PlanPricingMode = "TRAFFIC_SLIDER" | "FIXED_PACKAGE";
|
||||
|
||||
export interface NodeOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface InboundOption {
|
||||
id: string;
|
||||
protocol: "VMESS" | "VLESS" | "TROJAN" | "SHADOWSOCKS" | "HYSTERIA2";
|
||||
port: number;
|
||||
tag: string;
|
||||
}
|
||||
|
||||
export interface PlanFormValue {
|
||||
id: string;
|
||||
name: string;
|
||||
type: PlanType;
|
||||
description: string | null;
|
||||
durationDays: number;
|
||||
sortOrder: number;
|
||||
price: number | null;
|
||||
nodeId: string | null;
|
||||
inboundId: string | null;
|
||||
inboundOptionIds: string[];
|
||||
streamingServiceId: string | null;
|
||||
pricingMode: PlanPricingMode;
|
||||
fixedTrafficGb: number | null;
|
||||
fixedPrice: number | null;
|
||||
totalLimit: number | null;
|
||||
perUserLimit: number | null;
|
||||
totalTrafficGb: number | null;
|
||||
allowRenewal: boolean;
|
||||
allowTrafficTopup: boolean;
|
||||
renewalPrice: number | null;
|
||||
renewalPricingMode: "PER_DAY" | "FIXED_DURATION";
|
||||
renewalDurationDays: number | null;
|
||||
renewalMinDays: number | null;
|
||||
renewalMaxDays: number | null;
|
||||
renewalTrafficGb: number | null;
|
||||
topupPricingMode: "PER_GB" | "FIXED_AMOUNT";
|
||||
topupPricePerGb: number | null;
|
||||
topupFixedPrice: number | null;
|
||||
minTopupGb: number | null;
|
||||
maxTopupGb: number | null;
|
||||
pricePerGb: number | null;
|
||||
minTrafficGb: number | null;
|
||||
maxTrafficGb: number | null;
|
||||
}
|
||||
|
||||
export interface StreamingServiceOption {
|
||||
id: string;
|
||||
name: string;
|
||||
usedSlots: number;
|
||||
maxSlots: number;
|
||||
}
|
||||
265
src/app/(admin)/admin/plans/plan-form.tsx
Normal file
265
src/app/(admin)/admin/plans/plan-form.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
"use client";
|
||||
|
||||
import type { FormEvent, ReactNode } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { createPlan, updatePlan } from "@/actions/admin/plans";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
PlanBasicsFields,
|
||||
PlanLimitsFields,
|
||||
PlanPolicySection,
|
||||
ProxyNodeFields,
|
||||
ProxyPricingFields,
|
||||
StreamingConfigSection,
|
||||
} from "./plan-form-sections";
|
||||
import type {
|
||||
PlanFormValue,
|
||||
StreamingServiceOption,
|
||||
} from "./plan-form-types";
|
||||
import { usePlanFormState } from "./use-plan-form-state";
|
||||
|
||||
export type { PlanFormValue, StreamingServiceOption } from "./plan-form-types";
|
||||
|
||||
function FormSection({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<fieldset className="space-y-4 rounded-lg border border-border bg-muted/20 p-4">
|
||||
<legend className="px-1.5 text-sm font-semibold">{title}</legend>
|
||||
{children}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlanForm({
|
||||
plan,
|
||||
services,
|
||||
triggerLabel,
|
||||
triggerVariant = "default",
|
||||
}: {
|
||||
plan?: PlanFormValue;
|
||||
services: StreamingServiceOption[];
|
||||
triggerLabel?: string;
|
||||
triggerVariant?: "default" | "outline" | "ghost";
|
||||
}) {
|
||||
const isEdit = Boolean(plan);
|
||||
const router = useRouter();
|
||||
const {
|
||||
open,
|
||||
handleOpenChange,
|
||||
title,
|
||||
fieldId,
|
||||
type,
|
||||
setType,
|
||||
nodeId,
|
||||
setNodeId,
|
||||
selectedInboundIds,
|
||||
setSelectedInboundIds,
|
||||
streamingServiceId,
|
||||
setStreamingServiceId,
|
||||
pricingMode,
|
||||
setPricingMode,
|
||||
allowRenewal,
|
||||
setAllowRenewal,
|
||||
allowTrafficTopup,
|
||||
setAllowTrafficTopup,
|
||||
renewalPricingMode,
|
||||
setRenewalPricingMode,
|
||||
topupPricingMode,
|
||||
setTopupPricingMode,
|
||||
submitting,
|
||||
startSubmitting,
|
||||
finishSubmitting,
|
||||
nodes,
|
||||
inbounds,
|
||||
setInbounds,
|
||||
hasStreamingServices,
|
||||
toggleInbound,
|
||||
} = usePlanFormState({ plan, services, isEdit });
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (submitting) return;
|
||||
|
||||
const formData = new FormData(event.currentTarget);
|
||||
formData.set("type", type);
|
||||
formData.set("allowRenewal", String(allowRenewal));
|
||||
formData.set("allowTrafficTopup", String(type === "PROXY" ? allowTrafficTopup : false));
|
||||
formData.set("pricingMode", type === "PROXY" ? pricingMode : "TRAFFIC_SLIDER");
|
||||
|
||||
if (!allowRenewal) {
|
||||
formData.delete("renewalPrice");
|
||||
formData.delete("renewalPricingMode");
|
||||
formData.delete("renewalDurationDays");
|
||||
formData.delete("renewalMinDays");
|
||||
formData.delete("renewalMaxDays");
|
||||
formData.delete("renewalTrafficGb");
|
||||
} else if (renewalPricingMode === "FIXED_DURATION") {
|
||||
formData.delete("renewalMinDays");
|
||||
formData.delete("renewalMaxDays");
|
||||
} else {
|
||||
formData.delete("renewalDurationDays");
|
||||
}
|
||||
if (type !== "PROXY" || !allowTrafficTopup) {
|
||||
formData.delete("topupPricingMode");
|
||||
formData.delete("topupPricePerGb");
|
||||
formData.delete("topupFixedPrice");
|
||||
formData.delete("minTopupGb");
|
||||
formData.delete("maxTopupGb");
|
||||
}
|
||||
|
||||
if (type === "PROXY") {
|
||||
if (!nodeId) {
|
||||
toast.error("请先选择节点");
|
||||
return;
|
||||
}
|
||||
if (selectedInboundIds.length === 0) {
|
||||
toast.error("请至少勾选一个可售入站");
|
||||
return;
|
||||
}
|
||||
|
||||
formData.set("nodeId", nodeId);
|
||||
formData.set("inboundId", selectedInboundIds[0]);
|
||||
formData.set("inboundIds", selectedInboundIds.join(","));
|
||||
formData.delete("streamingServiceId");
|
||||
} else {
|
||||
if (!streamingServiceId) {
|
||||
toast.error("请先选择流媒体服务");
|
||||
return;
|
||||
}
|
||||
formData.set("streamingServiceId", streamingServiceId);
|
||||
formData.delete("nodeId");
|
||||
formData.delete("inboundId");
|
||||
formData.delete("inboundIds");
|
||||
formData.delete("totalTrafficGb");
|
||||
formData.delete("topupPricingMode");
|
||||
formData.delete("topupPricePerGb");
|
||||
formData.delete("topupFixedPrice");
|
||||
formData.delete("minTopupGb");
|
||||
formData.delete("maxTopupGb");
|
||||
formData.delete("renewalTrafficGb");
|
||||
}
|
||||
|
||||
try {
|
||||
startSubmitting();
|
||||
if (isEdit) {
|
||||
await updatePlan(plan!.id, formData);
|
||||
} else {
|
||||
await createPlan(formData);
|
||||
}
|
||||
toast.success(isEdit ? "套餐更新成功" : "套餐创建成功");
|
||||
handleOpenChange(false);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, `${isEdit ? "更新" : "创建"}失败`));
|
||||
} finally {
|
||||
finishSubmitting();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger
|
||||
render={<Button variant={triggerVariant} size={isEdit ? "sm" : "default"} />}
|
||||
>
|
||||
{triggerLabel ?? (isEdit ? "编辑" : "创建套餐")}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={(event) => void handleSubmit(event)} className="grid gap-4 lg:grid-cols-2">
|
||||
{/* Left column: basics + resource config */}
|
||||
<div className="space-y-4">
|
||||
<FormSection title="基础信息">
|
||||
<PlanBasicsFields
|
||||
fieldId={fieldId}
|
||||
isEdit={isEdit}
|
||||
type={type}
|
||||
setType={setType}
|
||||
plan={plan}
|
||||
services={services}
|
||||
streamingServiceId={streamingServiceId}
|
||||
setStreamingServiceId={setStreamingServiceId}
|
||||
hasStreamingServices={hasStreamingServices}
|
||||
setInbounds={setInbounds}
|
||||
setSelectedInboundIds={setSelectedInboundIds}
|
||||
setAllowTrafficTopup={setAllowTrafficTopup}
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
{type === "PROXY" ? (
|
||||
<FormSection title="节点与线路">
|
||||
<ProxyNodeFields
|
||||
fieldId={fieldId}
|
||||
nodes={nodes}
|
||||
nodeId={nodeId}
|
||||
setNodeId={setNodeId}
|
||||
inbounds={inbounds}
|
||||
setInbounds={setInbounds}
|
||||
selectedInboundIds={selectedInboundIds}
|
||||
setSelectedInboundIds={setSelectedInboundIds}
|
||||
toggleInbound={toggleInbound}
|
||||
/>
|
||||
</FormSection>
|
||||
) : (
|
||||
<FormSection title="服务与定价">
|
||||
<StreamingConfigSection
|
||||
fieldId={fieldId}
|
||||
plan={plan}
|
||||
services={services}
|
||||
streamingServiceId={streamingServiceId}
|
||||
setStreamingServiceId={setStreamingServiceId}
|
||||
hasStreamingServices={hasStreamingServices}
|
||||
/>
|
||||
</FormSection>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right column: pricing (proxy only) + sales policy + submit */}
|
||||
<div className="space-y-4">
|
||||
{type === "PROXY" && (
|
||||
<FormSection title="定价">
|
||||
<ProxyPricingFields
|
||||
fieldId={fieldId}
|
||||
plan={plan}
|
||||
pricingMode={pricingMode}
|
||||
setPricingMode={setPricingMode}
|
||||
allowTrafficTopup={allowTrafficTopup}
|
||||
/>
|
||||
</FormSection>
|
||||
)}
|
||||
|
||||
<FormSection title="销售策略">
|
||||
<PlanLimitsFields fieldId={fieldId} plan={plan} />
|
||||
<PlanPolicySection
|
||||
fieldId={fieldId}
|
||||
type={type}
|
||||
plan={plan}
|
||||
allowRenewal={allowRenewal}
|
||||
setAllowRenewal={setAllowRenewal}
|
||||
allowTrafficTopup={allowTrafficTopup}
|
||||
setAllowTrafficTopup={setAllowTrafficTopup}
|
||||
renewalPricingMode={renewalPricingMode}
|
||||
setRenewalPricingMode={setRenewalPricingMode}
|
||||
topupPricingMode={topupPricingMode}
|
||||
setTopupPricingMode={setTopupPricingMode}
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
<Button type="submit" size="lg" className="w-full" disabled={submitting}>
|
||||
{submitting ? "提交中..." : (isEdit ? "保存套餐" : "创建套餐")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
224
src/app/(admin)/admin/plans/plan-policy-section.tsx
Normal file
224
src/app/(admin)/admin/plans/plan-policy-section.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
"use client";
|
||||
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { PlanFormValue, PlanType } from "./plan-form-types";
|
||||
|
||||
type FieldId = (name: string) => string;
|
||||
type RenewalPricingMode = "PER_DAY" | "FIXED_DURATION";
|
||||
type TopupPricingMode = "PER_GB" | "FIXED_AMOUNT";
|
||||
|
||||
interface PlanPolicySectionProps {
|
||||
fieldId: FieldId;
|
||||
type: PlanType;
|
||||
plan?: PlanFormValue;
|
||||
allowRenewal: boolean;
|
||||
setAllowRenewal: Dispatch<SetStateAction<boolean>>;
|
||||
allowTrafficTopup: boolean;
|
||||
setAllowTrafficTopup: Dispatch<SetStateAction<boolean>>;
|
||||
renewalPricingMode: RenewalPricingMode;
|
||||
setRenewalPricingMode: Dispatch<SetStateAction<RenewalPricingMode>>;
|
||||
topupPricingMode: TopupPricingMode;
|
||||
setTopupPricingMode: Dispatch<SetStateAction<TopupPricingMode>>;
|
||||
}
|
||||
|
||||
export function PlanPolicySection({
|
||||
fieldId,
|
||||
type,
|
||||
plan,
|
||||
allowRenewal,
|
||||
setAllowRenewal,
|
||||
allowTrafficTopup,
|
||||
setAllowTrafficTopup,
|
||||
renewalPricingMode,
|
||||
setRenewalPricingMode,
|
||||
topupPricingMode,
|
||||
setTopupPricingMode,
|
||||
}: PlanPolicySectionProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="form-panel grid gap-4 sm:grid-cols-2">
|
||||
<div className="flex items-center justify-between gap-4 rounded-lg bg-muted/20 p-3">
|
||||
<div>
|
||||
<p id={fieldId("allowRenewal-label")} className="text-sm font-medium">开放续费</p>
|
||||
<p className="text-xs text-muted-foreground">用户可拖动选择续费时长</p>
|
||||
</div>
|
||||
<Switch aria-labelledby={fieldId("allowRenewal-label")} checked={allowRenewal} onCheckedChange={setAllowRenewal} />
|
||||
</div>
|
||||
{type === "PROXY" && (
|
||||
<div className="flex items-center justify-between gap-4 rounded-lg bg-muted/20 p-3">
|
||||
<div>
|
||||
<p id={fieldId("allowTrafficTopup-label")} className="text-sm font-medium">开放增流量</p>
|
||||
<p className="text-xs text-muted-foreground">用户可拖动选择加多少 GB</p>
|
||||
</div>
|
||||
<Switch aria-labelledby={fieldId("allowTrafficTopup-label")} checked={allowTrafficTopup} onCheckedChange={setAllowTrafficTopup} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{allowRenewal && (
|
||||
<div className="space-y-3 rounded-xl border border-border bg-muted/20 p-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor={fieldId("renewalPricingMode")}>续费计价</Label>
|
||||
<input type="hidden" name="renewalPricingMode" value={renewalPricingMode} />
|
||||
<Select value={renewalPricingMode} onValueChange={(value) => setRenewalPricingMode(value as RenewalPricingMode)}>
|
||||
<SelectTrigger id={fieldId("renewalPricingMode")} className="w-full">
|
||||
<SelectValue>
|
||||
{(value) => value === "PER_DAY" ? "按天计费" : "固定周期计费"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="PER_DAY">按天计费</SelectItem>
|
||||
<SelectItem value="FIXED_DURATION">固定周期计费</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={fieldId("renewalPrice")}>
|
||||
{renewalPricingMode === "PER_DAY" ? "续费价格(¥/天)" : "续费价格(¥/周期)"}
|
||||
</Label>
|
||||
<Input
|
||||
id={fieldId("renewalPrice")}
|
||||
name="renewalPrice"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0.01}
|
||||
required
|
||||
defaultValue={plan?.renewalPrice ?? ""}
|
||||
placeholder={renewalPricingMode === "PER_DAY" ? "例如 1" : "例如 29.9"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{renewalPricingMode === "FIXED_DURATION" ? (
|
||||
<div>
|
||||
<Label htmlFor={fieldId("renewalDurationDays")}>周期天数</Label>
|
||||
<Input
|
||||
id={fieldId("renewalDurationDays")}
|
||||
name="renewalDurationDays"
|
||||
type="number"
|
||||
min={1}
|
||||
required
|
||||
defaultValue={plan?.renewalDurationDays ?? plan?.durationDays ?? ""}
|
||||
placeholder="例如 30"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor={fieldId("renewalMinDays")}>最小续费天数</Label>
|
||||
<Input
|
||||
id={fieldId("renewalMinDays")}
|
||||
name="renewalMinDays"
|
||||
type="number"
|
||||
min={1}
|
||||
defaultValue={plan?.renewalMinDays ?? ""}
|
||||
placeholder="例如 1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={fieldId("renewalMaxDays")}>最大续费天数</Label>
|
||||
<Input
|
||||
id={fieldId("renewalMaxDays")}
|
||||
name="renewalMaxDays"
|
||||
type="number"
|
||||
min={1}
|
||||
defaultValue={plan?.renewalMaxDays ?? ""}
|
||||
placeholder="例如 180"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type === "PROXY" && allowTrafficTopup && (
|
||||
<div className="space-y-3 rounded-xl border border-border bg-muted/20 p-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor={fieldId("topupPricingMode")}>增流量计价</Label>
|
||||
<input type="hidden" name="topupPricingMode" value={topupPricingMode} />
|
||||
<Select value={topupPricingMode} onValueChange={(value) => setTopupPricingMode(value as TopupPricingMode)}>
|
||||
<SelectTrigger id={fieldId("topupPricingMode")} className="w-full">
|
||||
<SelectValue>
|
||||
{(value) => value === "FIXED_AMOUNT" ? "固定金额" : "按 GB 计费"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="PER_GB">按 GB 计费</SelectItem>
|
||||
<SelectItem value="FIXED_AMOUNT">固定金额</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{topupPricingMode === "PER_GB" ? (
|
||||
<div>
|
||||
<Label htmlFor={fieldId("topupPricePerGb")}>加流量价格(¥/GB)</Label>
|
||||
<Input
|
||||
id={fieldId("topupPricePerGb")}
|
||||
name="topupPricePerGb"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0.01}
|
||||
required
|
||||
defaultValue={plan?.topupPricePerGb ?? ""}
|
||||
placeholder="例如 0.8"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Label htmlFor={fieldId("topupFixedPrice")}>固定加流量金额(¥)</Label>
|
||||
<Input
|
||||
id={fieldId("topupFixedPrice")}
|
||||
name="topupFixedPrice"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0.01}
|
||||
required
|
||||
defaultValue={plan?.topupFixedPrice ?? ""}
|
||||
placeholder="例如 9.9"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor={fieldId("minTopupGb")}>最小增流量(GB)</Label>
|
||||
<Input
|
||||
id={fieldId("minTopupGb")}
|
||||
name="minTopupGb"
|
||||
type="number"
|
||||
min={1}
|
||||
defaultValue={plan?.minTopupGb ?? ""}
|
||||
placeholder="默认 1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={fieldId("maxTopupGb")}>最大增流量(GB)</Label>
|
||||
<Input
|
||||
id={fieldId("maxTopupGb")}
|
||||
name="maxTopupGb"
|
||||
type="number"
|
||||
min={1}
|
||||
defaultValue={plan?.maxTopupGb ?? ""}
|
||||
placeholder="留空=按流量池剩余额度"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
85
src/app/(admin)/admin/plans/plans-data.ts
Normal file
85
src/app/(admin)/admin/plans/plans-data.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { parsePage } from "@/lib/utils";
|
||||
import type { StreamingServiceOption } from "./plan-form";
|
||||
|
||||
const planInclude = {
|
||||
node: true,
|
||||
inbound: true,
|
||||
streamingService: true,
|
||||
inboundOptions: {
|
||||
include: {
|
||||
inbound: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "asc",
|
||||
},
|
||||
},
|
||||
_count: { select: { subscriptions: true } },
|
||||
} satisfies Prisma.SubscriptionPlanInclude;
|
||||
|
||||
export type AdminPlanRow = Prisma.SubscriptionPlanGetPayload<{
|
||||
include: typeof planInclude;
|
||||
}>;
|
||||
|
||||
export async function getAdminPlans(
|
||||
searchParams: Record<string, string | string[] | undefined>,
|
||||
) {
|
||||
const { page, skip, pageSize } = parsePage(searchParams);
|
||||
const q = typeof searchParams.q === "string" ? searchParams.q.trim() : "";
|
||||
const type = typeof searchParams.type === "string" ? searchParams.type : "";
|
||||
const status = typeof searchParams.status === "string" ? searchParams.status : "";
|
||||
|
||||
const where = {
|
||||
...(type ? { type: type as "PROXY" | "STREAMING" } : {}),
|
||||
...(status ? { isActive: status === "active" } : {}),
|
||||
...(q
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: q, mode: "insensitive" as const } },
|
||||
{ description: { contains: q, mode: "insensitive" as const } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
} satisfies Prisma.SubscriptionPlanWhereInput;
|
||||
|
||||
const [plans, total, services, activeGroups] = await Promise.all([
|
||||
prisma.subscriptionPlan.findMany({
|
||||
where,
|
||||
include: planInclude,
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.subscriptionPlan.count({ where }),
|
||||
prisma.streamingService.findMany({
|
||||
where: { isActive: true },
|
||||
select: { id: true, name: true, usedSlots: true, maxSlots: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
prisma.userSubscription.groupBy({
|
||||
by: ["planId"],
|
||||
where: { status: "ACTIVE" },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const activeCountMap = new Map(
|
||||
activeGroups.map((item) => [item.planId, item._count._all]),
|
||||
);
|
||||
const serviceOptions: StreamingServiceOption[] = services.map((service) => ({
|
||||
id: service.id,
|
||||
name: service.name,
|
||||
usedSlots: service.usedSlots,
|
||||
maxSlots: service.maxSlots,
|
||||
}));
|
||||
return {
|
||||
plans,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
filters: { q, type, status },
|
||||
activeCountMap,
|
||||
serviceOptions,
|
||||
};
|
||||
}
|
||||
247
src/app/(admin)/admin/plans/proxy-config-section.tsx
Normal file
247
src/app/(admin)/admin/plans/proxy-config-section.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
"use client";
|
||||
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
InboundOption,
|
||||
NodeOption,
|
||||
PlanFormValue,
|
||||
PlanPricingMode,
|
||||
} from "./plan-form-types";
|
||||
|
||||
type FieldId = (name: string) => string;
|
||||
|
||||
export function ProxyNodeFields({
|
||||
fieldId,
|
||||
nodes,
|
||||
nodeId,
|
||||
setNodeId,
|
||||
inbounds,
|
||||
setInbounds,
|
||||
selectedInboundIds,
|
||||
setSelectedInboundIds,
|
||||
toggleInbound,
|
||||
}: {
|
||||
fieldId: FieldId;
|
||||
nodes: NodeOption[];
|
||||
nodeId: string;
|
||||
setNodeId: Dispatch<SetStateAction<string>>;
|
||||
inbounds: InboundOption[];
|
||||
setInbounds: Dispatch<SetStateAction<InboundOption[]>>;
|
||||
selectedInboundIds: string[];
|
||||
setSelectedInboundIds: Dispatch<SetStateAction<string[]>>;
|
||||
toggleInbound: (inboundId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor={fieldId("nodeId")}>节点</Label>
|
||||
<Select
|
||||
value={nodeId}
|
||||
onValueChange={(value) => {
|
||||
setNodeId(value ?? "");
|
||||
setInbounds([]);
|
||||
setSelectedInboundIds([]);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id={fieldId("nodeId")}>
|
||||
<SelectValue placeholder="选择节点">
|
||||
{(value) => {
|
||||
const match = nodes.find((node) => node.id === value);
|
||||
return match ? match.name : "选择节点";
|
||||
}}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{nodes.map((node) => (
|
||||
<SelectItem key={node.id} value={node.id}>
|
||||
{node.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label id={fieldId("inboundIds-label")}>可售入站(可多选)</Label>
|
||||
<input type="hidden" name="inboundIds" value={selectedInboundIds.join(",")} />
|
||||
<div className="grid gap-2 sm:grid-cols-2" role="group" aria-labelledby={fieldId("inboundIds-label")}>
|
||||
{inbounds.map((inbound) => {
|
||||
const selected = selectedInboundIds.includes(inbound.id);
|
||||
return (
|
||||
<button
|
||||
key={inbound.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"choice-card text-left px-3 py-2.5 text-sm",
|
||||
selected
|
||||
? "border-primary/30 bg-primary/10 text-primary"
|
||||
: "hover:bg-muted/45",
|
||||
)}
|
||||
onClick={() => toggleInbound(inbound.id)}
|
||||
>
|
||||
<p className="font-medium">
|
||||
{inbound.protocol} · {inbound.port}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{inbound.tag}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{nodeId && inbounds.length === 0 && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">该节点暂无可用入站</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProxyPricingFields({
|
||||
fieldId,
|
||||
plan,
|
||||
pricingMode,
|
||||
setPricingMode,
|
||||
allowTrafficTopup,
|
||||
}: {
|
||||
fieldId: FieldId;
|
||||
plan?: PlanFormValue;
|
||||
pricingMode: PlanPricingMode;
|
||||
setPricingMode: Dispatch<SetStateAction<PlanPricingMode>>;
|
||||
allowTrafficTopup: boolean;
|
||||
}) {
|
||||
const pricingModeLabels: Record<string, string> = {
|
||||
TRAFFIC_SLIDER: "用户自选流量",
|
||||
FIXED_PACKAGE: "固定流量套餐",
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor={fieldId("pricingMode")}>售卖方式</Label>
|
||||
<Select value={pricingMode} onValueChange={(value) => setPricingMode(value as PlanPricingMode)}>
|
||||
<SelectTrigger id={fieldId("pricingMode")}>
|
||||
<SelectValue placeholder="选择售卖方式">
|
||||
{(value) => pricingModeLabels[value] ?? value}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="TRAFFIC_SLIDER">用户自选流量</SelectItem>
|
||||
<SelectItem value="FIXED_PACKAGE">固定流量套餐</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{pricingMode === "TRAFFIC_SLIDER" ? (
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<Label htmlFor={fieldId("pricePerGb")}>价格(¥/GB)</Label>
|
||||
<Input
|
||||
id={fieldId("pricePerGb")}
|
||||
name="pricePerGb"
|
||||
type="number"
|
||||
step="0.01"
|
||||
defaultValue={plan?.pricePerGb ?? ""}
|
||||
placeholder="例如 0.5"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={fieldId("minTrafficGb")}>最小 GB</Label>
|
||||
<Input
|
||||
id={fieldId("minTrafficGb")}
|
||||
name="minTrafficGb"
|
||||
type="number"
|
||||
defaultValue={plan?.minTrafficGb ?? ""}
|
||||
placeholder="例如 10"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={fieldId("maxTrafficGb")}>最大 GB</Label>
|
||||
<Input
|
||||
id={fieldId("maxTrafficGb")}
|
||||
name="maxTrafficGb"
|
||||
type="number"
|
||||
defaultValue={plan?.maxTrafficGb ?? ""}
|
||||
placeholder="例如 1000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor={fieldId("fixedTrafficGb")}>固定流量(GB)</Label>
|
||||
<Input
|
||||
id={fieldId("fixedTrafficGb")}
|
||||
name="fixedTrafficGb"
|
||||
type="number"
|
||||
min={1}
|
||||
defaultValue={plan?.fixedTrafficGb ?? plan?.minTrafficGb ?? ""}
|
||||
placeholder="例如 200"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={fieldId("fixedPrice")}>固定价格(¥)</Label>
|
||||
<Input
|
||||
id={fieldId("fixedPrice")}
|
||||
name="fixedPrice"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0.01}
|
||||
defaultValue={plan?.fixedPrice ?? ""}
|
||||
placeholder="例如 29.9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor={fieldId("totalTrafficGb")}>总流量池(GB)</Label>
|
||||
<Input
|
||||
id={fieldId("totalTrafficGb")}
|
||||
name="totalTrafficGb"
|
||||
type="number"
|
||||
min={1}
|
||||
defaultValue={plan?.totalTrafficGb ?? ""}
|
||||
placeholder="留空=无限流量"
|
||||
/>
|
||||
{allowTrafficTopup && (
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
增流量上限按剩余总流量实时计算。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** @deprecated Use ProxyNodeFields + ProxyPricingFields instead */
|
||||
export function ProxyConfigSection(props: {
|
||||
fieldId: FieldId;
|
||||
plan?: PlanFormValue;
|
||||
nodes: NodeOption[];
|
||||
nodeId: string;
|
||||
setNodeId: Dispatch<SetStateAction<string>>;
|
||||
inbounds: InboundOption[];
|
||||
setInbounds: Dispatch<SetStateAction<InboundOption[]>>;
|
||||
selectedInboundIds: string[];
|
||||
setSelectedInboundIds: Dispatch<SetStateAction<string[]>>;
|
||||
toggleInbound: (inboundId: string) => void;
|
||||
allowTrafficTopup: boolean;
|
||||
pricingMode: PlanPricingMode;
|
||||
setPricingMode: Dispatch<SetStateAction<PlanPricingMode>>;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<ProxyNodeFields {...props} />
|
||||
<ProxyPricingFields {...props} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
88
src/app/(admin)/admin/plans/streaming-config-section.tsx
Normal file
88
src/app/(admin)/admin/plans/streaming-config-section.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { PlanFormValue, StreamingServiceOption } from "./plan-form-types";
|
||||
|
||||
type FieldId = (name: string) => string;
|
||||
|
||||
interface StreamingConfigSectionProps {
|
||||
fieldId: FieldId;
|
||||
plan?: PlanFormValue;
|
||||
services: StreamingServiceOption[];
|
||||
streamingServiceId: string;
|
||||
setStreamingServiceId: Dispatch<SetStateAction<string>>;
|
||||
hasStreamingServices: boolean;
|
||||
}
|
||||
|
||||
export function StreamingConfigSection({
|
||||
fieldId,
|
||||
plan,
|
||||
services,
|
||||
streamingServiceId,
|
||||
setStreamingServiceId,
|
||||
hasStreamingServices,
|
||||
}: StreamingConfigSectionProps) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor={fieldId("streamingServiceId")}>绑定流媒体服务</Label>
|
||||
<Select
|
||||
value={streamingServiceId}
|
||||
onValueChange={(value) => setStreamingServiceId(value ?? "")}
|
||||
disabled={!hasStreamingServices}
|
||||
>
|
||||
<SelectTrigger id={fieldId("streamingServiceId")}>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
hasStreamingServices ? "选择流媒体服务" : "请先去添加流媒体服务"
|
||||
}
|
||||
>
|
||||
{(value) => {
|
||||
const match = services.find((service) => service.id === value);
|
||||
return match ? `${match.name} (${match.usedSlots}/${match.maxSlots})` : "选择流媒体服务";
|
||||
}}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{services.map((service) => (
|
||||
<SelectItem key={service.id} value={service.id}>
|
||||
{service.name} ({service.usedSlots}/{service.maxSlots})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!hasStreamingServices && (
|
||||
<p className="mt-2 text-xs text-destructive">
|
||||
还没有可用流媒体服务,请先到
|
||||
<Link href="/admin/services" className="mx-1 font-medium text-primary hover:text-primary/80">
|
||||
流媒体服务管理
|
||||
</Link>
|
||||
页面添加。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor={fieldId("price")}>价格(¥)</Label>
|
||||
<Input
|
||||
id={fieldId("price")}
|
||||
name="price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
defaultValue={plan?.price ?? ""}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
175
src/app/(admin)/admin/plans/use-plan-form-state.ts
Normal file
175
src/app/(admin)/admin/plans/use-plan-form-state.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useMemo, useState } from "react";
|
||||
import { fetchJson } from "@/lib/fetch-json";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
import { toast } from "sonner";
|
||||
import type {
|
||||
InboundOption,
|
||||
NodeOption,
|
||||
PlanFormValue,
|
||||
PlanPricingMode,
|
||||
PlanType,
|
||||
StreamingServiceOption,
|
||||
} from "./plan-form-types";
|
||||
|
||||
type SubmitState = "idle" | "submitting";
|
||||
|
||||
interface UsePlanFormStateArgs {
|
||||
plan?: PlanFormValue;
|
||||
services: StreamingServiceOption[];
|
||||
isEdit: boolean;
|
||||
}
|
||||
|
||||
export function usePlanFormState({ plan, services, isEdit }: UsePlanFormStateArgs) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [type, setType] = useState<PlanType>(plan?.type ?? "PROXY");
|
||||
const [nodeId, setNodeId] = useState(plan?.nodeId ?? "");
|
||||
const [selectedInboundIds, setSelectedInboundIds] = useState<string[]>(
|
||||
plan?.inboundOptionIds?.length ? plan.inboundOptionIds : (plan?.inboundId ? [plan.inboundId] : []),
|
||||
);
|
||||
const [streamingServiceId, setStreamingServiceId] = useState(plan?.streamingServiceId ?? "");
|
||||
const [pricingMode, setPricingMode] = useState<PlanPricingMode>(plan?.pricingMode ?? "TRAFFIC_SLIDER");
|
||||
const [allowRenewal, setAllowRenewal] = useState(plan?.allowRenewal ?? false);
|
||||
const [allowTrafficTopup, setAllowTrafficTopup] = useState(plan?.allowTrafficTopup ?? false);
|
||||
const [renewalPricingMode, setRenewalPricingMode] = useState<PlanFormValue["renewalPricingMode"]>(
|
||||
plan?.renewalPricingMode ?? "FIXED_DURATION",
|
||||
);
|
||||
const [topupPricingMode, setTopupPricingMode] = useState<PlanFormValue["topupPricingMode"]>(
|
||||
plan?.topupPricingMode ?? "PER_GB",
|
||||
);
|
||||
const [submitState, setSubmitState] = useState<SubmitState>("idle");
|
||||
const [nodes, setNodes] = useState<NodeOption[]>([]);
|
||||
const [inbounds, setInbounds] = useState<InboundOption[]>([]);
|
||||
const formId = useId();
|
||||
|
||||
const hasStreamingServices = services.length > 0;
|
||||
const title = useMemo(() => (isEdit ? "编辑套餐" : "创建套餐"), [isEdit]);
|
||||
const submitting = submitState === "submitting";
|
||||
const fieldId = (name: string) => `${formId}-${name}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
fetchJson<NodeOption[]>("/api/admin/nodes")
|
||||
.then((list) => {
|
||||
setNodes(list);
|
||||
if (type !== "PROXY") return;
|
||||
setNodeId((prev) => prev || list[0]?.id || "");
|
||||
})
|
||||
.catch((error) => {
|
||||
setNodes([]);
|
||||
toast.error(getErrorMessage(error, "节点列表加载失败"));
|
||||
});
|
||||
}, [open, type]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || type !== "PROXY" || !nodeId) return;
|
||||
|
||||
let cancelled = false;
|
||||
fetchJson<InboundOption[]>(`/api/admin/nodes/${nodeId}/inbounds`)
|
||||
.then((list) => {
|
||||
if (cancelled) return;
|
||||
setInbounds(list);
|
||||
setSelectedInboundIds((prev) => {
|
||||
const valid = prev.filter((id) => list.some((inbound) => inbound.id === id));
|
||||
if (valid.length > 0) return valid;
|
||||
return list[0]?.id ? [list[0].id] : [];
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
setInbounds([]);
|
||||
setSelectedInboundIds([]);
|
||||
toast.error(getErrorMessage(error, "入站列表加载失败"));
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, type, nodeId]);
|
||||
|
||||
function resetFromPlan() {
|
||||
if (!plan) return;
|
||||
setType(plan.type);
|
||||
setNodeId(plan.nodeId ?? "");
|
||||
setSelectedInboundIds(
|
||||
plan.inboundOptionIds?.length
|
||||
? plan.inboundOptionIds
|
||||
: (plan.inboundId ? [plan.inboundId] : []),
|
||||
);
|
||||
setStreamingServiceId(plan.streamingServiceId ?? "");
|
||||
setPricingMode(plan.pricingMode ?? "TRAFFIC_SLIDER");
|
||||
setAllowRenewal(plan.allowRenewal ?? false);
|
||||
setAllowTrafficTopup(plan.allowTrafficTopup ?? false);
|
||||
setRenewalPricingMode(plan.renewalPricingMode ?? "FIXED_DURATION");
|
||||
setTopupPricingMode(plan.topupPricingMode ?? "PER_GB");
|
||||
setSubmitState("idle");
|
||||
}
|
||||
|
||||
function resetForCreate() {
|
||||
setType("PROXY");
|
||||
setNodeId("");
|
||||
setSelectedInboundIds([]);
|
||||
setStreamingServiceId(hasStreamingServices ? services[0].id : "");
|
||||
setPricingMode("TRAFFIC_SLIDER");
|
||||
setAllowRenewal(false);
|
||||
setAllowTrafficTopup(false);
|
||||
setRenewalPricingMode("FIXED_DURATION");
|
||||
setTopupPricingMode("PER_GB");
|
||||
setSubmitState("idle");
|
||||
}
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
setOpen(next);
|
||||
if (!next && plan) {
|
||||
resetFromPlan();
|
||||
}
|
||||
if (next && !isEdit) {
|
||||
resetForCreate();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleInbound(inboundId: string) {
|
||||
setSelectedInboundIds((prev) => {
|
||||
if (prev.includes(inboundId)) {
|
||||
return prev.filter((id) => id !== inboundId);
|
||||
}
|
||||
return [...prev, inboundId];
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
open,
|
||||
handleOpenChange,
|
||||
title,
|
||||
fieldId,
|
||||
type,
|
||||
setType,
|
||||
nodeId,
|
||||
setNodeId,
|
||||
selectedInboundIds,
|
||||
setSelectedInboundIds,
|
||||
streamingServiceId,
|
||||
setStreamingServiceId,
|
||||
pricingMode,
|
||||
setPricingMode,
|
||||
allowRenewal,
|
||||
setAllowRenewal,
|
||||
allowTrafficTopup,
|
||||
setAllowTrafficTopup,
|
||||
renewalPricingMode,
|
||||
setRenewalPricingMode,
|
||||
topupPricingMode,
|
||||
setTopupPricingMode,
|
||||
submitting,
|
||||
startSubmitting: () => setSubmitState("submitting"),
|
||||
finishSubmitting: () => setSubmitState("idle"),
|
||||
nodes,
|
||||
setNodes,
|
||||
inbounds,
|
||||
setInbounds,
|
||||
hasStreamingServices,
|
||||
toggleInbound,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { batchToggleServiceStatus } from "@/actions/admin/services";
|
||||
import { BatchActionBar, BatchActionButton } from "@/components/admin/batch-action-bar";
|
||||
import { DataTableShell } from "@/components/admin/data-table-shell";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableBody,
|
||||
DataTableCell,
|
||||
DataTableHead,
|
||||
DataTableHeadCell,
|
||||
DataTableHeaderRow,
|
||||
DataTableRow,
|
||||
} from "@/components/shared/data-table";
|
||||
import { ActiveStatusBadge, StatusBadge } from "@/components/shared/status-badge";
|
||||
import { CredentialCell } from "../credential-cell";
|
||||
import { ServiceActions } from "../service-actions";
|
||||
import type { StreamingServiceRow } from "../services-data";
|
||||
|
||||
export function ServicesTable({ services }: { services: StreamingServiceRow[] }) {
|
||||
return (
|
||||
<DataTableShell
|
||||
isEmpty={services.length === 0}
|
||||
emptyTitle="暂无流媒体服务"
|
||||
emptyDescription="添加服务后,流媒体套餐才能分配共享槽位。"
|
||||
toolbar={
|
||||
<BatchActionBar
|
||||
id="service-batch-form"
|
||||
action={batchToggleServiceStatus}
|
||||
className="rounded-none bg-transparent"
|
||||
>
|
||||
<BatchActionButton name="isActive" value="true">批量启用</BatchActionButton>
|
||||
<BatchActionButton name="isActive" value="false" destructive>批量停用</BatchActionButton>
|
||||
</BatchActionBar>
|
||||
}
|
||||
>
|
||||
<DataTable aria-label="流媒体服务列表" className="min-w-[980px]">
|
||||
<DataTableHead>
|
||||
<DataTableHeaderRow>
|
||||
<DataTableHeadCell>选择</DataTableHeadCell>
|
||||
<DataTableHeadCell>名称</DataTableHeadCell>
|
||||
<DataTableHeadCell>状态</DataTableHeadCell>
|
||||
<DataTableHeadCell>凭据</DataTableHeadCell>
|
||||
<DataTableHeadCell>插槽</DataTableHeadCell>
|
||||
<DataTableHeadCell>描述</DataTableHeadCell>
|
||||
<DataTableHeadCell className="text-right">操作</DataTableHeadCell>
|
||||
</DataTableHeaderRow>
|
||||
</DataTableHead>
|
||||
<DataTableBody>
|
||||
{services.map((service) => (
|
||||
<DataTableRow key={service.id}>
|
||||
<DataTableCell>
|
||||
<input
|
||||
form="service-batch-form"
|
||||
type="checkbox"
|
||||
name="serviceIds"
|
||||
value={service.id}
|
||||
aria-label={`选择服务 ${service.name}`}
|
||||
/>
|
||||
</DataTableCell>
|
||||
<DataTableCell className="max-w-52 whitespace-normal break-words font-medium">{service.name}</DataTableCell>
|
||||
<DataTableCell>
|
||||
<ActiveStatusBadge active={service.isActive} />
|
||||
</DataTableCell>
|
||||
<DataTableCell>
|
||||
<CredentialCell serviceId={service.id} />
|
||||
</DataTableCell>
|
||||
<DataTableCell>
|
||||
<StatusBadge tone={service.usedSlots >= service.maxSlots ? "danger" : "success"}>
|
||||
{service.usedSlots}/{service.maxSlots}
|
||||
</StatusBadge>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
已分配 {service._count.slots} 个订阅槽位
|
||||
</p>
|
||||
</DataTableCell>
|
||||
<DataTableCell className="max-w-sm whitespace-normal break-words text-muted-foreground">
|
||||
{service.description || "—"}
|
||||
</DataTableCell>
|
||||
<DataTableCell>
|
||||
<div className="flex justify-end">
|
||||
<ServiceActions service={service} />
|
||||
</div>
|
||||
</DataTableCell>
|
||||
</DataTableRow>
|
||||
))}
|
||||
</DataTableBody>
|
||||
</DataTable>
|
||||
</DataTableShell>
|
||||
);
|
||||
}
|
||||
15
src/app/(admin)/admin/services/credential-action.ts
Normal file
15
src/app/(admin)/admin/services/credential-action.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
"use server";
|
||||
|
||||
import { requireAdmin } from "@/lib/require-auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
|
||||
export async function revealCredential(serviceId: string): Promise<string> {
|
||||
await requireAdmin();
|
||||
const service = await prisma.streamingService.findUnique({
|
||||
where: { id: serviceId },
|
||||
select: { credentials: true },
|
||||
});
|
||||
if (!service) throw new Error("服务不存在");
|
||||
return decrypt(service.credentials);
|
||||
}
|
||||
51
src/app/(admin)/admin/services/credential-cell.tsx
Normal file
51
src/app/(admin)/admin/services/credential-cell.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { revealCredential } from "./credential-action";
|
||||
|
||||
export function CredentialCell({ serviceId }: { serviceId: string }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [creds, setCreds] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function toggle() {
|
||||
if (visible) {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
if (creds) {
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await revealCredential(serviceId);
|
||||
setCreds(result);
|
||||
setVisible(true);
|
||||
} catch {
|
||||
setCreds("[解密失败]");
|
||||
setVisible(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs max-w-xs truncate">
|
||||
{visible ? creds : "••••••••"}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={toggle}
|
||||
disabled={loading}
|
||||
>
|
||||
{visible ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
src/app/(admin)/admin/services/page.tsx
Normal file
47
src/app/(admin)/admin/services/page.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { Metadata } from "next";
|
||||
import { AdminFilterBar } from "@/components/admin/filter-bar";
|
||||
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||
import { Pagination } from "@/components/shared/pagination";
|
||||
import { ServiceForm } from "./service-form";
|
||||
import { ServicesTable } from "./_components/services-table";
|
||||
import { getStreamingServices } from "./services-data";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "流媒体服务",
|
||||
description: "管理共享服务、凭据与可售容量。",
|
||||
};
|
||||
|
||||
export default async function ServicesPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const { services, total, page, pageSize, filters } = await getStreamingServices(await searchParams);
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
eyebrow="商品与订单"
|
||||
title="流媒体服务"
|
||||
actions={<ServiceForm />}
|
||||
/>
|
||||
<AdminFilterBar
|
||||
q={filters.q}
|
||||
searchPlaceholder="搜索服务名称或描述"
|
||||
selects={[
|
||||
{
|
||||
name: "status",
|
||||
value: filters.status,
|
||||
options: [
|
||||
{ label: "全部状态", value: "" },
|
||||
{ label: "启用中", value: "active" },
|
||||
{ label: "已停用", value: "inactive" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<ServicesTable services={services} />
|
||||
<Pagination total={total} pageSize={pageSize} page={page} />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
45
src/app/(admin)/admin/services/service-actions.tsx
Normal file
45
src/app/(admin)/admin/services/service-actions.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import type { StreamingService } from "@prisma/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
import { deleteService, toggleServiceStatus } from "@/actions/admin/services";
|
||||
import { toast } from "sonner";
|
||||
import { ServiceForm } from "./service-form";
|
||||
|
||||
export function ServiceActions({ service }: { service: StreamingService }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ServiceForm service={service} triggerLabel="编辑" triggerVariant="outline" />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
try {
|
||||
await toggleServiceStatus(service.id, !service.isActive);
|
||||
toast.success(service.isActive ? "服务已停用" : "服务已启用");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "更新状态失败"));
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{service.isActive ? "停用" : "启用"}
|
||||
</Button>
|
||||
<ConfirmActionButton
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
title="删除这个服务?"
|
||||
description="删除后无法恢复。请确认没有正在使用这个服务的共享名额。"
|
||||
confirmLabel="删除服务"
|
||||
successMessage="服务已删除"
|
||||
errorMessage="删除失败"
|
||||
onConfirm={() => deleteService(service.id)}
|
||||
>
|
||||
删除
|
||||
</ConfirmActionButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
src/app/(admin)/admin/services/service-form.tsx
Normal file
90
src/app/(admin)/admin/services/service-form.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { StreamingService } from "@prisma/client";
|
||||
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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { createService, updateService } from "@/actions/admin/services";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function ServiceForm({
|
||||
service,
|
||||
triggerLabel,
|
||||
triggerVariant = "default",
|
||||
}: {
|
||||
service?: StreamingService;
|
||||
triggerLabel?: string;
|
||||
triggerVariant?: "default" | "outline" | "ghost";
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const isEdit = Boolean(service);
|
||||
|
||||
async function handleSubmit(formData: FormData) {
|
||||
try {
|
||||
if (service) {
|
||||
await updateService(service.id, formData);
|
||||
toast.success("服务已更新");
|
||||
} else {
|
||||
await createService(formData);
|
||||
toast.success("服务创建成功");
|
||||
}
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, isEdit ? "更新失败" : "创建失败"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger render={<Button variant={triggerVariant} size={isEdit ? "sm" : "default"} />}>
|
||||
{triggerLabel ?? (isEdit ? "编辑" : "添加服务")}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? "编辑流媒体服务" : "添加流媒体服务"}</DialogTitle>
|
||||
<p className="text-sm leading-6 text-muted-foreground">服务会被套餐占用槽位,凭据只在后台可见,请确保描述足够清晰。</p>
|
||||
</DialogHeader>
|
||||
<form action={handleSubmit} className="form-panel space-y-5">
|
||||
<div>
|
||||
<Label>名称 (如 Netflix)</Label>
|
||||
<Input name="name" defaultValue={service?.name} required />
|
||||
</div>
|
||||
<div>
|
||||
<Label>凭据 (账号密码等)</Label>
|
||||
<Textarea
|
||||
name="credentials"
|
||||
required
|
||||
defaultValue=""
|
||||
placeholder={
|
||||
isEdit
|
||||
? "重新输入最新凭据,不留空"
|
||||
: "email: xxx password: xxx"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>最大共享人数</Label>
|
||||
<Input name="maxSlots" type="number" defaultValue={service?.maxSlots ?? 5} required />
|
||||
</div>
|
||||
<div>
|
||||
<Label>描述</Label>
|
||||
<Input name="description" defaultValue={service?.description ?? ""} />
|
||||
</div>
|
||||
<Button type="submit" size="lg" className="w-full">
|
||||
{isEdit ? "保存" : "创建"}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
48
src/app/(admin)/admin/services/services-data.ts
Normal file
48
src/app/(admin)/admin/services/services-data.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { parsePage } from "@/lib/utils";
|
||||
|
||||
const serviceInclude = {
|
||||
_count: {
|
||||
select: {
|
||||
slots: true,
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.StreamingServiceInclude;
|
||||
|
||||
export type StreamingServiceRow = Prisma.StreamingServiceGetPayload<{
|
||||
include: typeof serviceInclude;
|
||||
}>;
|
||||
|
||||
export async function getStreamingServices(
|
||||
searchParams: Record<string, string | string[] | undefined>,
|
||||
) {
|
||||
const { page, skip, pageSize } = parsePage(searchParams);
|
||||
const q = typeof searchParams.q === "string" ? searchParams.q.trim() : "";
|
||||
const status = typeof searchParams.status === "string" ? searchParams.status : "";
|
||||
|
||||
const where = {
|
||||
...(status ? { isActive: status === "active" } : {}),
|
||||
...(q
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: q, mode: "insensitive" as const } },
|
||||
{ description: { contains: q, mode: "insensitive" as const } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
} satisfies Prisma.StreamingServiceWhereInput;
|
||||
|
||||
const [services, total] = await Promise.all([
|
||||
prisma.streamingService.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: serviceInclude,
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.streamingService.count({ where }),
|
||||
]);
|
||||
|
||||
return { services, total, page, pageSize, filters: { q, status } };
|
||||
}
|
||||
47
src/app/(admin)/admin/settings/page.tsx
Normal file
47
src/app/(admin)/admin/settings/page.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { Metadata } from "next";
|
||||
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||
import { getAppConfig } from "@/services/app-config";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { SettingsForm } from "./settings-form";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "系统设置",
|
||||
description: "维护站点配置、注册策略与客服联系方式。",
|
||||
};
|
||||
|
||||
export default async function AdminSettingsPage() {
|
||||
const [config, coupons] = await Promise.all([
|
||||
getAppConfig(),
|
||||
prisma.coupon.findMany({ where: { isActive: true }, select: { id: true, code: true, name: true }, orderBy: { createdAt: "desc" } }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
eyebrow="系统"
|
||||
title="系统设置"
|
||||
/>
|
||||
<SettingsForm
|
||||
config={{
|
||||
siteName: config.siteName,
|
||||
siteUrl: config.siteUrl,
|
||||
supportContact: config.supportContact,
|
||||
maintenanceNotice: config.maintenanceNotice,
|
||||
siteNotice: config.siteNotice,
|
||||
allowRegistration: config.allowRegistration,
|
||||
requireInviteCode: config.requireInviteCode,
|
||||
autoReminderDispatchEnabled: config.autoReminderDispatchEnabled,
|
||||
reminderDispatchIntervalMinutes: config.reminderDispatchIntervalMinutes,
|
||||
trafficSyncEnabled: config.trafficSyncEnabled,
|
||||
trafficSyncIntervalSeconds: config.trafficSyncIntervalSeconds,
|
||||
inviteRewardEnabled: config.inviteRewardEnabled,
|
||||
inviteRewardRate: Number(config.inviteRewardRate),
|
||||
inviteRewardCouponId: config.inviteRewardCouponId,
|
||||
turnstileSiteKey: config.turnstileSiteKey,
|
||||
turnstileSecretKey: config.turnstileSecretKey,
|
||||
}}
|
||||
coupons={coupons}
|
||||
/>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
258
src/app/(admin)/admin/settings/settings-form.tsx
Normal file
258
src/app/(admin)/admin/settings/settings-form.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Bell, Clock3, Gift, 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";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { saveAppSettings } from "@/actions/admin/settings";
|
||||
import { toast } from "sonner";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
|
||||
interface AppConfig {
|
||||
siteName: string;
|
||||
siteUrl: string | null;
|
||||
supportContact: string | null;
|
||||
maintenanceNotice: string | null;
|
||||
siteNotice: string | null;
|
||||
allowRegistration: boolean;
|
||||
requireInviteCode: boolean;
|
||||
autoReminderDispatchEnabled: boolean;
|
||||
reminderDispatchIntervalMinutes: number;
|
||||
trafficSyncEnabled: boolean;
|
||||
trafficSyncIntervalSeconds: number;
|
||||
inviteRewardEnabled: boolean;
|
||||
inviteRewardRate: number;
|
||||
inviteRewardCouponId: string | null;
|
||||
turnstileSiteKey: string | null;
|
||||
turnstileSecretKey: string | null;
|
||||
}
|
||||
|
||||
interface CouponOption {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const selectClassName = "premium-input w-full appearance-none px-3.5 py-2 text-sm outline-none";
|
||||
|
||||
export function SettingsForm({ config, coupons }: { config: AppConfig; coupons: CouponOption[] }) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function handleSubmit(formData: FormData) {
|
||||
setSaving(true);
|
||||
try {
|
||||
await saveAppSettings(formData);
|
||||
toast.success("设置已保存");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "保存失败"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={handleSubmit} className="form-panel space-y-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex size-11 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
|
||||
<Settings2 className="size-5" />
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold tracking-tight">全局设置</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">把注册策略、自动化任务和公告内容集中配置,避免页面状态割裂。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<Settings2 className="size-4 text-primary" /> 基础信息
|
||||
</div>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="siteName">站点名称</Label>
|
||||
<Input id="siteName" name="siteName" defaultValue={config.siteName} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="siteUrl">站点域名 / URL</Label>
|
||||
<Input id="siteUrl" name="siteUrl" defaultValue={config.siteUrl ?? ""} placeholder="https://example.com" />
|
||||
<p className="text-xs leading-5 text-muted-foreground">用于订阅链接、支付回调和 Agent 一键安装命令,反代后建议填写公网域名。</p>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor="supportContact">客服联系方式</Label>
|
||||
<Input id="supportContact" name="supportContact" defaultValue={config.supportContact ?? ""} />
|
||||
</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" /> 自动化任务
|
||||
</div>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="autoReminderDispatchEnabled">自动提醒派发</Label>
|
||||
<select
|
||||
id="autoReminderDispatchEnabled"
|
||||
name="autoReminderDispatchEnabled"
|
||||
defaultValue={String(config.autoReminderDispatchEnabled)}
|
||||
className={selectClassName}
|
||||
>
|
||||
<option value="true">开启</option>
|
||||
<option value="false">关闭</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reminderDispatchIntervalMinutes">提醒间隔(分钟)</Label>
|
||||
<Input id="reminderDispatchIntervalMinutes" name="reminderDispatchIntervalMinutes" type="number" min={1} defaultValue={config.reminderDispatchIntervalMinutes} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="trafficSyncEnabled">3x-ui 流量定时同步</Label>
|
||||
<select
|
||||
id="trafficSyncEnabled"
|
||||
name="trafficSyncEnabled"
|
||||
defaultValue={String(config.trafficSyncEnabled)}
|
||||
className={selectClassName}
|
||||
>
|
||||
<option value="true">开启</option>
|
||||
<option value="false">关闭</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="trafficSyncIntervalSeconds">流量同步间隔(秒)</Label>
|
||||
<Input
|
||||
id="trafficSyncIntervalSeconds"
|
||||
name="trafficSyncIntervalSeconds"
|
||||
type="number"
|
||||
min={10}
|
||||
step={1}
|
||||
defaultValue={config.trafficSyncIntervalSeconds}
|
||||
placeholder="60"
|
||||
/>
|
||||
<p className="text-xs leading-5 text-muted-foreground">进程级后台定时任务,默认 60 秒;建议不要低于 10 秒。</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">
|
||||
<ShieldCheck className="size-4 text-primary" /> 注册策略
|
||||
</div>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="allowRegistration">开放注册</Label>
|
||||
<select
|
||||
id="allowRegistration"
|
||||
name="allowRegistration"
|
||||
defaultValue={String(config.allowRegistration)}
|
||||
className={selectClassName}
|
||||
>
|
||||
<option value="true">是</option>
|
||||
<option value="false">否</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="requireInviteCode">注册必须邀请码</Label>
|
||||
<select
|
||||
id="requireInviteCode"
|
||||
name="requireInviteCode"
|
||||
defaultValue={String(config.requireInviteCode)}
|
||||
className={selectClassName}
|
||||
>
|
||||
<option value="false">否</option>
|
||||
<option value="true">是</option>
|
||||
</select>
|
||||
</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">
|
||||
<Gift className="size-4 text-primary" /> 邀请奖励
|
||||
</div>
|
||||
<div className="grid gap-5 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="inviteRewardEnabled">自动发放奖励</Label>
|
||||
<select
|
||||
id="inviteRewardEnabled"
|
||||
name="inviteRewardEnabled"
|
||||
defaultValue={String(config.inviteRewardEnabled)}
|
||||
className={selectClassName}
|
||||
>
|
||||
<option value="false">关闭</option>
|
||||
<option value="true">开启</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="inviteRewardRate">返利比例(%)</Label>
|
||||
<Input id="inviteRewardRate" name="inviteRewardRate" type="number" min={0} max={100} step="0.01" defaultValue={config.inviteRewardRate} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="inviteRewardCouponId">自动发放优惠券</Label>
|
||||
<select
|
||||
id="inviteRewardCouponId"
|
||||
name="inviteRewardCouponId"
|
||||
defaultValue={config.inviteRewardCouponId ?? ""}
|
||||
className={selectClassName}
|
||||
>
|
||||
<option value="">不发放优惠券</option>
|
||||
{coupons.map((coupon) => (
|
||||
<option key={coupon.id} value={coupon.id}>
|
||||
{coupon.name} · {coupon.code}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
被邀请用户完成首笔订单后,系统会为邀请人记录返利,并可自动把指定优惠券放入邀请人的券包。
|
||||
</p>
|
||||
</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">
|
||||
<ShieldAlert className="size-4 text-primary" /> Cloudflare Turnstile
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
为登录和注册页面添加人机验证。留空则不启用。
|
||||
</p>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="turnstileSiteKey">Site Key</Label>
|
||||
<Input id="turnstileSiteKey" name="turnstileSiteKey" defaultValue={config.turnstileSiteKey ?? ""} placeholder="0x4AAAAAAA..." />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="turnstileSecretKey">Secret Key</Label>
|
||||
<Input id="turnstileSecretKey" name="turnstileSecretKey" type="password" defaultValue={config.turnstileSecretKey ?? ""} placeholder="0x4AAAAAAA..." />
|
||||
</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">
|
||||
<Bell className="size-4 text-primary" /> 公告内容
|
||||
</div>
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="siteNotice">用户侧站点公告</Label>
|
||||
<Textarea id="siteNotice" name="siteNotice" rows={4} defaultValue={config.siteNotice ?? ""} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maintenanceNotice">维护通知</Label>
|
||||
<Textarea id="maintenanceNotice" name="maintenanceNotice" rows={4} defaultValue={config.maintenanceNotice ?? ""} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<Button type="submit" size="lg" disabled={saving}>
|
||||
{saving ? "保存中..." : "保存设置"}
|
||||
</Button>
|
||||
<a href="/api/admin/export/config" className={buttonVariants({ variant: "outline", size: "lg" })}>
|
||||
导出配置备份
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
40
src/app/(admin)/admin/subscriptions/[id]/page.tsx
Normal file
40
src/app/(admin)/admin/subscriptions/[id]/page.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||
import { SubscriptionDetailCards } from "@/components/subscriptions/subscription-detail-cards";
|
||||
import { SubscriptionTimelineSection } from "@/components/subscriptions/subscription-timeline-section";
|
||||
import { TrafficLogList } from "@/components/subscriptions/traffic-log-list";
|
||||
import { getAdminSubscriptionDetail } from "./subscription-detail-data";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "订阅详情",
|
||||
description: "查看订阅生命周期、资源绑定和流量日志。",
|
||||
};
|
||||
|
||||
export default async function AdminSubscriptionDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const data = await getAdminSubscriptionDetail(id);
|
||||
|
||||
if (!data) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { subscription, auditLogs, trafficLogs } = data;
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
eyebrow="订阅详情"
|
||||
title={subscription.plan.name}
|
||||
description={subscription.user.email}
|
||||
/>
|
||||
<SubscriptionDetailCards subscription={subscription} showClientEmail />
|
||||
<SubscriptionTimelineSection logs={auditLogs} />
|
||||
<TrafficLogList logs={trafficLogs} />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const adminSubscriptionDetailInclude = {
|
||||
user: true,
|
||||
plan: true,
|
||||
nodeClient: {
|
||||
include: {
|
||||
inbound: {
|
||||
include: {
|
||||
server: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
streamingSlot: {
|
||||
include: {
|
||||
service: true,
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.UserSubscriptionInclude;
|
||||
|
||||
export type AdminSubscriptionDetail = Prisma.UserSubscriptionGetPayload<{
|
||||
include: typeof adminSubscriptionDetailInclude;
|
||||
}>;
|
||||
|
||||
export async function getAdminSubscriptionDetail(subscriptionId: string) {
|
||||
const subscription = await prisma.userSubscription.findUnique({
|
||||
where: { id: subscriptionId },
|
||||
include: adminSubscriptionDetailInclude,
|
||||
});
|
||||
|
||||
if (!subscription) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [auditLogs, trafficLogs] = await Promise.all([
|
||||
prisma.auditLog.findMany({
|
||||
where: {
|
||||
targetType: "UserSubscription",
|
||||
targetId: subscription.id,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 100,
|
||||
}),
|
||||
subscription.nodeClient
|
||||
? prisma.trafficLog.findMany({
|
||||
where: {
|
||||
clientId: subscription.nodeClient.id,
|
||||
},
|
||||
orderBy: { timestamp: "desc" },
|
||||
take: 30,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
return { subscription, auditLogs, trafficLogs };
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import Link from "next/link";
|
||||
import { batchSubscriptionOperation } from "@/actions/admin/subscriptions";
|
||||
import { BatchActionBar, BatchActionButton } from "@/components/admin/batch-action-bar";
|
||||
import { DataTableShell } from "@/components/admin/data-table-shell";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableBody,
|
||||
DataTableCell,
|
||||
DataTableHead,
|
||||
DataTableHeadCell,
|
||||
DataTableHeaderRow,
|
||||
DataTableRow,
|
||||
} from "@/components/shared/data-table";
|
||||
import {
|
||||
SubscriptionStatusBadge,
|
||||
SubscriptionTypeBadge,
|
||||
} from "@/components/shared/domain-badges";
|
||||
import { formatBytes, formatDateShort } from "@/lib/utils";
|
||||
import { AdminSubscriptionActions } from "../subscription-actions";
|
||||
import type { StreamingServiceOption } from "../streaming-slot-dialog";
|
||||
import type { AdminSubscriptionRow } from "../subscriptions-data";
|
||||
|
||||
interface SubscriptionsTableProps {
|
||||
subscriptions: AdminSubscriptionRow[];
|
||||
streamingServices: StreamingServiceOption[];
|
||||
}
|
||||
|
||||
function SubscriptionResource({ subscription }: { subscription: AdminSubscriptionRow }) {
|
||||
if (subscription.plan.type === "PROXY") {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p>{subscription.nodeClient?.inbound.server.name ?? "未分配节点"}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{subscription.nodeClient
|
||||
? `${subscription.nodeClient.inbound.protocol} · ${subscription.nodeClient.inbound.tag}`
|
||||
: "暂无客户端"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p>{subscription.streamingSlot?.service.name ?? "未分配服务"}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{subscription.streamingSlot ? "已占用槽位" : "暂无槽位"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubscriptionTraffic({ subscription }: { subscription: AdminSubscriptionRow }) {
|
||||
if (subscription.plan.type !== "PROXY") return <span className="text-muted-foreground">—</span>;
|
||||
|
||||
const limit = subscription.trafficLimit ? formatBytes(subscription.trafficLimit) : "无限";
|
||||
const used = formatBytes(subscription.trafficUsed);
|
||||
|
||||
return <span>{used} / {limit}</span>;
|
||||
}
|
||||
|
||||
export function SubscriptionsTable({
|
||||
subscriptions,
|
||||
streamingServices,
|
||||
}: SubscriptionsTableProps) {
|
||||
return (
|
||||
<DataTableShell
|
||||
isEmpty={subscriptions.length === 0}
|
||||
emptyTitle="暂无订阅记录"
|
||||
emptyDescription="用户完成购买并开通后,订阅会出现在这里。"
|
||||
toolbar={
|
||||
<BatchActionBar
|
||||
id="subscription-batch-form"
|
||||
action={batchSubscriptionOperation}
|
||||
className="rounded-none bg-transparent"
|
||||
>
|
||||
<BatchActionButton value="suspend">批量封停</BatchActionButton>
|
||||
<BatchActionButton value="activate">批量恢复</BatchActionButton>
|
||||
<BatchActionButton value="delete" destructive>
|
||||
批量彻底删除
|
||||
</BatchActionButton>
|
||||
</BatchActionBar>
|
||||
}
|
||||
>
|
||||
<DataTable aria-label="订阅列表" className="min-w-[1080px]">
|
||||
<DataTableHead>
|
||||
<DataTableHeaderRow>
|
||||
<DataTableHeadCell>选择</DataTableHeadCell>
|
||||
<DataTableHeadCell>用户</DataTableHeadCell>
|
||||
<DataTableHeadCell>套餐</DataTableHeadCell>
|
||||
<DataTableHeadCell>类型</DataTableHeadCell>
|
||||
<DataTableHeadCell>资源</DataTableHeadCell>
|
||||
<DataTableHeadCell>流量</DataTableHeadCell>
|
||||
<DataTableHeadCell>有效期</DataTableHeadCell>
|
||||
<DataTableHeadCell>状态</DataTableHeadCell>
|
||||
<DataTableHeadCell className="text-right">操作</DataTableHeadCell>
|
||||
</DataTableHeaderRow>
|
||||
</DataTableHead>
|
||||
<DataTableBody>
|
||||
{subscriptions.map((subscription) => (
|
||||
<DataTableRow key={subscription.id}>
|
||||
<DataTableCell>
|
||||
<input
|
||||
form="subscription-batch-form"
|
||||
type="checkbox"
|
||||
name="subscriptionIds"
|
||||
value={subscription.id}
|
||||
aria-label={`选择订阅 ${subscription.id}`}
|
||||
/>
|
||||
</DataTableCell>
|
||||
<DataTableCell className="max-w-56 whitespace-normal break-all">
|
||||
<p className="font-medium">{subscription.user.email}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{subscription.user.name || "未设置昵称"}
|
||||
</p>
|
||||
</DataTableCell>
|
||||
<DataTableCell className="max-w-52 whitespace-normal break-words">
|
||||
<Link
|
||||
href={`/admin/subscriptions/${subscription.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{subscription.plan.name}
|
||||
</Link>
|
||||
</DataTableCell>
|
||||
<DataTableCell>
|
||||
<SubscriptionTypeBadge type={subscription.plan.type} />
|
||||
</DataTableCell>
|
||||
<DataTableCell>
|
||||
<SubscriptionResource subscription={subscription} />
|
||||
</DataTableCell>
|
||||
<DataTableCell>
|
||||
<SubscriptionTraffic subscription={subscription} />
|
||||
</DataTableCell>
|
||||
<DataTableCell>
|
||||
<p>{formatDateShort(subscription.startDate)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
到 {formatDateShort(subscription.endDate)}
|
||||
</p>
|
||||
</DataTableCell>
|
||||
<DataTableCell>
|
||||
<SubscriptionStatusBadge status={subscription.status} />
|
||||
</DataTableCell>
|
||||
<DataTableCell>
|
||||
<div className="flex justify-end">
|
||||
<AdminSubscriptionActions
|
||||
subscriptionId={subscription.id}
|
||||
status={subscription.status}
|
||||
type={subscription.plan.type}
|
||||
streamingServices={streamingServices}
|
||||
/>
|
||||
</div>
|
||||
</DataTableCell>
|
||||
</DataTableRow>
|
||||
))}
|
||||
</DataTableBody>
|
||||
</DataTable>
|
||||
</DataTableShell>
|
||||
);
|
||||
}
|
||||
59
src/app/(admin)/admin/subscriptions/page.tsx
Normal file
59
src/app/(admin)/admin/subscriptions/page.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { Metadata } from "next";
|
||||
import { AdminFilterBar } from "@/components/admin/filter-bar";
|
||||
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||
import { Pagination } from "@/components/shared/pagination";
|
||||
import { SubscriptionsTable } from "./_components/subscriptions-table";
|
||||
import { getAdminSubscriptions } from "./subscriptions-data";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "订阅管理",
|
||||
description: "管理订阅状态、客户端绑定与流媒体槽位。",
|
||||
};
|
||||
|
||||
export default async function AdminSubscriptionsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const { subscriptions, total, page, pageSize, filters, streamingServices } =
|
||||
await getAdminSubscriptions(await searchParams);
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
eyebrow="商品与订单"
|
||||
title="订阅管理"
|
||||
/>
|
||||
|
||||
<AdminFilterBar
|
||||
q={filters.q}
|
||||
searchPlaceholder="搜索用户邮箱、昵称、套餐名"
|
||||
selects={[
|
||||
{
|
||||
name: "status",
|
||||
value: filters.status,
|
||||
options: [
|
||||
{ label: "全部状态", value: "" },
|
||||
{ label: "活跃", value: "ACTIVE" },
|
||||
{ label: "暂停", value: "SUSPENDED" },
|
||||
{ label: "过期", value: "EXPIRED" },
|
||||
{ label: "取消", value: "CANCELLED" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "type",
|
||||
value: filters.type,
|
||||
options: [
|
||||
{ label: "全部类型", value: "" },
|
||||
{ label: "代理", value: "PROXY" },
|
||||
{ label: "流媒体", value: "STREAMING" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<SubscriptionsTable subscriptions={subscriptions} streamingServices={streamingServices} />
|
||||
<Pagination total={total} pageSize={pageSize} page={page} />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { reassignStreamingSlot } from "@/actions/admin/subscriptions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export interface StreamingServiceOption {
|
||||
id: string;
|
||||
name: string;
|
||||
usedSlots: number;
|
||||
maxSlots: number;
|
||||
}
|
||||
|
||||
export function StreamingSlotDialog({
|
||||
subscriptionId,
|
||||
services,
|
||||
}: {
|
||||
subscriptionId: string;
|
||||
services: StreamingServiceOption[];
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [serviceId, setServiceId] = useState(services[0]?.id ?? "");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function handleAssign() {
|
||||
if (!serviceId) {
|
||||
toast.error("请选择目标服务");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await reassignStreamingSlot(subscriptionId, serviceId);
|
||||
toast.success("槽位已调配");
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "调配失败"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger render={<Button size="sm" variant="outline" />}>
|
||||
调配槽位
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>手动调配流媒体槽位</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<Select value={serviceId} onValueChange={(value) => setServiceId(value ?? "")}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择目标服务">
|
||||
{(v) => {
|
||||
const m = services.find((s) => s.id === v);
|
||||
return m ? `${m.name} · ${m.usedSlots}/${m.maxSlots}` : "选择目标服务";
|
||||
}}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{services.map((service) => (
|
||||
<SelectItem key={service.id} value={service.id}>
|
||||
{service.name} · {service.usedSlots}/{service.maxSlots}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button className="w-full" onClick={() => void handleAssign()} disabled={saving}>
|
||||
{saving ? "处理中..." : "确认调配"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
88
src/app/(admin)/admin/subscriptions/subscription-actions.tsx
Normal file
88
src/app/(admin)/admin/subscriptions/subscription-actions.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
import {
|
||||
activateSubscription,
|
||||
deleteSubscriptionPermanently,
|
||||
suspendSubscription,
|
||||
} from "@/actions/admin/subscriptions";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
StreamingSlotDialog,
|
||||
type StreamingServiceOption,
|
||||
} from "./streaming-slot-dialog";
|
||||
|
||||
export function AdminSubscriptionActions({
|
||||
subscriptionId,
|
||||
status,
|
||||
type,
|
||||
streamingServices,
|
||||
}: {
|
||||
subscriptionId: string;
|
||||
status: "ACTIVE" | "EXPIRED" | "CANCELLED" | "SUSPENDED";
|
||||
type: "PROXY" | "STREAMING";
|
||||
streamingServices: StreamingServiceOption[];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{type === "STREAMING" && streamingServices.length > 0 && (
|
||||
<StreamingSlotDialog
|
||||
subscriptionId={subscriptionId}
|
||||
services={streamingServices}
|
||||
/>
|
||||
)}
|
||||
|
||||
{status === "ACTIVE" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
try {
|
||||
await suspendSubscription(subscriptionId);
|
||||
toast.success("订阅已暂停");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "暂停失败"));
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
暂停
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status === "SUSPENDED" && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
try {
|
||||
await activateSubscription(subscriptionId);
|
||||
toast.success("订阅已恢复");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "恢复失败"));
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
恢复
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<ConfirmActionButton
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
title="彻底删除这个订阅?"
|
||||
description="会同步删除远端客户端,并清理本地记录与相关订单。此操作无法恢复。"
|
||||
confirmLabel="删除订阅"
|
||||
successMessage="订阅已删除"
|
||||
errorMessage="删除失败"
|
||||
onConfirm={() => deleteSubscriptionPermanently(subscriptionId)}
|
||||
>
|
||||
彻底删除
|
||||
</ConfirmActionButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
src/app/(admin)/admin/subscriptions/subscriptions-data.ts
Normal file
80
src/app/(admin)/admin/subscriptions/subscriptions-data.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { parsePage } from "@/lib/utils";
|
||||
import type { StreamingServiceOption } from "./streaming-slot-dialog";
|
||||
|
||||
const adminSubscriptionInclude = {
|
||||
user: true,
|
||||
plan: true,
|
||||
nodeClient: {
|
||||
include: {
|
||||
inbound: {
|
||||
include: {
|
||||
server: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
streamingSlot: {
|
||||
include: {
|
||||
service: true,
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.UserSubscriptionInclude;
|
||||
|
||||
export type AdminSubscriptionRow = Prisma.UserSubscriptionGetPayload<{
|
||||
include: typeof adminSubscriptionInclude;
|
||||
}>;
|
||||
|
||||
export async function getAdminSubscriptions(
|
||||
searchParams: Record<string, string | string[] | undefined>,
|
||||
) {
|
||||
const { page, skip, pageSize } = parsePage(searchParams);
|
||||
const q = typeof searchParams.q === "string" ? searchParams.q.trim() : "";
|
||||
const status = typeof searchParams.status === "string" ? searchParams.status : "";
|
||||
const type = typeof searchParams.type === "string" ? searchParams.type : "";
|
||||
|
||||
const where = {
|
||||
...(status ? { status: status as "ACTIVE" | "EXPIRED" | "CANCELLED" | "SUSPENDED" } : {}),
|
||||
...(type ? { plan: { type: type as "PROXY" | "STREAMING" } } : {}),
|
||||
...(q
|
||||
? {
|
||||
OR: [
|
||||
{ user: { email: { contains: q, mode: "insensitive" as const } } },
|
||||
{ user: { name: { contains: q, mode: "insensitive" as const } } },
|
||||
{ plan: { name: { contains: q, mode: "insensitive" as const } } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
} satisfies Prisma.UserSubscriptionWhereInput;
|
||||
|
||||
const [subscriptions, total, streamingServices] = await Promise.all([
|
||||
prisma.userSubscription.findMany({
|
||||
where,
|
||||
include: adminSubscriptionInclude,
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.userSubscription.count({ where }),
|
||||
prisma.streamingService.findMany({
|
||||
where: { isActive: true },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
usedSlots: true,
|
||||
maxSlots: true,
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
subscriptions,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
filters: { q, status, type },
|
||||
streamingServices: streamingServices satisfies StreamingServiceOption[],
|
||||
};
|
||||
}
|
||||
61
src/app/(admin)/admin/support/[id]/page.tsx
Normal file
61
src/app/(admin)/admin/support/[id]/page.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||
import {
|
||||
SupportTicketPriorityBadge,
|
||||
SupportTicketStatusBadge,
|
||||
} from "@/components/support/ticket-badges";
|
||||
import { SupportTicketThread } from "@/components/support/ticket-thread";
|
||||
import { AdminSupportTicketActions } from "@/components/support/admin-ticket-actions";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { AdminSupportReplyForm } from "../_components/admin-support-reply-form";
|
||||
import { SupportTicketMetaForm } from "../_components/support-ticket-meta-form";
|
||||
import { getAdminSupportTicketDetail } from "../support-data";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "工单详情",
|
||||
description: "查看并处理指定工单会话。",
|
||||
};
|
||||
|
||||
export default async function AdminSupportTicketDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const ticket = await getAdminSupportTicketDetail(id);
|
||||
|
||||
if (!ticket) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
eyebrow="工单详情"
|
||||
title={ticket.subject}
|
||||
description={`用户 ${ticket.user.email} · 创建于 ${formatDate(ticket.createdAt)} · 最近更新 ${formatDate(ticket.updatedAt)}`}
|
||||
actions={
|
||||
<AdminSupportTicketActions
|
||||
ticketId={ticket.id}
|
||||
redirectAfterDelete="/admin/support"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<SupportTicketStatusBadge status={ticket.status} />
|
||||
<SupportTicketPriorityBadge priority={ticket.priority} />
|
||||
{ticket.category && (
|
||||
<span className="rounded-full bg-muted px-2.5 py-1 text-xs font-medium text-muted-foreground">
|
||||
{ticket.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SupportTicketMetaForm ticket={ticket} />
|
||||
<SupportTicketThread replies={ticket.replies} adminLabel="管理员" />
|
||||
{ticket.status !== "CLOSED" && <AdminSupportReplyForm ticketId={ticket.id} />}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Paperclip, Send } from "lucide-react";
|
||||
import { replySupportAsAdmin } from "@/actions/admin/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 { SUPPORT_ATTACHMENT_ACCEPT } from "@/services/support";
|
||||
|
||||
export function AdminSupportReplyForm({ ticketId }: { ticketId: string }) {
|
||||
async function submitReply(formData: FormData) {
|
||||
"use server";
|
||||
await replySupportAsAdmin(ticketId, formData);
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={submitReply} className="form-panel space-y-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
|
||||
<Send className="size-4" />
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="font-heading text-lg font-semibold tracking-tight">回复用户</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">保持说明清晰,必要时上传截图或补充文件。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="body">回复内容</Label>
|
||||
<Textarea id="body" name="body" rows={4} placeholder="输入给用户的回复" required />
|
||||
</div>
|
||||
<div className="space-y-2 rounded-lg border border-border bg-muted/30 p-4">
|
||||
<Label htmlFor="admin-reply-attachments" className="inline-flex items-center gap-2">
|
||||
<Paperclip className="size-4" /> 附件
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-reply-attachments"
|
||||
name="attachments"
|
||||
type="file"
|
||||
multiple
|
||||
accept={SUPPORT_ATTACHMENT_ACCEPT}
|
||||
/>
|
||||
<p className="field-note">
|
||||
仅支持 JPG、PNG、WEBP、GIF、AVIF 图片,最多 3 张,每张不超过 3MB。
|
||||
</p>
|
||||
</div>
|
||||
<Button type="submit" size="lg" className="w-full sm:w-auto">发送回复</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import Link from "next/link";
|
||||
import { DataTableShell } from "@/components/admin/data-table-shell";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableBody,
|
||||
DataTableCell,
|
||||
DataTableHead,
|
||||
DataTableHeadCell,
|
||||
DataTableHeaderRow,
|
||||
DataTableRow,
|
||||
} from "@/components/shared/data-table";
|
||||
import {
|
||||
SupportTicketPriorityBadge,
|
||||
SupportTicketStatusBadge,
|
||||
} from "@/components/support/ticket-badges";
|
||||
import { AdminSupportTicketActions } from "@/components/support/admin-ticket-actions";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import type { AdminSupportTicketRow } from "../support-data";
|
||||
|
||||
interface AdminSupportTableProps {
|
||||
tickets: AdminSupportTicketRow[];
|
||||
}
|
||||
|
||||
export function AdminSupportTable({ tickets }: AdminSupportTableProps) {
|
||||
return (
|
||||
<DataTableShell
|
||||
isEmpty={tickets.length === 0}
|
||||
emptyTitle="暂无工单"
|
||||
emptyDescription="用户提交售后问题后,会显示在这里。"
|
||||
>
|
||||
<DataTable aria-label="后台工单列表" className="min-w-[860px]">
|
||||
<DataTableHead>
|
||||
<DataTableHeaderRow>
|
||||
<DataTableHeadCell>标题</DataTableHeadCell>
|
||||
<DataTableHeadCell>用户</DataTableHeadCell>
|
||||
<DataTableHeadCell>状态</DataTableHeadCell>
|
||||
<DataTableHeadCell>优先级</DataTableHeadCell>
|
||||
<DataTableHeadCell>回复数</DataTableHeadCell>
|
||||
<DataTableHeadCell>更新时间</DataTableHeadCell>
|
||||
<DataTableHeadCell className="text-right">操作</DataTableHeadCell>
|
||||
</DataTableHeaderRow>
|
||||
</DataTableHead>
|
||||
<DataTableBody>
|
||||
{tickets.map((ticket) => (
|
||||
<DataTableRow key={ticket.id}>
|
||||
<DataTableCell className="max-w-64 whitespace-normal break-words">
|
||||
<Link href={`/admin/support/${ticket.id}`} className="font-medium hover:underline">
|
||||
{ticket.subject}
|
||||
</Link>
|
||||
{ticket.category && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{ticket.category}</p>
|
||||
)}
|
||||
</DataTableCell>
|
||||
<DataTableCell className="max-w-56 whitespace-normal break-all">{ticket.user.email}</DataTableCell>
|
||||
<DataTableCell>
|
||||
<SupportTicketStatusBadge status={ticket.status} />
|
||||
</DataTableCell>
|
||||
<DataTableCell>
|
||||
<SupportTicketPriorityBadge priority={ticket.priority} />
|
||||
</DataTableCell>
|
||||
<DataTableCell className="tabular-nums">{ticket._count.replies}</DataTableCell>
|
||||
<DataTableCell className="whitespace-nowrap text-muted-foreground">
|
||||
<time dateTime={ticket.updatedAt.toISOString()}>{formatDate(ticket.updatedAt)}</time>
|
||||
</DataTableCell>
|
||||
<DataTableCell>
|
||||
<div className="flex justify-end">
|
||||
<AdminSupportTicketActions ticketId={ticket.id} />
|
||||
</div>
|
||||
</DataTableCell>
|
||||
</DataTableRow>
|
||||
))}
|
||||
</DataTableBody>
|
||||
</DataTable>
|
||||
</DataTableShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { updateSupportTicketMeta } from "@/actions/admin/support";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { AdminSupportTicketDetail } from "../support-data";
|
||||
|
||||
export function SupportTicketMetaForm({ ticket }: { ticket: AdminSupportTicketDetail }) {
|
||||
return (
|
||||
<form
|
||||
action={updateSupportTicketMeta}
|
||||
className="surface-card flex flex-wrap items-end gap-3 rounded-xl p-4"
|
||||
>
|
||||
<input type="hidden" name="ticketId" value={ticket.id} />
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">状态</Label>
|
||||
<select
|
||||
id="status"
|
||||
name="status"
|
||||
defaultValue={ticket.status}
|
||||
className="h-11 px-3 text-sm outline-none"
|
||||
>
|
||||
<option value="OPEN">待处理</option>
|
||||
<option value="USER_REPLIED">用户已回复</option>
|
||||
<option value="ADMIN_REPLIED">管理员已回复</option>
|
||||
<option value="CLOSED">已关闭</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="priority">优先级</Label>
|
||||
<select
|
||||
id="priority"
|
||||
name="priority"
|
||||
defaultValue={ticket.priority}
|
||||
className="h-11 px-3 text-sm outline-none"
|
||||
>
|
||||
<option value="LOW">低</option>
|
||||
<option value="NORMAL">普通</option>
|
||||
<option value="HIGH">高</option>
|
||||
<option value="URGENT">紧急</option>
|
||||
</select>
|
||||
</div>
|
||||
<Button type="submit" variant="outline" size="lg">更新状态</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
62
src/app/(admin)/admin/support/page.tsx
Normal file
62
src/app/(admin)/admin/support/page.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { Metadata } from "next";
|
||||
import { AdminFilterBar } from "@/components/admin/filter-bar";
|
||||
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||
import { Pagination } from "@/components/shared/pagination";
|
||||
import { AdminSupportTable } from "./_components/admin-support-table";
|
||||
import { getAdminSupportTickets } from "./support-data";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "工单与售后",
|
||||
description: "处理用户工单、售后回复与状态流转。",
|
||||
};
|
||||
|
||||
export default async function AdminSupportPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const { tickets, total, page, pageSize, filters } = await getAdminSupportTickets(
|
||||
await searchParams,
|
||||
);
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
eyebrow="用户支持"
|
||||
title="工单与售后"
|
||||
/>
|
||||
|
||||
<AdminFilterBar
|
||||
q={filters.q}
|
||||
searchPlaceholder="搜索标题、分类、用户邮箱"
|
||||
selects={[
|
||||
{
|
||||
name: "status",
|
||||
value: filters.status,
|
||||
options: [
|
||||
{ label: "全部状态", value: "" },
|
||||
{ label: "待处理", value: "OPEN" },
|
||||
{ label: "用户已回复", value: "USER_REPLIED" },
|
||||
{ label: "管理员已回复", value: "ADMIN_REPLIED" },
|
||||
{ label: "已关闭", value: "CLOSED" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "priority",
|
||||
value: filters.priority,
|
||||
options: [
|
||||
{ label: "全部优先级", value: "" },
|
||||
{ label: "低", value: "LOW" },
|
||||
{ label: "普通", value: "NORMAL" },
|
||||
{ label: "高", value: "HIGH" },
|
||||
{ label: "紧急", value: "URGENT" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<AdminSupportTable tickets={tickets} />
|
||||
<Pagination total={total} pageSize={pageSize} page={page} />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
79
src/app/(admin)/admin/support/support-data.ts
Normal file
79
src/app/(admin)/admin/support/support-data.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { parsePage } from "@/lib/utils";
|
||||
|
||||
const adminSupportTicketInclude = {
|
||||
user: { select: { email: true } },
|
||||
_count: { select: { replies: true } },
|
||||
} satisfies Prisma.SupportTicketInclude;
|
||||
|
||||
const adminSupportTicketDetailInclude = {
|
||||
user: { select: { email: true } },
|
||||
replies: {
|
||||
include: {
|
||||
author: { select: { email: true } },
|
||||
attachments: {
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
mimeType: true,
|
||||
size: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
},
|
||||
} satisfies Prisma.SupportTicketInclude;
|
||||
|
||||
export type AdminSupportTicketRow = Prisma.SupportTicketGetPayload<{
|
||||
include: typeof adminSupportTicketInclude;
|
||||
}>;
|
||||
|
||||
export type AdminSupportTicketDetail = Prisma.SupportTicketGetPayload<{
|
||||
include: typeof adminSupportTicketDetailInclude;
|
||||
}>;
|
||||
|
||||
export async function getAdminSupportTickets(
|
||||
searchParams: Record<string, string | string[] | undefined>,
|
||||
) {
|
||||
const { page, skip, pageSize } = parsePage(searchParams, 30);
|
||||
const q = typeof searchParams.q === "string" ? searchParams.q.trim() : "";
|
||||
const status = typeof searchParams.status === "string" ? searchParams.status : "";
|
||||
const priority = typeof searchParams.priority === "string" ? searchParams.priority : "";
|
||||
|
||||
const where = {
|
||||
...(status
|
||||
? { status: status as "OPEN" | "USER_REPLIED" | "ADMIN_REPLIED" | "CLOSED" }
|
||||
: {}),
|
||||
...(priority ? { priority: priority as "LOW" | "NORMAL" | "HIGH" | "URGENT" } : {}),
|
||||
...(q
|
||||
? {
|
||||
OR: [
|
||||
{ subject: { contains: q, mode: "insensitive" as const } },
|
||||
{ category: { contains: q, mode: "insensitive" as const } },
|
||||
{ user: { email: { contains: q, mode: "insensitive" as const } } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
} satisfies Prisma.SupportTicketWhereInput;
|
||||
|
||||
const [tickets, total] = await Promise.all([
|
||||
prisma.supportTicket.findMany({
|
||||
where,
|
||||
include: adminSupportTicketInclude,
|
||||
orderBy: [{ updatedAt: "desc" }],
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.supportTicket.count({ where }),
|
||||
]);
|
||||
|
||||
return { tickets, total, page, pageSize, filters: { q, status, priority } };
|
||||
}
|
||||
|
||||
export async function getAdminSupportTicketDetail(ticketId: string) {
|
||||
return prisma.supportTicket.findUnique({
|
||||
where: { id: ticketId },
|
||||
include: adminSupportTicketDetailInclude,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { BellRing } from "lucide-react";
|
||||
import { runReminderTask } from "@/actions/admin/tasks";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function TaskLaunchPanel() {
|
||||
return (
|
||||
<div className="form-panel grid gap-3 md:grid-cols-3">
|
||||
<form action={runReminderTask} className="choice-card flex flex-col items-start gap-3 p-4">
|
||||
<span className="flex size-10 items-center justify-center rounded-[1rem] bg-amber-500/10 text-amber-700 dark:text-amber-300">
|
||||
<BellRing className="size-4" />
|
||||
</span>
|
||||
<div>
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
src/app/(admin)/admin/tasks/_components/task-runs-table.tsx
Normal file
99
src/app/(admin)/admin/tasks/_components/task-runs-table.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { batchRetryTaskRuns, retryTaskRun } from "@/actions/admin/tasks";
|
||||
import { BatchActionBar, BatchActionButton } from "@/components/admin/batch-action-bar";
|
||||
import { DataTableShell } from "@/components/admin/data-table-shell";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableBody,
|
||||
DataTableCell,
|
||||
DataTableHead,
|
||||
DataTableHeadCell,
|
||||
DataTableHeaderRow,
|
||||
DataTableRow,
|
||||
} from "@/components/shared/data-table";
|
||||
import { TaskStatusBadge, taskKindLabels } from "@/components/shared/domain-badges";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import type { AdminTaskRunRow } from "../tasks-data";
|
||||
|
||||
interface TaskRunsTableProps {
|
||||
tasks: AdminTaskRunRow[];
|
||||
}
|
||||
|
||||
export function TaskRunsTable({ tasks }: TaskRunsTableProps) {
|
||||
return (
|
||||
<DataTableShell
|
||||
isEmpty={tasks.length === 0}
|
||||
emptyTitle="暂无任务记录"
|
||||
emptyDescription="手动或定时任务执行后,会显示运行状态与错误信息。"
|
||||
toolbar={
|
||||
<BatchActionBar
|
||||
id="task-batch-form"
|
||||
action={batchRetryTaskRuns}
|
||||
label="批量重试失败任务"
|
||||
className="rounded-none bg-transparent"
|
||||
>
|
||||
<BatchActionButton>批量重试失败任务</BatchActionButton>
|
||||
</BatchActionBar>
|
||||
}
|
||||
>
|
||||
<DataTable aria-label="任务运行列表" className="min-w-[980px]">
|
||||
<DataTableHead>
|
||||
<DataTableHeaderRow>
|
||||
<DataTableHeadCell>选择</DataTableHeadCell>
|
||||
<DataTableHeadCell>时间</DataTableHeadCell>
|
||||
<DataTableHeadCell>任务</DataTableHeadCell>
|
||||
<DataTableHeadCell>状态</DataTableHeadCell>
|
||||
<DataTableHeadCell>操作者</DataTableHeadCell>
|
||||
<DataTableHeadCell>错误</DataTableHeadCell>
|
||||
<DataTableHeadCell className="text-right">操作</DataTableHeadCell>
|
||||
</DataTableHeaderRow>
|
||||
</DataTableHead>
|
||||
<DataTableBody>
|
||||
{tasks.map((task) => (
|
||||
<DataTableRow key={task.id}>
|
||||
<DataTableCell>
|
||||
{task.retryable && task.status === "FAILED" ? (
|
||||
<input
|
||||
form="task-batch-form"
|
||||
type="checkbox"
|
||||
name="taskIds"
|
||||
value={task.id}
|
||||
aria-label={`选择任务 ${task.title}`}
|
||||
/>
|
||||
) : null}
|
||||
</DataTableCell>
|
||||
<DataTableCell className="whitespace-nowrap text-muted-foreground">
|
||||
{formatDate(task.createdAt)}
|
||||
</DataTableCell>
|
||||
<DataTableCell>
|
||||
<p className="font-medium">{task.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{taskKindLabels[task.kind]}</p>
|
||||
</DataTableCell>
|
||||
<DataTableCell>
|
||||
<TaskStatusBadge status={task.status} />
|
||||
</DataTableCell>
|
||||
<DataTableCell>{task.triggeredBy?.email ?? "系统"}</DataTableCell>
|
||||
<DataTableCell className="max-w-lg whitespace-pre-wrap break-words text-xs text-muted-foreground">
|
||||
{task.errorMessage || "—"}
|
||||
</DataTableCell>
|
||||
<DataTableCell>
|
||||
<div className="flex justify-end">
|
||||
{task.retryable && task.status === "FAILED" && (
|
||||
<form
|
||||
action={async () => {
|
||||
"use server";
|
||||
await retryTaskRun(task.id);
|
||||
}}
|
||||
>
|
||||
<Button type="submit" size="sm" variant="outline">重试</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</DataTableCell>
|
||||
</DataTableRow>
|
||||
))}
|
||||
</DataTableBody>
|
||||
</DataTable>
|
||||
</DataTableShell>
|
||||
);
|
||||
}
|
||||
58
src/app/(admin)/admin/tasks/page.tsx
Normal file
58
src/app/(admin)/admin/tasks/page.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { Metadata } from "next";
|
||||
import { AdminFilterBar } from "@/components/admin/filter-bar";
|
||||
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||
import { Pagination } from "@/components/shared/pagination";
|
||||
import { TaskLaunchPanel } from "./_components/task-launch-panel";
|
||||
import { TaskRunsTable } from "./_components/task-runs-table";
|
||||
import { getAdminTaskRuns } from "./tasks-data";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "任务中心",
|
||||
description: "执行系统任务并跟踪任务执行历史。",
|
||||
};
|
||||
|
||||
export default async function TasksPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const { tasks, total, page, pageSize, filters } = await getAdminTaskRuns(await searchParams);
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
eyebrow="系统"
|
||||
title="任务中心"
|
||||
/>
|
||||
<TaskLaunchPanel />
|
||||
<AdminFilterBar
|
||||
q={filters.q}
|
||||
searchPlaceholder="搜索任务标题、错误信息、目标类型"
|
||||
selects={[
|
||||
{
|
||||
name: "kind",
|
||||
value: filters.kind,
|
||||
options: [
|
||||
{ label: "全部类型", value: "" },
|
||||
{ label: "提醒派发", value: "REMINDER_DISPATCH" },
|
||||
{ label: "订单重试", value: "ORDER_PROVISION_RETRY" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
value: filters.status,
|
||||
options: [
|
||||
{ label: "全部状态", value: "" },
|
||||
{ label: "待执行", value: "PENDING" },
|
||||
{ label: "运行中", value: "RUNNING" },
|
||||
{ label: "成功", value: "SUCCESS" },
|
||||
{ label: "失败", value: "FAILED" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<TaskRunsTable tasks={tasks} />
|
||||
<Pagination total={total} pageSize={pageSize} page={page} />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
55
src/app/(admin)/admin/tasks/tasks-data.ts
Normal file
55
src/app/(admin)/admin/tasks/tasks-data.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { parsePage } from "@/lib/utils";
|
||||
|
||||
const taskRunInclude = {
|
||||
triggeredBy: {
|
||||
select: { email: true },
|
||||
},
|
||||
} satisfies Prisma.TaskRunInclude;
|
||||
|
||||
export type AdminTaskRunRow = Prisma.TaskRunGetPayload<{
|
||||
include: typeof taskRunInclude;
|
||||
}>;
|
||||
|
||||
export async function getAdminTaskRuns(
|
||||
searchParams: Record<string, string | string[] | undefined>,
|
||||
) {
|
||||
const { page, skip, pageSize } = parsePage(searchParams, 30);
|
||||
const q = typeof searchParams.q === "string" ? searchParams.q.trim() : "";
|
||||
const kind = typeof searchParams.kind === "string" ? searchParams.kind : "";
|
||||
const status = typeof searchParams.status === "string" ? searchParams.status : "";
|
||||
|
||||
const where = {
|
||||
...(kind
|
||||
? {
|
||||
kind: kind as
|
||||
| "REMINDER_DISPATCH"
|
||||
| "ORDER_PROVISION_RETRY",
|
||||
}
|
||||
: {}),
|
||||
...(status ? { status: status as "PENDING" | "RUNNING" | "SUCCESS" | "FAILED" } : {}),
|
||||
...(q
|
||||
? {
|
||||
OR: [
|
||||
{ title: { contains: q, mode: "insensitive" as const } },
|
||||
{ errorMessage: { contains: q, mode: "insensitive" as const } },
|
||||
{ targetType: { contains: q, mode: "insensitive" as const } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
} satisfies Prisma.TaskRunWhereInput;
|
||||
|
||||
const [tasks, total] = await Promise.all([
|
||||
prisma.taskRun.findMany({
|
||||
where,
|
||||
include: taskRunInclude,
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.taskRun.count({ where }),
|
||||
]);
|
||||
|
||||
return { tasks, total, page, pageSize, filters: { q, kind, status } };
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { DataTableShell } from "@/components/admin/data-table-shell";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableBody,
|
||||
DataTableCell,
|
||||
DataTableHead,
|
||||
DataTableHeadCell,
|
||||
DataTableHeaderRow,
|
||||
DataTableRow,
|
||||
} from "@/components/shared/data-table";
|
||||
import { ActiveStatusBadge, StatusBadge } from "@/components/shared/status-badge";
|
||||
import { cn, formatBytes } from "@/lib/utils";
|
||||
import type { TrafficClientRow } from "../traffic-data";
|
||||
|
||||
interface TrafficClientsTableProps {
|
||||
clients: TrafficClientRow[];
|
||||
}
|
||||
|
||||
function TrafficUsageBar({ used, limit }: { used: number; limit: number | null }) {
|
||||
if (!limit) return null;
|
||||
|
||||
const percent = Math.min(Math.round((used / limit) * 100), 100);
|
||||
return (
|
||||
<div className="mt-2 h-1.5 w-24 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full",
|
||||
percent > 90 ? "bg-destructive" : percent > 70 ? "bg-amber-500" : "bg-emerald-500",
|
||||
)}
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrafficClientsTable({ clients }: TrafficClientsTableProps) {
|
||||
const visibleClients = clients.filter((client) => client.subscription != null);
|
||||
|
||||
return (
|
||||
<DataTableShell
|
||||
isEmpty={visibleClients.length === 0}
|
||||
emptyTitle="暂无流量数据"
|
||||
emptyDescription="客户端绑定订阅并同步流量后,会显示在这里。"
|
||||
>
|
||||
<DataTable aria-label="流量客户端列表" className="min-w-[760px]">
|
||||
<DataTableHead>
|
||||
<DataTableHeaderRow>
|
||||
<DataTableHeadCell>用户</DataTableHeadCell>
|
||||
<DataTableHeadCell>节点</DataTableHeadCell>
|
||||
<DataTableHeadCell>协议</DataTableHeadCell>
|
||||
<DataTableHeadCell>上传</DataTableHeadCell>
|
||||
<DataTableHeadCell>下载</DataTableHeadCell>
|
||||
<DataTableHeadCell>已用/总量</DataTableHeadCell>
|
||||
<DataTableHeadCell>状态</DataTableHeadCell>
|
||||
</DataTableHeaderRow>
|
||||
</DataTableHead>
|
||||
<DataTableBody>
|
||||
{visibleClients.map((client) => {
|
||||
const subscription = client.subscription!;
|
||||
const used = Number(subscription.trafficUsed);
|
||||
const limit = subscription.trafficLimit ? Number(subscription.trafficLimit) : null;
|
||||
|
||||
return (
|
||||
<DataTableRow key={client.id}>
|
||||
<DataTableCell className="max-w-56 whitespace-normal break-all">
|
||||
<p className="font-medium">{client.user.email}</p>
|
||||
<p className="text-xs text-muted-foreground">{client.email}</p>
|
||||
</DataTableCell>
|
||||
<DataTableCell>{client.inbound.server.name}</DataTableCell>
|
||||
<DataTableCell>
|
||||
<StatusBadge tone="neutral">{client.inbound.protocol}</StatusBadge>
|
||||
</DataTableCell>
|
||||
<DataTableCell className="tabular-nums">{formatBytes(client.trafficUp)}</DataTableCell>
|
||||
<DataTableCell className="tabular-nums">{formatBytes(client.trafficDown)}</DataTableCell>
|
||||
<DataTableCell>
|
||||
<span className="tabular-nums">
|
||||
{formatBytes(used)} / {limit ? formatBytes(limit) : "无限"}
|
||||
</span>
|
||||
<TrafficUsageBar used={used} limit={limit} />
|
||||
</DataTableCell>
|
||||
<DataTableCell>
|
||||
<ActiveStatusBadge active={client.isEnabled} activeLabel="启用" inactiveLabel="禁用" />
|
||||
</DataTableCell>
|
||||
</DataTableRow>
|
||||
);
|
||||
})}
|
||||
</DataTableBody>
|
||||
</DataTable>
|
||||
</DataTableShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Activity, Power, Users } from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import type { TrafficOverview } from "../traffic-data";
|
||||
|
||||
function OverviewCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "default" | "success" | "warning";
|
||||
}) {
|
||||
const toneClass =
|
||||
tone === "success"
|
||||
? "bg-emerald-500/10 text-emerald-600"
|
||||
: tone === "warning"
|
||||
? "bg-amber-500/10 text-amber-700"
|
||||
: "bg-primary/10 text-primary";
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 pt-4">
|
||||
<span className={`flex size-10 items-center justify-center rounded-lg ${toneClass}`}>
|
||||
{icon}
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="text-lg font-semibold tabular-nums">{value}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrafficOverviewCards({ overview }: { overview: TrafficOverview }) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<OverviewCard icon={<Users className="size-4" />} label="客户端总数" value={`${overview.totalClients}`} />
|
||||
<OverviewCard icon={<Power className="size-4" />} label="启用中" value={`${overview.enabledClients}`} tone="success" />
|
||||
<OverviewCard icon={<Power className="size-4" />} label="已禁用" value={`${overview.disabledClients}`} />
|
||||
<OverviewCard icon={<Activity className="size-4" />} label="24h 有流量" value={`${overview.activeClients24h}`} tone="warning" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
src/app/(admin)/admin/traffic/_components/trend-section.tsx
Normal file
35
src/app/(admin)/admin/traffic/_components/trend-section.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Activity } from "lucide-react";
|
||||
import { TrafficTrendChart } from "@/components/shared/traffic-trend-chart";
|
||||
import type { TrafficTrendPoint } from "../traffic-data";
|
||||
|
||||
export function TrendSection({ trend }: { trend: TrafficTrendPoint[] }) {
|
||||
return (
|
||||
<div className="surface-card overflow-hidden rounded-xl p-4">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<span className="flex size-8 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Activity className="size-4" />
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold tracking-tight">近 14 天全站流量趋势</h3>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">用于观察全站用量变化和同步质量。</p>
|
||||
</div>
|
||||
</div>
|
||||
<TrafficTrendChart data={trend} color="oklch(0.52 0.13 172)" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrendSectionSkeleton() {
|
||||
return (
|
||||
<div className="surface-card rounded-xl p-4">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="size-8 animate-pulse rounded-lg bg-muted" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-40 animate-pulse rounded bg-muted" />
|
||||
<div className="h-3 w-56 animate-pulse rounded bg-muted/70" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[200px] animate-pulse rounded-lg bg-muted/30" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
src/app/(admin)/admin/traffic/page.tsx
Normal file
68
src/app/(admin)/admin/traffic/page.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { Metadata } from "next";
|
||||
import { AdminFilterBar } from "@/components/admin/filter-bar";
|
||||
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||
import { Pagination } from "@/components/shared/pagination";
|
||||
import { TrafficSyncButton } from "./sync-button";
|
||||
import { TrafficOverviewCards } from "./_components/traffic-overview-cards";
|
||||
import { TrendSection } from "./_components/trend-section";
|
||||
import { TrafficClientsTable } from "./_components/traffic-clients-table";
|
||||
import { getSiteTrafficTrend, getTrafficClients, getTrafficOverview } from "./traffic-data";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "流量监控",
|
||||
description: "查看客户端流量并执行同步任务。",
|
||||
};
|
||||
|
||||
export default async function TrafficPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const [{ clients, total, page, pageSize, filters }, trend, overview] = await Promise.all([
|
||||
getTrafficClients(resolvedSearchParams),
|
||||
getSiteTrafficTrend(),
|
||||
getTrafficOverview(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
eyebrow="基础设施"
|
||||
title="流量监控"
|
||||
actions={<TrafficSyncButton />}
|
||||
/>
|
||||
<AdminFilterBar
|
||||
q={filters.q}
|
||||
searchPlaceholder="搜索用户邮箱、客户端邮箱、节点名"
|
||||
selects={[
|
||||
{
|
||||
name: "status",
|
||||
value: filters.status,
|
||||
options: [
|
||||
{ label: "全部状态", value: "" },
|
||||
{ label: "启用", value: "enabled" },
|
||||
{ label: "禁用", value: "disabled" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "protocol",
|
||||
value: filters.protocol,
|
||||
options: [
|
||||
{ label: "全部协议", value: "" },
|
||||
{ label: "VMESS", value: "VMESS" },
|
||||
{ label: "VLESS", value: "VLESS" },
|
||||
{ label: "TROJAN", value: "TROJAN" },
|
||||
{ label: "SHADOWSOCKS", value: "SHADOWSOCKS" },
|
||||
{ label: "HYSTERIA2", value: "HYSTERIA2" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<TrafficOverviewCards overview={overview} />
|
||||
<TrendSection trend={trend} />
|
||||
<TrafficClientsTable clients={clients} />
|
||||
<Pagination total={total} pageSize={pageSize} page={page} />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
36
src/app/(admin)/admin/traffic/sync-button.tsx
Normal file
36
src/app/(admin)/admin/traffic/sync-button.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { syncTrafficViews } from "@/actions/admin/traffic";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function TrafficSyncButton() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
return (
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await syncTrafficViews();
|
||||
const failed = result.failed > 0 ? `,失败 ${result.failed} 个` : "";
|
||||
toast.success(`已同步 ${result.synced} 个客户端${failed}`);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "同步失败"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{loading ? "同步中..." : "同步 3x-ui 流量"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
111
src/app/(admin)/admin/traffic/traffic-data.ts
Normal file
111
src/app/(admin)/admin/traffic/traffic-data.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { parsePage } from "@/lib/utils";
|
||||
|
||||
const trafficClientInclude = {
|
||||
user: true,
|
||||
inbound: { include: { server: true } },
|
||||
subscription: { include: { plan: true } },
|
||||
} satisfies Prisma.NodeClientInclude;
|
||||
|
||||
export type TrafficClientRow = Prisma.NodeClientGetPayload<{
|
||||
include: typeof trafficClientInclude;
|
||||
}>;
|
||||
|
||||
export interface TrafficTrendPoint {
|
||||
date: string;
|
||||
valueGb: number;
|
||||
}
|
||||
|
||||
export interface TrafficOverview {
|
||||
totalClients: number;
|
||||
enabledClients: number;
|
||||
disabledClients: number;
|
||||
activeClients24h: number;
|
||||
}
|
||||
|
||||
export async function getTrafficClients(
|
||||
searchParams: Record<string, string | string[] | undefined>,
|
||||
) {
|
||||
const { page, skip, pageSize } = parsePage(searchParams);
|
||||
const q = typeof searchParams.q === "string" ? searchParams.q.trim() : "";
|
||||
const status = typeof searchParams.status === "string" ? searchParams.status : "";
|
||||
const protocol = typeof searchParams.protocol === "string" ? searchParams.protocol : "";
|
||||
|
||||
const where = {
|
||||
...(status ? { isEnabled: status === "enabled" } : {}),
|
||||
...(protocol
|
||||
? {
|
||||
inbound: {
|
||||
protocol: protocol as "VMESS" | "VLESS" | "TROJAN" | "SHADOWSOCKS" | "HYSTERIA2",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(q
|
||||
? {
|
||||
OR: [
|
||||
{ user: { email: { contains: q, mode: "insensitive" as const } } },
|
||||
{ inbound: { server: { name: { contains: q, mode: "insensitive" as const } } } },
|
||||
{ email: { contains: q, mode: "insensitive" as const } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
} satisfies Prisma.NodeClientWhereInput;
|
||||
|
||||
const [clients, total] = await Promise.all([
|
||||
prisma.nodeClient.findMany({
|
||||
where,
|
||||
include: trafficClientInclude,
|
||||
orderBy: { updatedAt: "desc" },
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.nodeClient.count({ where }),
|
||||
]);
|
||||
|
||||
return { clients, total, page, pageSize, filters: { q, status, protocol } };
|
||||
}
|
||||
|
||||
export async function getSiteTrafficTrend(days = 14): Promise<TrafficTrendPoint[]> {
|
||||
const trafficWindowStart = new Date();
|
||||
trafficWindowStart.setDate(trafficWindowStart.getDate() - days);
|
||||
|
||||
const logs = await prisma.trafficLog.findMany({
|
||||
where: { timestamp: { gte: trafficWindowStart } },
|
||||
select: { timestamp: true, upload: true, download: true },
|
||||
orderBy: { timestamp: "asc" },
|
||||
});
|
||||
|
||||
const trendMap = new Map<string, number>();
|
||||
for (const log of logs) {
|
||||
const day = new Date(log.timestamp).toISOString().slice(5, 10);
|
||||
const valueGb = Number(log.upload + log.download) / 1024 ** 3;
|
||||
trendMap.set(day, (trendMap.get(day) ?? 0) + valueGb);
|
||||
}
|
||||
|
||||
return Array.from(trendMap.entries()).map(([date, valueGb]) => ({
|
||||
date,
|
||||
valueGb: Number(valueGb.toFixed(2)),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getTrafficOverview(): Promise<TrafficOverview> {
|
||||
const since = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
|
||||
const [totalClients, enabledClients, activeClientIds] = await Promise.all([
|
||||
prisma.nodeClient.count(),
|
||||
prisma.nodeClient.count({ where: { isEnabled: true } }),
|
||||
prisma.trafficLog.findMany({
|
||||
where: { timestamp: { gte: since } },
|
||||
select: { clientId: true },
|
||||
distinct: ["clientId"],
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
totalClients,
|
||||
enabledClients,
|
||||
disabledClients: totalClients - enabledClients,
|
||||
activeClients24h: activeClientIds.length,
|
||||
};
|
||||
}
|
||||
103
src/app/(admin)/admin/users/_components/users-table.tsx
Normal file
103
src/app/(admin)/admin/users/_components/users-table.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { batchUpdateUserStatus } from "@/actions/admin/users";
|
||||
import { BatchActionBar, BatchActionButton } from "@/components/admin/batch-action-bar";
|
||||
import { DataTableShell } from "@/components/admin/data-table-shell";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableBody,
|
||||
DataTableCell,
|
||||
DataTableHead,
|
||||
DataTableHeadCell,
|
||||
DataTableHeaderRow,
|
||||
DataTableRow,
|
||||
} from "@/components/shared/data-table";
|
||||
import { UserRoleBadge, UserStatusBadge } from "@/components/shared/domain-badges";
|
||||
import { formatDateShort } from "@/lib/utils";
|
||||
import { UserActions } from "../user-actions";
|
||||
import type { AdminUserRow } from "../users-data";
|
||||
|
||||
interface UsersTableProps {
|
||||
users: AdminUserRow[];
|
||||
}
|
||||
|
||||
export function UsersTable({ users }: UsersTableProps) {
|
||||
return (
|
||||
<DataTableShell
|
||||
isEmpty={users.length === 0}
|
||||
emptyTitle="暂无用户"
|
||||
emptyDescription="创建用户或等待新用户注册后,会显示在这里。"
|
||||
toolbar={
|
||||
<BatchActionBar
|
||||
id="user-batch-form"
|
||||
action={batchUpdateUserStatus}
|
||||
className="rounded-none bg-transparent"
|
||||
>
|
||||
<BatchActionButton name="status" value="ACTIVE">
|
||||
批量启用
|
||||
</BatchActionButton>
|
||||
<BatchActionButton name="status" value="DISABLED">
|
||||
批量禁用
|
||||
</BatchActionButton>
|
||||
<BatchActionButton name="status" value="BANNED" destructive>
|
||||
批量封禁
|
||||
</BatchActionButton>
|
||||
</BatchActionBar>
|
||||
}
|
||||
>
|
||||
<DataTable aria-label="用户列表" className="min-w-[980px]">
|
||||
<DataTableHead>
|
||||
<DataTableHeaderRow>
|
||||
<DataTableHeadCell>选择</DataTableHeadCell>
|
||||
<DataTableHeadCell>邮箱</DataTableHeadCell>
|
||||
<DataTableHeadCell>昵称</DataTableHeadCell>
|
||||
<DataTableHeadCell>邀请码</DataTableHeadCell>
|
||||
<DataTableHeadCell>角色</DataTableHeadCell>
|
||||
<DataTableHeadCell>状态</DataTableHeadCell>
|
||||
<DataTableHeadCell>订阅数</DataTableHeadCell>
|
||||
<DataTableHeadCell>邀请数据</DataTableHeadCell>
|
||||
<DataTableHeadCell>注册时间</DataTableHeadCell>
|
||||
<DataTableHeadCell className="text-right">操作</DataTableHeadCell>
|
||||
</DataTableHeaderRow>
|
||||
</DataTableHead>
|
||||
<DataTableBody>
|
||||
{users.map((user) => (
|
||||
<DataTableRow key={user.id}>
|
||||
<DataTableCell>
|
||||
<input
|
||||
form="user-batch-form"
|
||||
type="checkbox"
|
||||
name="userIds"
|
||||
value={user.id}
|
||||
aria-label={`选择用户 ${user.email}`}
|
||||
/>
|
||||
</DataTableCell>
|
||||
<DataTableCell className="max-w-56 whitespace-normal break-all font-medium">{user.email}</DataTableCell>
|
||||
<DataTableCell className="max-w-44 whitespace-normal break-words text-muted-foreground">{user.name || "—"}</DataTableCell>
|
||||
<DataTableCell className="max-w-40 whitespace-normal break-all text-muted-foreground">{user.inviteCode || "—"}</DataTableCell>
|
||||
<DataTableCell>
|
||||
<UserRoleBadge role={user.role} />
|
||||
</DataTableCell>
|
||||
<DataTableCell>
|
||||
<UserStatusBadge status={user.status} />
|
||||
</DataTableCell>
|
||||
<DataTableCell className="tabular-nums">{user._count.subscriptions}</DataTableCell>
|
||||
<DataTableCell className="text-xs text-muted-foreground">
|
||||
<div className="space-y-1">
|
||||
<p>邀请了 {user._count.invitedUsers} 人</p>
|
||||
<p>邀请人:{user.invitedBy?.email ?? "—"}</p>
|
||||
</div>
|
||||
</DataTableCell>
|
||||
<DataTableCell className="whitespace-nowrap text-muted-foreground">
|
||||
{formatDateShort(user.createdAt)}
|
||||
</DataTableCell>
|
||||
<DataTableCell>
|
||||
<div className="flex justify-end">
|
||||
<UserActions user={user} />
|
||||
</div>
|
||||
</DataTableCell>
|
||||
</DataTableRow>
|
||||
))}
|
||||
</DataTableBody>
|
||||
</DataTable>
|
||||
</DataTableShell>
|
||||
);
|
||||
}
|
||||
57
src/app/(admin)/admin/users/page.tsx
Normal file
57
src/app/(admin)/admin/users/page.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { Metadata } from "next";
|
||||
import { AdminFilterBar } from "@/components/admin/filter-bar";
|
||||
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||
import { Pagination } from "@/components/shared/pagination";
|
||||
import { UserForm } from "./user-form";
|
||||
import { UsersTable } from "./_components/users-table";
|
||||
import { getAdminUsers } from "./users-data";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "用户管理",
|
||||
description: "管理用户身份、状态与基础信息。",
|
||||
};
|
||||
|
||||
export default async function UsersPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const { users, total, page, pageSize, filters } = await getAdminUsers(await searchParams);
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
eyebrow="用户支持"
|
||||
title="用户管理"
|
||||
actions={<UserForm />}
|
||||
/>
|
||||
<AdminFilterBar
|
||||
q={filters.q}
|
||||
searchPlaceholder="搜索邮箱、昵称、邀请码、邀请人"
|
||||
selects={[
|
||||
{
|
||||
name: "role",
|
||||
value: filters.role,
|
||||
options: [
|
||||
{ label: "全部角色", value: "" },
|
||||
{ label: "管理员", value: "ADMIN" },
|
||||
{ label: "普通用户", value: "USER" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
value: filters.status,
|
||||
options: [
|
||||
{ label: "全部状态", value: "" },
|
||||
{ label: "正常", value: "ACTIVE" },
|
||||
{ label: "禁用", value: "DISABLED" },
|
||||
{ label: "封禁", value: "BANNED" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<UsersTable users={users} />
|
||||
<Pagination total={total} pageSize={pageSize} page={page} />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
76
src/app/(admin)/admin/users/user-actions.tsx
Normal file
76
src/app/(admin)/admin/users/user-actions.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { updateUserStatus, deleteUser } from "@/actions/admin/users";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
import { toast } from "sonner";
|
||||
import type { User } from "@prisma/client";
|
||||
import { UserForm } from "./user-form";
|
||||
|
||||
export function UserActions({ user }: { user: User }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<UserForm user={user} triggerLabel="编辑" triggerVariant="outline" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant="ghost" size="sm" />}>...</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{user.status !== "ACTIVE" && (
|
||||
<DropdownMenuItem onClick={async () => {
|
||||
try {
|
||||
await updateUserStatus(user.id, "ACTIVE");
|
||||
toast.success("已启用");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "启用失败"));
|
||||
}
|
||||
}}>
|
||||
启用
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{user.status !== "DISABLED" && (
|
||||
<DropdownMenuItem onClick={async () => {
|
||||
try {
|
||||
await updateUserStatus(user.id, "DISABLED");
|
||||
toast.success("已禁用");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "禁用失败"));
|
||||
}
|
||||
}}>
|
||||
禁用
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{user.status !== "BANNED" && (
|
||||
<DropdownMenuItem onClick={async () => {
|
||||
try {
|
||||
await updateUserStatus(user.id, "BANNED");
|
||||
toast.success("已封禁");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "封禁失败"));
|
||||
}
|
||||
}}>
|
||||
封禁
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ConfirmActionButton
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
title="删除这个用户?"
|
||||
description="用户账号及相关数据会被清理。请确认已完成必要备份。"
|
||||
confirmLabel="删除用户"
|
||||
successMessage="用户已删除"
|
||||
errorMessage="删除失败"
|
||||
onConfirm={() => deleteUser(user.id)}
|
||||
>
|
||||
删除
|
||||
</ConfirmActionButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
src/app/(admin)/admin/users/user-form.tsx
Normal file
92
src/app/(admin)/admin/users/user-form.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { User } from "@prisma/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { createUser, updateUser } from "@/actions/admin/users";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function UserForm({
|
||||
user,
|
||||
triggerLabel,
|
||||
triggerVariant = "default",
|
||||
}: {
|
||||
user?: User;
|
||||
triggerLabel?: string;
|
||||
triggerVariant?: "default" | "outline" | "ghost";
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const isEdit = Boolean(user);
|
||||
|
||||
async function handleSubmit(formData: FormData) {
|
||||
try {
|
||||
if (user) {
|
||||
await updateUser(user.id, formData);
|
||||
toast.success("用户已更新");
|
||||
} else {
|
||||
await createUser(formData);
|
||||
toast.success("用户创建成功");
|
||||
}
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, isEdit ? "更新失败" : "创建失败"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger render={<Button variant={triggerVariant} size={isEdit ? "sm" : "default"} />}>
|
||||
{triggerLabel ?? (isEdit ? "编辑" : "创建用户")}
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? "编辑用户" : "创建用户"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form action={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<Label>邮箱</Label>
|
||||
<Input name="email" type="email" defaultValue={user?.email} required />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{isEdit ? "新密码(可留空)" : "密码"}</Label>
|
||||
<Input
|
||||
name="password"
|
||||
type="password"
|
||||
required={!isEdit}
|
||||
minLength={6}
|
||||
placeholder={isEdit ? "留空则保持不变" : undefined}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>昵称</Label>
|
||||
<Input name="name" defaultValue={user?.name ?? ""} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>角色</Label>
|
||||
<select
|
||||
name="role"
|
||||
defaultValue={user?.role ?? "USER"}
|
||||
className="h-10 w-full px-3 text-sm outline-none"
|
||||
>
|
||||
<option value="USER">普通用户</option>
|
||||
<option value="ADMIN">管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
<Button type="submit" className="w-full">
|
||||
{isEdit ? "保存" : "创建"}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
59
src/app/(admin)/admin/users/users-data.ts
Normal file
59
src/app/(admin)/admin/users/users-data.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { parsePage } from "@/lib/utils";
|
||||
|
||||
const adminUserInclude = {
|
||||
invitedBy: {
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
subscriptions: true,
|
||||
orders: true,
|
||||
invitedUsers: true,
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.UserInclude;
|
||||
|
||||
export type AdminUserRow = Prisma.UserGetPayload<{
|
||||
include: typeof adminUserInclude;
|
||||
}>;
|
||||
|
||||
export async function getAdminUsers(
|
||||
searchParams: Record<string, string | string[] | undefined>,
|
||||
) {
|
||||
const { page, skip, pageSize } = parsePage(searchParams);
|
||||
const q = typeof searchParams.q === "string" ? searchParams.q.trim() : "";
|
||||
const role = typeof searchParams.role === "string" ? searchParams.role : "";
|
||||
const status = typeof searchParams.status === "string" ? searchParams.status : "";
|
||||
|
||||
const where = {
|
||||
...(role ? { role: role as "ADMIN" | "USER" } : {}),
|
||||
...(status ? { status: status as "ACTIVE" | "DISABLED" | "BANNED" } : {}),
|
||||
...(q
|
||||
? {
|
||||
OR: [
|
||||
{ email: { contains: q, mode: "insensitive" as const } },
|
||||
{ name: { contains: q, mode: "insensitive" as const } },
|
||||
{ inviteCode: { contains: q, mode: "insensitive" as const } },
|
||||
{ invitedBy: { email: { contains: q, mode: "insensitive" as const } } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
} satisfies Prisma.UserWhereInput;
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
where,
|
||||
include: adminUserInclude,
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.user.count({ where }),
|
||||
]);
|
||||
|
||||
return { users, total, page, pageSize, filters: { q, role, status } };
|
||||
}
|
||||
48
src/app/(admin)/layout.tsx
Normal file
48
src/app/(admin)/layout.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AdminSidebar } from "@/components/admin/sidebar";
|
||||
import { AdminMobileNav } from "@/components/admin/mobile-nav";
|
||||
import { AnnouncementLoader } from "@/components/announcements/announcement-loader";
|
||||
import { PageTransition } from "@/components/shared/page-transition";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "管理后台",
|
||||
template: "%s | J-Board",
|
||||
},
|
||||
description: "管理用户、订单、套餐、节点和系统配置。",
|
||||
};
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
if (session.user.role !== "ADMIN") {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[100dvh] overflow-hidden p-0 md:p-3">
|
||||
<div className="hidden shrink-0 md:flex">
|
||||
<AdminSidebar />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden md:pl-3">
|
||||
<AdminMobileNav />
|
||||
<main className="flex-1 overflow-auto px-3 py-4 sm:px-5 sm:py-6 md:pt-0 lg:px-7 lg:pb-7">
|
||||
<Suspense fallback={null}>
|
||||
<AnnouncementLoader userId={session.user.id} role="ADMIN" />
|
||||
</Suspense>
|
||||
<PageTransition>{children}</PageTransition>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
src/app/(auth)/_components/auth-shell.tsx
Normal file
53
src/app/(auth)/_components/auth-shell.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PublicNotice } from "../public-notice";
|
||||
|
||||
export function AuthShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<main className="grid min-h-[100dvh] place-items-center px-4 py-10">
|
||||
<div className="w-full">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<PublicNotice />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthCard({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card className={cn("mx-auto w-full max-w-md rounded-xl p-1", className)}>
|
||||
{(title || description) && (
|
||||
<CardHeader className="space-y-2 pt-6 text-center">
|
||||
<div className="mx-auto flex size-10 items-center justify-center rounded-lg bg-primary text-sm font-bold text-primary-foreground">
|
||||
S
|
||||
</div>
|
||||
{title && <h1 className="text-display text-2xl font-semibold">{title}</h1>}
|
||||
{description && <p className="text-sm leading-6 text-muted-foreground">{description}</p>}
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent className="pb-6">{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthErrorMessage({ message }: { message: string }) {
|
||||
if (!message) return null;
|
||||
return (
|
||||
<div className="rounded-2xl border border-destructive/15 bg-destructive/10 px-3 py-2 text-center text-sm font-medium text-destructive">
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
src/app/(auth)/layout.tsx
Normal file
34
src/app/(auth)/layout.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AnnouncementLoader } from "@/components/announcements/announcement-loader";
|
||||
import { PageTransition } from "@/components/shared/page-transition";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "登录与注册",
|
||||
template: "%s | J-Board",
|
||||
},
|
||||
description: "登录或注册 J-Board 账号。",
|
||||
};
|
||||
|
||||
export default async function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (session) {
|
||||
redirect(session.user.role === "ADMIN" ? "/admin/dashboard" : "/dashboard");
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Suspense fallback={null}>
|
||||
<AnnouncementLoader />
|
||||
</Suspense>
|
||||
<PageTransition>{children}</PageTransition>
|
||||
</>
|
||||
);
|
||||
}
|
||||
71
src/app/(auth)/login/login-page-client.tsx
Normal file
71
src/app/(auth)/login/login-page-client.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import type { FormEvent } from "react";
|
||||
import Link from "next/link";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { TurnstileWidget } from "@/components/shared/turnstile-widget";
|
||||
import { AuthCard, AuthErrorMessage, AuthShell } from "../_components/auth-shell";
|
||||
|
||||
export function LoginPageClient({ siteKey }: { siteKey?: string | null }) {
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [turnstileToken, setTurnstileToken] = useState("");
|
||||
|
||||
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (siteKey && !turnstileToken) {
|
||||
setError("请完成人机验证");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const result = await signIn("credentials", {
|
||||
email: formData.get("email"),
|
||||
password: formData.get("password"),
|
||||
turnstileToken,
|
||||
redirect: false,
|
||||
});
|
||||
setLoading(false);
|
||||
if (result?.error) {
|
||||
setError("邮箱或密码错误");
|
||||
} else {
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell>
|
||||
<AuthCard title="J-Board" description="登录你的 JB面板账户">
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<AuthErrorMessage message={error} />
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">邮箱</Label>
|
||||
<Input id="email" name="email" type="email" autoComplete="email" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input id="password" name="password" type="password" autoComplete="current-password" required />
|
||||
</div>
|
||||
<TurnstileWidget siteKey={siteKey} onSuccess={setTurnstileToken} />
|
||||
<Button type="submit" className="w-full" size="lg" disabled={loading}>
|
||||
{loading ? "登录中..." : "登录"}
|
||||
</Button>
|
||||
</form>
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
没有账户?{" "}
|
||||
<Link href="/register" className="font-medium text-primary hover:underline">
|
||||
注册
|
||||
</Link>
|
||||
</p>
|
||||
</AuthCard>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
13
src/app/(auth)/login/page.tsx
Normal file
13
src/app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getAppConfig } from "@/services/app-config";
|
||||
import { LoginPageClient } from "./login-page-client";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "登录",
|
||||
description: "登录 J-Board 账户并进入用户中心。",
|
||||
};
|
||||
|
||||
export default async function LoginPage() {
|
||||
const config = await getAppConfig();
|
||||
return <LoginPageClient siteKey={config.turnstileSiteKey} />;
|
||||
}
|
||||
43
src/app/(auth)/public-notice.tsx
Normal file
43
src/app/(auth)/public-notice.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { AlertTriangle, Bell } from "lucide-react";
|
||||
import { fetchJson } from "@/lib/fetch-json";
|
||||
|
||||
interface PublicInfo {
|
||||
maintenanceNotice: string | null;
|
||||
siteNotice: string | null;
|
||||
}
|
||||
|
||||
export function PublicNotice() {
|
||||
const [info, setInfo] = useState<PublicInfo | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchJson<PublicInfo>("/api/public/app-info")
|
||||
.then(setInfo)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
if (!info) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-4 space-y-3">
|
||||
{info.maintenanceNotice && (
|
||||
<div className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-3 py-2.5 text-sm leading-6 text-amber-900 dark:text-amber-200">
|
||||
<div className="flex gap-2">
|
||||
<AlertTriangle className="mt-1 size-4 shrink-0" />
|
||||
<span>{info.maintenanceNotice}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{info.siteNotice && (
|
||||
<div className="rounded-lg border border-border bg-card px-3 py-2.5 text-sm leading-6 text-muted-foreground">
|
||||
<div className="flex gap-2">
|
||||
<Bell className="mt-1 size-4 shrink-0 text-primary" />
|
||||
<span>{info.siteNotice}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
src/app/(auth)/register/page.tsx
Normal file
13
src/app/(auth)/register/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getAppConfig } from "@/services/app-config";
|
||||
import { RegisterPageClient } from "./register-page-client";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "注册",
|
||||
description: "创建 J-Board 新账户并开始订阅服务。",
|
||||
};
|
||||
|
||||
export default async function RegisterPage() {
|
||||
const config = await getAppConfig();
|
||||
return <RegisterPageClient siteKey={config.turnstileSiteKey} />;
|
||||
}
|
||||
113
src/app/(auth)/register/register-page-client.tsx
Normal file
113
src/app/(auth)/register/register-page-client.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import type { FormEvent } from "react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { TurnstileWidget } from "@/components/shared/turnstile-widget";
|
||||
import { AuthCard, AuthErrorMessage, AuthShell } from "../_components/auth-shell";
|
||||
|
||||
export function RegisterPageClient({ siteKey }: { siteKey?: string | null }) {
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [turnstileToken, setTurnstileToken] = useState("");
|
||||
|
||||
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (siteKey && !turnstileToken) {
|
||||
setError("请完成人机验证");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const password = formData.get("password") as string;
|
||||
const confirmPassword = formData.get("confirmPassword") as string;
|
||||
if (password !== confirmPassword) {
|
||||
setError("两次密码不一致");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: formData.get("email"),
|
||||
password: formData.get("password"),
|
||||
name: formData.get("name"),
|
||||
inviteCode: formData.get("inviteCode"),
|
||||
turnstileToken,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
setError(data.error || "注册失败");
|
||||
} else {
|
||||
setSuccess(true);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<AuthShell>
|
||||
<AuthCard>
|
||||
<div className="space-y-4 py-3 text-center">
|
||||
<div className="text-4xl" aria-hidden="true">🎉</div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">注册成功</h1>
|
||||
<p className="text-sm text-muted-foreground">账户已创建,请登录。</p>
|
||||
<Link href="/login" className={buttonVariants({ size: "lg", className: "w-full" })}>
|
||||
去登录
|
||||
</Link>
|
||||
</div>
|
||||
</AuthCard>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell>
|
||||
<AuthCard title="J-Board" description="创建 JB面板账户">
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<AuthErrorMessage message={error} />
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">昵称</Label>
|
||||
<Input id="name" name="name" autoComplete="name" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">邮箱</Label>
|
||||
<Input id="email" name="email" type="email" autoComplete="email" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input id="password" name="password" type="password" autoComplete="new-password" required minLength={6} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">确认密码</Label>
|
||||
<Input id="confirmPassword" name="confirmPassword" type="password" autoComplete="new-password" required minLength={6} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="inviteCode">邀请码(可选)</Label>
|
||||
<Input id="inviteCode" name="inviteCode" autoComplete="off" />
|
||||
</div>
|
||||
<TurnstileWidget siteKey={siteKey} onSuccess={setTurnstileToken} />
|
||||
<Button type="submit" className="w-full" size="lg" disabled={loading}>
|
||||
{loading ? "注册中..." : "注册"}
|
||||
</Button>
|
||||
</form>
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
已有账户?{" "}
|
||||
<Link href="/login" className="font-medium text-primary hover:underline">
|
||||
登录
|
||||
</Link>
|
||||
</p>
|
||||
</AuthCard>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
30
src/app/(payment)/layout.tsx
Normal file
30
src/app/(payment)/layout.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "支付中心",
|
||||
template: "%s | J-Board",
|
||||
},
|
||||
description: "选择支付方式并完成订单结算。",
|
||||
};
|
||||
|
||||
export default async function PaymentLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (session.user.role === "ADMIN") {
|
||||
redirect("/admin/dashboard");
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { CopyButton } from "@/components/shared/copy-button";
|
||||
import { StatusBadge } from "@/components/shared/status-badge";
|
||||
import type { PaymentInfo } from "../payment-types";
|
||||
|
||||
export function AlipayQrView({ qrCode }: { qrCode: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-border bg-muted/20 p-4">
|
||||
<p className="font-medium">请使用支付宝扫码支付</p>
|
||||
<div className="rounded-xl border border-border bg-white p-4">
|
||||
<QRCodeSVG value={qrCode} size={220} />
|
||||
</div>
|
||||
<CopyButton text={qrCode} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UsdtView({ raw }: { raw: NonNullable<PaymentInfo["raw"]> }) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-xl border border-primary/15 bg-primary/10 p-4 text-center">
|
||||
<p className="text-xs font-medium tracking-wide text-primary/75">USDT 转账 · TRC20</p>
|
||||
<p className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-primary tabular-nums">
|
||||
{raw.usdtAmount} USDT
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">请务必转账精确金额,系统自动匹配确认</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-xl border border-border bg-muted/25 p-4 text-sm">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<span className="shrink-0 text-muted-foreground">收款地址</span>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<code className="truncate rounded-full bg-background/70 px-2 py-1 text-xs">
|
||||
{raw.walletAddress?.slice(0, 10)}...{raw.walletAddress?.slice(-6)}
|
||||
</code>
|
||||
<CopyButton text={raw.walletAddress || ""} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="text-muted-foreground">网络</span>
|
||||
<StatusBadge tone="neutral">{raw.network}</StatusBadge>
|
||||
</div>
|
||||
{raw.exchangeRate && (
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="text-muted-foreground">汇率</span>
|
||||
<span className="font-medium">1 USDT = ¥{raw.exchangeRate}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<div className="rounded-xl border border-border bg-white p-4">
|
||||
<QRCodeSVG value={raw.walletAddress || ""} size={220} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
|
||||
export function PaymentFrame({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<main className="grid min-h-[100dvh] place-items-center px-4 py-10">
|
||||
<div className="w-full max-w-2xl">{children}</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function PaymentCard({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<Card className="rounded-xl p-1">
|
||||
<CardHeader className="space-y-2 pt-6 text-center">
|
||||
<div className="mx-auto flex size-10 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||
<ShieldCheck className="size-5" />
|
||||
</div>
|
||||
<h1 className="text-display text-2xl font-semibold">{title}</h1>
|
||||
<p className="mx-auto max-w-md text-sm leading-6 text-muted-foreground">
|
||||
选择一种适合你的支付方式。支付完成后,订单会自动确认并进入开通流程。
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 pb-6">{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user