Initial commit

This commit is contained in:
JetSprow
2026-04-29 05:12:39 +10:00
commit 27dbca9cbf
379 changed files with 43486 additions and 0 deletions

View File

@@ -0,0 +1,189 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/require-auth";
import { actorFromSession, recordAuditLog } from "@/services/audit";
import {
deleteAnnouncementNotifications,
dispatchAnnouncementNotifications,
syncAnnouncementNotifications,
} from "@/services/announcements";
const announcementSchema = z.object({
title: z.string().trim().min(1, "标题不能为空"),
body: z.string().trim().min(1, "内容不能为空"),
audience: z.enum(["PUBLIC", "USERS", "ADMINS", "SPECIFIC_USER"]),
displayType: z.enum(["INLINE", "BIG", "POPUP"]).default("INLINE"),
targetUserId: z.string().optional(),
dismissible: z.string().optional(),
sendNotification: z.string().optional(),
startAt: z.string().optional(),
endAt: z.string().optional(),
});
function revalidateAnnouncementViews() {
revalidatePath("/admin/announcements");
revalidatePath("/dashboard");
revalidatePath("/account");
revalidatePath("/notifications");
revalidatePath("/login");
}
function parseAnnouncementInput(formData: FormData) {
const data = announcementSchema.parse(Object.fromEntries(formData));
if (data.audience === "SPECIFIC_USER" && !data.targetUserId) {
throw new Error("定向消息必须选择用户");
}
const startAt = data.startAt ? new Date(data.startAt) : null;
const endAt = data.endAt ? new Date(data.endAt) : null;
if (startAt && Number.isNaN(startAt.getTime())) {
throw new Error("开始时间格式无效");
}
if (endAt && Number.isNaN(endAt.getTime())) {
throw new Error("结束时间格式无效");
}
if (startAt && endAt && endAt <= startAt) {
throw new Error("结束时间必须晚于开始时间");
}
return {
title: data.title,
body: data.body,
audience: data.audience,
displayType: data.displayType,
targetUserId: data.audience === "SPECIFIC_USER" ? data.targetUserId ?? null : null,
dismissible: data.dismissible === "true",
sendNotification: data.sendNotification === "true",
startAt,
endAt,
};
}
export async function createAnnouncement(formData: FormData) {
const session = await requireAdmin();
const data = parseAnnouncementInput(formData);
const announcement = await prisma.announcement.create({
data: {
title: data.title,
body: data.body,
audience: data.audience,
displayType: data.displayType,
targetUserId: data.targetUserId,
createdById: session.user.id,
isActive: true,
dismissible: data.dismissible,
sendNotification: data.sendNotification,
startAt: data.startAt,
endAt: data.endAt,
},
});
await dispatchAnnouncementNotifications(announcement.id);
await recordAuditLog({
actor: actorFromSession(session),
action: "announcement.create",
targetType: "Announcement",
targetId: announcement.id,
targetLabel: announcement.title,
message: `创建公告/消息 ${announcement.title}`,
});
revalidateAnnouncementViews();
}
export async function updateAnnouncement(id: string, formData: FormData) {
const session = await requireAdmin();
const data = parseAnnouncementInput(formData);
const announcement = await prisma.announcement.update({
where: { id },
data: {
title: data.title,
body: data.body,
audience: data.audience,
displayType: data.displayType,
targetUserId: data.targetUserId,
dismissible: data.dismissible,
sendNotification: data.sendNotification,
startAt: data.startAt,
endAt: data.endAt,
},
});
await syncAnnouncementNotifications(announcement.id);
await recordAuditLog({
actor: actorFromSession(session),
action: "announcement.update",
targetType: "Announcement",
targetId: announcement.id,
targetLabel: announcement.title,
message: `更新公告/消息 ${announcement.title}`,
});
revalidateAnnouncementViews();
}
export async function toggleAnnouncement(id: string, isActive: boolean) {
const session = await requireAdmin();
const announcement = await prisma.announcement.update({
where: { id },
data: { isActive },
});
await recordAuditLog({
actor: actorFromSession(session),
action: isActive ? "announcement.enable" : "announcement.disable",
targetType: "Announcement",
targetId: announcement.id,
targetLabel: announcement.title,
message: `${isActive ? "启用" : "停用"}公告/消息 ${announcement.title}`,
});
if (isActive) {
await dispatchAnnouncementNotifications(announcement.id);
}
revalidateAnnouncementViews();
}
export async function deleteAnnouncement(id: string) {
const session = await requireAdmin();
const announcement = await prisma.announcement.findUnique({
where: { id },
select: {
id: true,
title: true,
},
});
if (!announcement) {
throw new Error("公告不存在");
}
await prisma.$transaction(async (tx) => {
await deleteAnnouncementNotifications(announcement.id, tx);
await tx.announcement.delete({
where: {
id: announcement.id,
},
});
await recordAuditLog(
{
actor: actorFromSession(session),
action: "announcement.delete",
targetType: "Announcement",
targetId: announcement.id,
targetLabel: announcement.title,
message: `删除公告/消息 ${announcement.title}`,
},
tx,
);
});
revalidateAnnouncementViews();
}

View File

@@ -0,0 +1,34 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireAdmin } from "@/lib/require-auth";
import { actorFromSession, recordAuditLog } from "@/services/audit";
import { restoreDatabaseBackupFile, restoreDatabaseBackupSql } from "@/services/database-backup";
export async function restoreDatabaseBackup(formData: FormData) {
const session = await requireAdmin();
const sqlText = String(formData.get("sqlText") || "").trim();
const file = formData.get("sqlFile");
const confirmation = String(formData.get("confirmation") || "");
if (confirmation !== "RESTORE") {
throw new Error("请输入 RESTORE 确认恢复操作");
}
if (file instanceof File && file.size > 0) {
await restoreDatabaseBackupFile(file);
} else if (sqlText) {
await restoreDatabaseBackupSql(sqlText);
} else {
throw new Error("请上传 SQL 备份文件或粘贴 SQL 内容");
}
await recordAuditLog({
actor: actorFromSession(session),
action: "backup.restore",
targetType: "Database",
message: "执行数据库恢复",
});
revalidatePath("/admin/backups");
}

View File

@@ -0,0 +1,127 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/require-auth";
import { actorFromSession, recordAuditLog } from "@/services/audit";
const optionalNumber = z.preprocess(
(value) => (value === "" || value == null ? undefined : Number(value)),
z.number().optional(),
);
const optionalInt = z.preprocess(
(value) => (value === "" || value == null ? undefined : Number(value)),
z.number().int().optional(),
);
const couponSchema = z.object({
code: z.string().trim().min(2).transform((value) => value.toUpperCase()),
name: z.string().trim().min(1),
description: z.string().trim().optional(),
discountType: z.enum(["AMOUNT_OFF", "PERCENT_OFF"]),
discountValue: z.coerce.number().positive(),
thresholdAmount: optionalNumber,
maxDiscountAmount: optionalNumber,
totalLimit: optionalInt,
perUserLimit: optionalInt,
isPublic: z.string().optional(),
});
const promotionSchema = z.object({
name: z.string().trim().min(1),
thresholdAmount: z.coerce.number().positive(),
discountAmount: z.coerce.number().positive(),
sortOrder: z.coerce.number().int().default(100),
});
export async function createCoupon(formData: FormData) {
const session = await requireAdmin();
const data = couponSchema.parse(Object.fromEntries(formData));
if (data.discountType === "PERCENT_OFF" && data.discountValue > 100) {
throw new Error("折扣百分比不能超过 100");
}
const coupon = await prisma.coupon.create({
data: {
code: data.code,
name: data.name,
description: data.description || null,
discountType: data.discountType,
discountValue: data.discountValue,
thresholdAmount: data.thresholdAmount ?? null,
maxDiscountAmount: data.maxDiscountAmount ?? null,
totalLimit: data.totalLimit ?? null,
perUserLimit: data.perUserLimit ?? null,
isPublic: data.isPublic === "true",
},
});
await recordAuditLog({
actor: actorFromSession(session),
action: "coupon.create",
targetType: "Coupon",
targetId: coupon.id,
targetLabel: coupon.code,
message: `创建优惠券 ${coupon.code}`,
});
revalidateCommerce();
}
export async function toggleCoupon(id: string, isActive: boolean) {
const session = await requireAdmin();
const coupon = await prisma.coupon.update({ where: { id }, data: { isActive } });
await recordAuditLog({
actor: actorFromSession(session),
action: "coupon.toggle",
targetType: "Coupon",
targetId: id,
targetLabel: coupon.code,
message: `${isActive ? "启用" : "停用"}优惠券 ${coupon.code}`,
});
revalidateCommerce();
}
export async function createPromotionRule(formData: FormData) {
const session = await requireAdmin();
const data = promotionSchema.parse(Object.fromEntries(formData));
if (data.discountAmount >= data.thresholdAmount) {
throw new Error("满减金额应小于门槛金额");
}
const rule = await prisma.promotionRule.create({
data: {
name: data.name,
thresholdAmount: data.thresholdAmount,
discountAmount: data.discountAmount,
sortOrder: data.sortOrder,
},
});
await recordAuditLog({
actor: actorFromSession(session),
action: "promotion.create",
targetType: "PromotionRule",
targetId: rule.id,
targetLabel: rule.name,
message: `创建满减规则 ${rule.name}`,
});
revalidateCommerce();
}
export async function togglePromotionRule(id: string, isActive: boolean) {
const session = await requireAdmin();
const rule = await prisma.promotionRule.update({ where: { id }, data: { isActive } });
await recordAuditLog({
actor: actorFromSession(session),
action: "promotion.toggle",
targetType: "PromotionRule",
targetId: id,
targetLabel: rule.name,
message: `${isActive ? "启用" : "停用"}满减规则 ${rule.name}`,
});
revalidateCommerce();
}
function revalidateCommerce() {
revalidatePath("/admin/commerce");
revalidatePath("/admin/plans");
revalidatePath("/store");
revalidatePath("/cart");
}

245
src/actions/admin/nodes.ts Normal file
View File

@@ -0,0 +1,245 @@
"use server";
import crypto from "crypto";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/require-auth";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { actorFromSession, recordAuditLog } from "@/services/audit";
import { encrypt } from "@/lib/crypto";
import { testAndSyncNodeInbounds } from "@/services/node-panel/sync-inbounds";
const nodeSchema = z.object({
name: z.string().trim().optional(),
panelUrl: z.string().trim().min(1, "3x-ui 面板地址必填"),
panelUsername: z.string().trim().min(1, "3x-ui 用户名必填"),
panelPassword: z.string().trim().min(1, "3x-ui 密码必填"),
});
function normalizePanelUrl(raw: string): string {
try {
let value = raw.trim();
if (!/^https?:\/\//i.test(value)) {
value = `http://${value}`;
}
const url = new URL(value);
let pathname = url.pathname.replace(/\/+$/, "");
pathname = pathname.replace(/\/panel\/login$/i, "");
pathname = pathname.replace(/\/panel$/i, "");
pathname = pathname.replace(/\/login$/i, "");
return `${url.origin}${pathname}`;
} catch {
throw new Error("面板地址格式不正确,请填写 IP:端口 或 http://IP:端口");
}
}
function parseNodeData(formData: FormData) {
const raw = nodeSchema.parse(Object.fromEntries(formData));
const panelUrl = normalizePanelUrl(raw.panelUrl);
const panel = new URL(panelUrl);
const name = (raw.name || "").trim() || `节点-${panel.hostname}`;
return {
name,
panelUrl,
panelUsername: raw.panelUsername,
panelPassword: raw.panelPassword,
panelType: "3x-ui",
};
}
export async function createNode(formData: FormData) {
const session = await requireAdmin();
const data = parseNodeData(formData);
const node = await prisma.nodeServer.create({ data });
const result = await testAndSyncNodeInbounds(node);
await recordAuditLog({
actor: actorFromSession(session),
action: "node.create",
targetType: "NodeServer",
targetId: node.id,
targetLabel: node.name,
message: `创建 3x-ui 节点 ${node.name}`,
});
revalidatePath("/admin/nodes");
return {
...result,
message: result.success ? `节点创建成功,${result.message}` : `节点已创建,但${result.message}`,
};
}
export async function updateNode(id: string, formData: FormData) {
const session = await requireAdmin();
const data = parseNodeData(formData);
const node = await prisma.nodeServer.update({ where: { id }, data });
const result = await testAndSyncNodeInbounds(node);
await recordAuditLog({
actor: actorFromSession(session),
action: "node.update",
targetType: "NodeServer",
targetId: node.id,
targetLabel: node.name,
message: `更新 3x-ui 节点 ${node.name}`,
});
revalidatePath("/admin/nodes");
return {
...result,
message: result.success ? `节点已更新,${result.message}` : `节点已更新,但${result.message}`,
};
}
export async function deleteNode(id: string) {
const session = await requireAdmin();
const node = await prisma.nodeServer.delete({ where: { id } });
await recordAuditLog({
actor: actorFromSession(session),
action: "node.delete",
targetType: "NodeServer",
targetId: node.id,
targetLabel: node.name,
message: `删除节点 ${node.name}`,
});
revalidatePath("/admin/nodes");
}
export async function testNodeConnection(id: string) {
const session = await requireAdmin();
const server = await prisma.nodeServer.findUniqueOrThrow({ where: { id } });
const result = await testAndSyncNodeInbounds(server);
await recordAuditLog({
actor: actorFromSession(session),
action: "node.test",
targetType: "NodeServer",
targetId: server.id,
targetLabel: server.name,
message: `测试 3x-ui 节点 ${server.name}${result.message}`,
});
revalidatePath("/admin/nodes");
return result;
}
export async function batchTestNodeConnections(formData: FormData) {
const nodeIds = formData.getAll("nodeIds").map(String).filter(Boolean);
if (nodeIds.length === 0) {
throw new Error("请至少选择一个节点");
}
for (const nodeId of nodeIds) {
await testNodeConnection(nodeId);
}
}
function withInboundDisplayName(settings: unknown, displayName: string) {
const base = settings && typeof settings === "object" && !Array.isArray(settings)
? settings as Record<string, unknown>
: {};
return { ...base, displayName: displayName.trim() };
}
const inboundDisplayNameSchema = z.object({
displayName: z.string().trim().min(1, "前台名称不能为空").max(60, "前台名称不能超过 60 个字符"),
});
export async function updateInboundDisplayName(id: string, formData: FormData) {
const session = await requireAdmin();
const { displayName } = inboundDisplayNameSchema.parse(Object.fromEntries(formData));
const inbound = await prisma.nodeInbound.findUniqueOrThrow({
where: { id },
include: { server: { select: { name: true } } },
});
await prisma.nodeInbound.update({
where: { id },
data: { settings: withInboundDisplayName(inbound.settings, displayName) },
});
await recordAuditLog({
actor: actorFromSession(session),
action: "inbound.display_name.update",
targetType: "NodeInbound",
targetId: inbound.id,
targetLabel: displayName,
message: `更新节点 ${inbound.server.name} 的前台线路名称`,
});
revalidatePath("/admin/nodes");
revalidatePath("/store");
}
export async function deleteInbound(id: string) {
const session = await requireAdmin();
const inbound = await prisma.nodeInbound.findUniqueOrThrow({
where: { id },
include: { server: true },
});
await prisma.nodeInbound.delete({ where: { id } });
await recordAuditLog({
actor: actorFromSession(session),
action: "inbound.delete",
targetType: "NodeInbound",
targetId: inbound.id,
targetLabel: `${inbound.protocol}:${inbound.port}`,
message: `从本地移除节点 ${inbound.server.name} 的入站 ${inbound.protocol}:${inbound.port}`,
});
revalidatePath("/admin/nodes");
}
export async function generateAgentToken(nodeId: string) {
const session = await requireAdmin();
const node = await prisma.nodeServer.findUniqueOrThrow({
where: { id: nodeId },
select: { id: true, name: true },
});
const plainToken = crypto.randomBytes(32).toString("hex");
const encrypted = encrypt(plainToken);
await prisma.nodeServer.update({
where: { id: nodeId },
data: { agentToken: encrypted },
});
await recordAuditLog({
actor: actorFromSession(session),
action: "node.probe_token.generate",
targetType: "NodeServer",
targetId: node.id,
targetLabel: node.name,
message: `为节点 ${node.name} 生成探测 Token`,
});
revalidatePath("/admin/nodes");
return plainToken;
}
export async function revokeAgentToken(nodeId: string) {
const session = await requireAdmin();
const node = await prisma.nodeServer.findUniqueOrThrow({
where: { id: nodeId },
select: { id: true, name: true },
});
await prisma.nodeServer.update({
where: { id: nodeId },
data: { agentToken: null },
});
await recordAuditLog({
actor: actorFromSession(session),
action: "node.probe_token.revoke",
targetType: "NodeServer",
targetId: node.id,
targetLabel: node.name,
message: `撤销节点 ${node.name} 的探测 Token`,
});
revalidatePath("/admin/nodes");
}

View File

@@ -0,0 +1,92 @@
"use server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/require-auth";
import { revalidatePath } from "next/cache";
import { confirmPendingOrder } from "@/services/payment/process";
import { actorFromSession, recordAuditLog } from "@/services/audit";
export async function confirmOrder(orderId: string) {
const session = await requireAdmin();
const order = await prisma.order.findUniqueOrThrow({
where: { id: orderId },
select: { status: true, id: true },
});
if (order.status !== "PENDING") {
throw new Error("订单状态不正确");
}
const result = await confirmPendingOrder(orderId);
if (result.finalStatus !== "PAID") {
throw new Error(result.errorMessage ?? "订单处理失败");
}
await recordAuditLog({
actor: actorFromSession(session),
action: "order.confirm",
targetType: "Order",
targetId: order.id,
targetLabel: order.id,
message: `确认订单 ${order.id}`,
});
revalidatePath("/admin/orders");
}
export async function cancelOrder(orderId: string) {
const session = await requireAdmin();
await prisma.order.update({ where: { id: orderId }, data: { status: "CANCELLED" } });
await recordAuditLog({
actor: actorFromSession(session),
action: "order.cancel",
targetType: "Order",
targetId: orderId,
targetLabel: orderId,
message: `取消订单 ${orderId}`,
});
revalidatePath("/admin/orders");
}
export async function updateOrderReview(
orderId: string,
reviewStatus: "NORMAL" | "FLAGGED" | "RESOLVED",
reviewNote?: string,
) {
const session = await requireAdmin();
const order = await prisma.order.update({
where: { id: orderId },
data: {
reviewStatus,
reviewNote: reviewNote?.trim() || null,
},
});
await recordAuditLog({
actor: actorFromSession(session),
action: "order.review",
targetType: "Order",
targetId: order.id,
targetLabel: order.id,
message: `将订单 ${order.id} 标记为 ${reviewStatus}`,
});
revalidatePath("/admin/orders");
}
export async function batchOrderOperation(formData: FormData) {
const action = String(formData.get("action") || "");
const orderIds = formData.getAll("orderIds").map(String).filter(Boolean);
if (orderIds.length === 0) {
throw new Error("请至少选择一个订单");
}
for (const orderId of orderIds) {
if (action === "confirm") {
await confirmOrder(orderId);
} else if (action === "cancel") {
await cancelOrder(orderId);
} else {
throw new Error("不支持的批量操作");
}
}
}

View File

@@ -0,0 +1,51 @@
"use server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/require-auth";
import { revalidatePath } from "next/cache";
import {
normalizePaymentConfig,
parsePaymentConfig,
} from "@/services/payment/catalog";
import { actorFromSession, recordAuditLog } from "@/services/audit";
import { z } from "zod";
export async function savePaymentConfig(
provider: string,
config: Record<string, string>,
enabled: boolean
) {
const session = await requireAdmin();
const normalizedConfig = normalizePaymentConfig(config);
let finalConfig = normalizedConfig as Record<string, string | number>;
if (enabled) {
try {
finalConfig = parsePaymentConfig(provider, normalizedConfig) as Record<string, string | number>;
} catch (error) {
if (error instanceof z.ZodError) {
const messages = error.issues.map((e) => e.message).join("");
throw new Error(messages);
}
throw error;
}
}
const jsonConfig = JSON.parse(JSON.stringify(finalConfig));
await prisma.paymentConfig.upsert({
where: { provider },
create: { provider, config: jsonConfig, enabled },
update: { config: jsonConfig, enabled },
});
await recordAuditLog({
actor: actorFromSession(session),
action: "payment.config",
targetType: "PaymentConfig",
targetId: provider,
targetLabel: provider,
message: `${enabled ? "启用并更新" : "更新"}支付配置 ${provider}`,
});
revalidatePath("/admin/payments");
}

689
src/actions/admin/plans.ts Normal file
View File

@@ -0,0 +1,689 @@
"use server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/require-auth";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { actorFromSession, recordAuditLog } from "@/services/audit";
import { deleteSubscriptionPermanently } from "./subscriptions";
const optionalNumber = z.preprocess(
(value) => (value === "" || value == null ? undefined : Number(value)),
z.number().optional(),
);
const optionalInt = z.preprocess(
(value) => (value === "" || value == null ? undefined : Number(value)),
z.number().int().optional(),
);
const optionalBool = z.preprocess((value) => {
if (value === "" || value == null) return undefined;
if (typeof value === "boolean") return value;
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
return normalized === "true" || normalized === "1" || normalized === "on";
}
return Boolean(value);
}, z.boolean().optional());
const planSchema = z.object({
name: z.string().min(1),
type: z.enum(["STREAMING", "PROXY"]),
description: z.string().optional(),
durationDays: z.coerce.number().int().positive(),
sortOrder: z.coerce.number().int().default(100),
price: optionalNumber,
nodeId: z.string().optional(),
inboundId: z.string().optional(),
inboundIds: z.string().optional(),
streamingServiceId: z.string().optional(),
pricingMode: z.enum(["TRAFFIC_SLIDER", "FIXED_PACKAGE"]).optional(),
fixedTrafficGb: optionalInt,
fixedPrice: optionalNumber,
totalLimit: optionalInt,
perUserLimit: optionalInt,
totalTrafficGb: optionalInt,
allowRenewal: optionalBool,
allowTrafficTopup: optionalBool,
renewalPrice: optionalNumber,
renewalPricingMode: z.enum(["PER_DAY", "FIXED_DURATION"]).optional(),
renewalDurationDays: optionalInt,
renewalMinDays: optionalInt,
renewalMaxDays: optionalInt,
renewalTrafficGb: optionalInt,
topupPricingMode: z.enum(["PER_GB", "FIXED_AMOUNT"]).optional(),
topupPricePerGb: optionalNumber,
topupFixedPrice: optionalNumber,
minTopupGb: optionalInt,
maxTopupGb: optionalInt,
pricePerGb: optionalNumber,
minTrafficGb: optionalInt,
maxTrafficGb: optionalInt,
});
function parseInboundIds(raw: string | undefined, fallbackInboundId?: string): string[] {
const list = (raw ?? "")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
if (list.length > 0) {
return Array.from(new Set(list));
}
if (fallbackInboundId) {
return [fallbackInboundId];
}
return [];
}
function assertProxyPricing(data: z.infer<typeof planSchema>) {
const pricingMode = data.pricingMode ?? "TRAFFIC_SLIDER";
if (pricingMode === "FIXED_PACKAGE") {
if (data.fixedTrafficGb == null || data.fixedTrafficGb <= 0) {
throw new Error("固定流量套餐必须填写固定流量");
}
if (data.fixedPrice == null || data.fixedPrice <= 0) {
throw new Error("固定流量套餐必须填写固定价格");
}
} else {
if (data.pricePerGb == null || data.pricePerGb <= 0) {
throw new Error("自选流量套餐必须填写每 GB 价格");
}
if (data.minTrafficGb == null || data.minTrafficGb <= 0) {
throw new Error("自选流量套餐必须填写最小流量");
}
if (data.maxTrafficGb == null || data.maxTrafficGb <= 0) {
throw new Error("自选流量套餐必须填写最大流量");
}
if (data.maxTrafficGb < data.minTrafficGb) {
throw new Error("最大流量不能小于最小流量");
}
}
return pricingMode;
}
type PlanFormData = z.infer<typeof planSchema>;
function getRenewalPolicy(data: PlanFormData, allowRenewal: boolean) {
if (!allowRenewal) {
return {
renewalPrice: null,
renewalPricingMode: "FIXED_DURATION",
renewalDurationDays: null,
renewalMinDays: null,
renewalMaxDays: null,
};
}
if (data.renewalPrice == null || data.renewalPrice <= 0) {
throw new Error("开启续费时,续费金额必须大于 0");
}
const renewalPricingMode = data.renewalPricingMode ?? "FIXED_DURATION";
if (renewalPricingMode === "FIXED_DURATION") {
const renewalDurationDays = data.renewalDurationDays ?? data.durationDays;
if (!renewalDurationDays || renewalDurationDays <= 0) {
throw new Error("固定周期续费必须填写续费天数");
}
return {
renewalPrice: data.renewalPrice,
renewalPricingMode,
renewalDurationDays,
renewalMinDays: renewalDurationDays,
renewalMaxDays: renewalDurationDays,
};
}
const renewalMinDays = data.renewalMinDays ?? 1;
const renewalMaxDays = data.renewalMaxDays ?? data.durationDays;
if (renewalMinDays <= 0 || renewalMaxDays <= 0) {
throw new Error("续费天数范围必须大于 0");
}
if (renewalMaxDays < renewalMinDays) {
throw new Error("续费最大天数不能小于最小天数");
}
return {
renewalPrice: data.renewalPrice,
renewalPricingMode,
renewalDurationDays: null,
renewalMinDays,
renewalMaxDays,
};
}
function getTopupPolicy(data: PlanFormData, allowTrafficTopup: boolean) {
if (data.type !== "PROXY" || !allowTrafficTopup) {
return {
topupPricingMode: "PER_GB",
topupPricePerGb: null,
topupFixedPrice: null,
minTopupGb: null,
maxTopupGb: null,
};
}
const topupPricingMode = data.topupPricingMode ?? "PER_GB";
if (topupPricingMode === "PER_GB") {
if (data.topupPricePerGb == null || data.topupPricePerGb <= 0) {
throw new Error("开启增流量时,每 GB 加流量价格必须大于 0");
}
} else if (data.topupFixedPrice == null || data.topupFixedPrice <= 0) {
throw new Error("开启增流量时,固定加流量金额必须大于 0");
}
const minTopupGb = data.minTopupGb ?? 1;
const maxTopupGb = data.maxTopupGb ?? null;
if (minTopupGb <= 0) {
throw new Error("最小增流量必须大于 0");
}
if (maxTopupGb != null && maxTopupGb <= 0) {
throw new Error("最大增流量必须大于 0");
}
if (maxTopupGb != null && maxTopupGb < minTopupGb) {
throw new Error("最大增流量不能小于最小增流量");
}
return {
topupPricingMode,
topupPricePerGb: topupPricingMode === "PER_GB" ? data.topupPricePerGb : null,
topupFixedPrice: topupPricingMode === "FIXED_AMOUNT" ? data.topupFixedPrice : null,
minTopupGb,
maxTopupGb,
};
}
export async function createPlan(formData: FormData) {
const session = await requireAdmin();
const raw = Object.fromEntries(formData);
const data = planSchema.parse(raw);
const allowRenewal = data.allowRenewal ?? false;
const allowTrafficTopup = data.allowTrafficTopup ?? false;
if (data.totalLimit != null && data.totalLimit <= 0) {
throw new Error("总量上限必须大于 0");
}
if (data.perUserLimit != null && data.perUserLimit <= 0) {
throw new Error("每用户限购必须大于 0");
}
if (data.totalTrafficGb != null && data.totalTrafficGb <= 0) {
throw new Error("总流量池必须大于 0");
}
const renewalPolicy = getRenewalPolicy(data, allowRenewal);
const topupPolicy = getTopupPolicy(data, allowTrafficTopup);
if (data.type === "PROXY") {
const pricingMode = assertProxyPricing(data);
if (!data.nodeId) throw new Error("代理套餐必须选择节点");
const inboundIds = parseInboundIds(data.inboundIds, data.inboundId);
if (inboundIds.length === 0) {
throw new Error("请至少配置一个可售入站");
}
const inbounds = await prisma.nodeInbound.findMany({
where: {
id: { in: inboundIds },
},
select: { id: true, serverId: true, isActive: true },
});
if (inbounds.length !== inboundIds.length) {
throw new Error("存在无效入站,请重新选择");
}
for (const inbound of inbounds) {
if (inbound.serverId !== data.nodeId) {
throw new Error("入站与节点不匹配");
}
if (!inbound.isActive) {
throw new Error("入站未启用");
}
}
let createdPlanId = "";
await prisma.$transaction(async (tx) => {
const plan = await tx.subscriptionPlan.create({
data: {
name: data.name,
type: "PROXY",
description: data.description || null,
durationDays: data.durationDays,
sortOrder: data.sortOrder,
totalLimit: data.totalLimit ?? null,
perUserLimit: data.perUserLimit ?? null,
totalTrafficGb: data.totalTrafficGb ?? null,
allowRenewal,
allowTrafficTopup,
renewalPrice: renewalPolicy.renewalPrice,
renewalPricingMode: renewalPolicy.renewalPricingMode,
renewalDurationDays: renewalPolicy.renewalDurationDays,
renewalMinDays: renewalPolicy.renewalMinDays,
renewalMaxDays: renewalPolicy.renewalMaxDays,
renewalTrafficGb: null,
topupPricingMode: topupPolicy.topupPricingMode,
topupPricePerGb: topupPolicy.topupPricePerGb,
topupFixedPrice: topupPolicy.topupFixedPrice,
minTopupGb: topupPolicy.minTopupGb,
maxTopupGb: topupPolicy.maxTopupGb,
nodeId: data.nodeId,
inboundId: inboundIds[0],
streamingServiceId: null,
categoryId: null,
pricingMode,
fixedTrafficGb: pricingMode === "FIXED_PACKAGE" ? data.fixedTrafficGb : null,
fixedPrice: pricingMode === "FIXED_PACKAGE" ? data.fixedPrice : null,
price: null,
pricePerGb: pricingMode === "TRAFFIC_SLIDER" ? data.pricePerGb : null,
minTrafficGb: pricingMode === "TRAFFIC_SLIDER" ? data.minTrafficGb : null,
maxTrafficGb: pricingMode === "TRAFFIC_SLIDER" ? data.maxTrafficGb : null,
},
});
createdPlanId = plan.id;
await tx.planInboundOption.createMany({
data: inboundIds.map((inboundId) => ({
planId: plan.id,
inboundId,
})),
});
});
await recordAuditLog({
actor: actorFromSession(session),
action: "plan.create",
targetType: "SubscriptionPlan",
targetId: createdPlanId,
targetLabel: data.name,
message: `创建代理套餐 ${data.name}`,
});
} else {
if (!data.streamingServiceId) {
throw new Error("流媒体套餐必须绑定一个流媒体服务");
}
const service = await prisma.streamingService.findUnique({
where: { id: data.streamingServiceId },
select: { id: true, isActive: true },
});
if (!service || !service.isActive) {
throw new Error("所选流媒体服务不存在或未启用");
}
const plan = await prisma.subscriptionPlan.create({
data: {
name: data.name,
type: "STREAMING",
description: data.description || null,
durationDays: data.durationDays,
sortOrder: data.sortOrder,
totalLimit: data.totalLimit ?? null,
perUserLimit: data.perUserLimit ?? null,
totalTrafficGb: null,
allowRenewal,
allowTrafficTopup: false,
renewalPrice: renewalPolicy.renewalPrice,
renewalPricingMode: renewalPolicy.renewalPricingMode,
renewalDurationDays: renewalPolicy.renewalDurationDays,
renewalMinDays: renewalPolicy.renewalMinDays,
renewalMaxDays: renewalPolicy.renewalMaxDays,
renewalTrafficGb: null,
topupPricingMode: "PER_GB",
topupPricePerGb: null,
topupFixedPrice: null,
minTopupGb: null,
maxTopupGb: null,
streamingServiceId: data.streamingServiceId,
categoryId: null,
pricingMode: "TRAFFIC_SLIDER",
fixedTrafficGb: null,
fixedPrice: null,
price: data.price ?? 0,
nodeId: null,
inboundId: null,
pricePerGb: null,
minTrafficGb: null,
maxTrafficGb: null,
},
});
await recordAuditLog({
actor: actorFromSession(session),
action: "plan.create",
targetType: "SubscriptionPlan",
targetId: plan.id,
targetLabel: plan.name,
message: `创建流媒体套餐 ${plan.name}`,
});
}
revalidatePath("/admin/plans");
revalidatePath("/store");
}
export async function updatePlan(id: string, formData: FormData) {
const session = await requireAdmin();
const raw = Object.fromEntries(formData);
const data = planSchema.parse(raw);
const allowRenewal = data.allowRenewal ?? false;
const allowTrafficTopup = data.allowTrafficTopup ?? false;
const existing = await prisma.subscriptionPlan.findUniqueOrThrow({
where: { id },
select: { type: true, nodeId: true },
});
if (existing.type !== data.type) {
throw new Error("暂不支持修改套餐类型,请新建套餐");
}
if (data.totalLimit != null && data.totalLimit <= 0) {
throw new Error("总量上限必须大于 0");
}
if (data.perUserLimit != null && data.perUserLimit <= 0) {
throw new Error("每用户限购必须大于 0");
}
if (data.totalTrafficGb != null && data.totalTrafficGb <= 0) {
throw new Error("总流量池必须大于 0");
}
const renewalPolicy = getRenewalPolicy(data, allowRenewal);
const topupPolicy = getTopupPolicy(data, allowTrafficTopup);
if (data.type === "PROXY") {
const pricingMode = assertProxyPricing(data);
const nodeId = data.nodeId ?? existing.nodeId;
if (!nodeId) throw new Error("代理套餐必须选择节点");
if (data.totalTrafficGb == null || data.totalTrafficGb <= 0) {
throw new Error("代理套餐必须填写总流量池,且大于 0");
}
const inboundIds = parseInboundIds(data.inboundIds, data.inboundId);
if (inboundIds.length === 0) {
throw new Error("请至少配置一个可售入站");
}
const inbounds = await prisma.nodeInbound.findMany({
where: {
id: { in: inboundIds },
},
select: { id: true, serverId: true, isActive: true },
});
if (inbounds.length !== inboundIds.length) {
throw new Error("存在无效入站,请重新选择");
}
for (const inbound of inbounds) {
if (inbound.serverId !== nodeId) {
throw new Error("入站与节点不匹配");
}
if (!inbound.isActive) {
throw new Error("入站未启用");
}
}
await prisma.$transaction(async (tx) => {
await tx.subscriptionPlan.update({
where: { id },
data: {
name: data.name,
description: data.description || null,
durationDays: data.durationDays,
sortOrder: data.sortOrder,
totalLimit: data.totalLimit ?? null,
perUserLimit: data.perUserLimit ?? null,
totalTrafficGb: data.totalTrafficGb ?? null,
allowRenewal,
allowTrafficTopup,
renewalPrice: renewalPolicy.renewalPrice,
renewalPricingMode: renewalPolicy.renewalPricingMode,
renewalDurationDays: renewalPolicy.renewalDurationDays,
renewalMinDays: renewalPolicy.renewalMinDays,
renewalMaxDays: renewalPolicy.renewalMaxDays,
renewalTrafficGb: null,
topupPricingMode: topupPolicy.topupPricingMode,
topupPricePerGb: topupPolicy.topupPricePerGb,
topupFixedPrice: topupPolicy.topupFixedPrice,
minTopupGb: topupPolicy.minTopupGb,
maxTopupGb: topupPolicy.maxTopupGb,
nodeId,
inboundId: inboundIds[0],
streamingServiceId: null,
categoryId: null,
pricingMode,
fixedTrafficGb: pricingMode === "FIXED_PACKAGE" ? data.fixedTrafficGb : null,
fixedPrice: pricingMode === "FIXED_PACKAGE" ? data.fixedPrice : null,
price: null,
pricePerGb: pricingMode === "TRAFFIC_SLIDER" ? data.pricePerGb : null,
minTrafficGb: pricingMode === "TRAFFIC_SLIDER" ? data.minTrafficGb : null,
maxTrafficGb: pricingMode === "TRAFFIC_SLIDER" ? data.maxTrafficGb : null,
},
});
await tx.planInboundOption.deleteMany({ where: { planId: id } });
await tx.planInboundOption.createMany({
data: inboundIds.map((inboundId) => ({ planId: id, inboundId })),
});
});
await recordAuditLog({
actor: actorFromSession(session),
action: "plan.update",
targetType: "SubscriptionPlan",
targetId: id,
targetLabel: data.name,
message: `更新代理套餐 ${data.name}`,
});
} else {
if (!data.streamingServiceId) {
throw new Error("流媒体套餐必须绑定一个流媒体服务");
}
const service = await prisma.streamingService.findUnique({
where: { id: data.streamingServiceId },
select: { id: true, isActive: true },
});
if (!service || !service.isActive) {
throw new Error("所选流媒体服务不存在或未启用");
}
await prisma.subscriptionPlan.update({
where: { id },
data: {
name: data.name,
description: data.description || null,
durationDays: data.durationDays,
sortOrder: data.sortOrder,
totalLimit: data.totalLimit ?? null,
perUserLimit: data.perUserLimit ?? null,
totalTrafficGb: null,
allowRenewal,
allowTrafficTopup: false,
renewalPrice: renewalPolicy.renewalPrice,
renewalPricingMode: renewalPolicy.renewalPricingMode,
renewalDurationDays: renewalPolicy.renewalDurationDays,
renewalMinDays: renewalPolicy.renewalMinDays,
renewalMaxDays: renewalPolicy.renewalMaxDays,
renewalTrafficGb: null,
topupPricingMode: "PER_GB",
topupPricePerGb: null,
topupFixedPrice: null,
minTopupGb: null,
maxTopupGb: null,
streamingServiceId: data.streamingServiceId,
categoryId: null,
pricingMode: "TRAFFIC_SLIDER",
fixedTrafficGb: null,
fixedPrice: null,
price: data.price,
nodeId: null,
inboundId: null,
pricePerGb: null,
minTrafficGb: null,
maxTrafficGb: null,
},
});
await prisma.planInboundOption.deleteMany({ where: { planId: id } });
await recordAuditLog({
actor: actorFromSession(session),
action: "plan.update",
targetType: "SubscriptionPlan",
targetId: id,
targetLabel: data.name,
message: `更新流媒体套餐 ${data.name}`,
});
}
revalidatePath("/admin/plans");
revalidatePath("/store");
}
export async function deletePlan(id: string) {
const session = await requireAdmin();
const plan = await prisma.subscriptionPlan.update({
where: { id },
data: { isActive: false },
});
await recordAuditLog({
actor: actorFromSession(session),
action: "plan.disable",
targetType: "SubscriptionPlan",
targetId: plan.id,
targetLabel: plan.name,
message: `下架套餐 ${plan.name}`,
});
revalidatePath("/admin/plans");
revalidatePath("/store");
}
export async function deletePlanPermanently(id: string) {
const session = await requireAdmin();
const actor = actorFromSession(session);
const plan = await prisma.subscriptionPlan.findUniqueOrThrow({
where: { id },
include: {
inboundOptions: {
include: {
inbound: {
include: {
_count: {
select: {
clients: true,
planOptions: true,
plans: true,
},
},
},
},
},
},
subscriptions: {
select: { id: true },
},
},
});
for (const subscription of plan.subscriptions) {
await deleteSubscriptionPermanently(subscription.id);
}
const relatedOrders = await prisma.order.findMany({
where: { planId: plan.id },
select: { id: true },
});
await prisma.order.deleteMany({
where: { planId: plan.id },
});
const deletableInboundIds: string[] = [];
for (const option of plan.inboundOptions) {
const inbound = option.inbound;
const otherPlanRefs = inbound._count.planOptions - 1;
const otherPlanDirectRefs = inbound._count.plans - (plan.inboundId === inbound.id ? 1 : 0);
const hasClients = inbound._count.clients > 0;
if (otherPlanRefs <= 0 && otherPlanDirectRefs <= 0 && !hasClients) {
deletableInboundIds.push(inbound.id);
}
}
await prisma.$transaction(async (tx) => {
await tx.planInboundOption.deleteMany({
where: { planId: plan.id },
});
if (deletableInboundIds.length > 0) {
await tx.nodeInbound.deleteMany({
where: { id: { in: deletableInboundIds } },
});
}
await tx.subscriptionPlan.delete({
where: { id: plan.id },
});
});
await recordAuditLog({
actor,
action: "plan.delete",
targetType: "SubscriptionPlan",
targetId: plan.id,
targetLabel: plan.name,
message: `彻底删除套餐 ${plan.name}`,
metadata: {
deletedOrderIds: relatedOrders.map((order) => order.id),
deletedInboundIds: deletableInboundIds,
},
});
revalidatePath("/admin/plans");
revalidatePath("/store");
revalidatePath("/admin/subscriptions");
}
export async function togglePlan(id: string, isActive: boolean) {
const session = await requireAdmin();
const plan = await prisma.subscriptionPlan.update({
where: { id },
data: { isActive },
});
await recordAuditLog({
actor: actorFromSession(session),
action: isActive ? "plan.enable" : "plan.disable",
targetType: "SubscriptionPlan",
targetId: plan.id,
targetLabel: plan.name,
message: `${isActive ? "上架" : "下架"}套餐 ${plan.name}`,
});
revalidatePath("/admin/plans");
}
export async function batchPlanOperation(formData: FormData) {
const session = await requireAdmin();
const action = String(formData.get("action") || "");
const planIds = formData.getAll("planIds").map(String).filter(Boolean);
if (planIds.length === 0) {
throw new Error("请至少选择一个套餐");
}
if (action === "enable" || action === "disable") {
const isActive = action === "enable";
await prisma.subscriptionPlan.updateMany({
where: { id: { in: planIds } },
data: { isActive },
});
await recordAuditLog({
actor: actorFromSession(session),
action: isActive ? "plan.batch_enable" : "plan.batch_disable",
targetType: "SubscriptionPlan",
message: `${isActive ? "批量上架" : "批量下架"} ${planIds.length} 个套餐`,
metadata: { planIds },
});
} else if (action === "delete") {
for (const planId of planIds) {
await deletePlanPermanently(planId);
}
return;
} else {
throw new Error("不支持的批量操作");
}
revalidatePath("/admin/plans");
revalidatePath("/store");
}

View File

@@ -0,0 +1,153 @@
"use server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/require-auth";
import { encrypt } from "@/lib/crypto";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { actorFromSession, recordAuditLog } from "@/services/audit";
import { createNotification } from "@/services/notifications";
const serviceSchema = z.object({
name: z.string().min(1),
credentials: z.string().min(1),
maxSlots: z.coerce.number().int().positive(),
description: z.string().optional(),
});
export async function createService(formData: FormData) {
const session = await requireAdmin();
const data = serviceSchema.parse(Object.fromEntries(formData));
const service = await prisma.streamingService.create({
data: {
name: data.name,
credentials: encrypt(data.credentials),
maxSlots: data.maxSlots,
description: data.description || null,
},
});
await recordAuditLog({
actor: actorFromSession(session),
action: "service.create",
targetType: "StreamingService",
targetId: service.id,
targetLabel: service.name,
message: `创建流媒体服务 ${service.name}`,
});
revalidatePath("/admin/services");
}
export async function updateService(id: string, formData: FormData) {
const session = await requireAdmin();
const data = serviceSchema.parse(Object.fromEntries(formData));
const affectedUsers = await prisma.streamingSlot.findMany({
where: { serviceId: id },
select: { userId: true },
distinct: ["userId"],
});
const service = await prisma.streamingService.update({
where: { id },
data: {
name: data.name,
credentials: encrypt(data.credentials),
maxSlots: data.maxSlots,
description: data.description || null,
},
});
for (const row of affectedUsers) {
await createNotification({
userId: row.userId,
type: "SYSTEM",
level: "INFO",
title: "流媒体凭据已更新",
body: `${service.name} 的共享凭据已更新,请重新查看最新账号信息。`,
link: "/subscriptions",
dedupeKey: `service-credential-update:${service.id}:${row.userId}:${Date.now()}`,
});
}
await recordAuditLog({
actor: actorFromSession(session),
action: "service.update",
targetType: "StreamingService",
targetId: service.id,
targetLabel: service.name,
message: `更新流媒体服务 ${service.name}`,
});
revalidatePath("/admin/services");
}
export async function deleteService(id: string) {
const session = await requireAdmin();
const service = await prisma.streamingService.findUniqueOrThrow({
where: { id },
include: { _count: { select: { slots: true } } },
});
if (service._count.slots > 0) {
throw new Error(`该服务仍有 ${service._count.slots} 个关联槽位,请先清理后再删除`);
}
await prisma.streamingService.delete({ where: { id } });
await recordAuditLog({
actor: actorFromSession(session),
action: "service.delete",
targetType: "StreamingService",
targetId: service.id,
targetLabel: service.name,
message: `彻底删除流媒体服务 ${service.name}`,
});
revalidatePath("/admin/services");
revalidatePath("/store");
}
export async function toggleServiceStatus(id: string, isActive: boolean) {
const session = await requireAdmin();
const service = await prisma.streamingService.update({
where: { id },
data: { isActive },
});
await recordAuditLog({
actor: actorFromSession(session),
action: isActive ? "service.enable" : "service.disable",
targetType: "StreamingService",
targetId: service.id,
targetLabel: service.name,
message: `${isActive ? "启用" : "停用"}流媒体服务 ${service.name}`,
});
revalidatePath("/admin/services");
revalidatePath("/store");
}
export async function batchToggleServiceStatus(formData: FormData) {
const session = await requireAdmin();
const isActive = String(formData.get("isActive")) === "true";
const serviceIds = formData.getAll("serviceIds").map(String).filter(Boolean);
if (serviceIds.length === 0) {
throw new Error("请至少选择一个服务");
}
const services = await prisma.streamingService.findMany({
where: { id: { in: serviceIds } },
select: { id: true, name: true },
});
await prisma.streamingService.updateMany({
where: { id: { in: serviceIds } },
data: { isActive },
});
await recordAuditLog({
actor: actorFromSession(session),
action: isActive ? "service.batch_enable" : "service.batch_disable",
targetType: "StreamingService",
message: `${isActive ? "批量启用" : "批量停用"} ${services.length} 个流媒体服务`,
metadata: {
serviceIds,
},
});
revalidatePath("/admin/services");
revalidatePath("/store");
}

View File

@@ -0,0 +1,79 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/require-auth";
import { actorFromSession, recordAuditLog } from "@/services/audit";
import { getAppConfig } from "@/services/app-config";
import { normalizeSiteUrl } from "@/services/site-url";
const settingsSchema = z.object({
siteName: z.string().trim().min(1, "站点名称不能为空"),
siteUrl: z.string().trim().optional(),
supportContact: z.string().trim().optional(),
maintenanceNotice: z.string().trim().optional(),
siteNotice: z.string().trim().optional(),
allowRegistration: z.string().optional(),
requireInviteCode: z.string().optional(),
autoReminderDispatchEnabled: z.string().optional(),
reminderDispatchIntervalMinutes: z.coerce.number().int().positive().optional(),
trafficSyncEnabled: z.string().optional(),
trafficSyncIntervalSeconds: z.coerce.number().int().min(10).optional(),
inviteRewardEnabled: z.string().optional(),
inviteRewardRate: z.coerce.number().min(0).max(100).optional(),
inviteRewardCouponId: z.string().trim().optional(),
turnstileSiteKey: z.string().trim().optional(),
turnstileSecretKey: z.string().trim().optional(),
});
export async function saveAppSettings(formData: FormData) {
const session = await requireAdmin();
const parsed = settingsSchema.parse(Object.fromEntries(formData));
const current = await getAppConfig();
const next = {
siteName: parsed.siteName,
siteUrl: normalizeSiteUrl(parsed.siteUrl) || null,
supportContact: parsed.supportContact || null,
maintenanceNotice: parsed.maintenanceNotice || null,
siteNotice: parsed.siteNotice || null,
allowRegistration: parsed.allowRegistration === "true",
requireInviteCode: parsed.requireInviteCode === "true",
autoReminderDispatchEnabled: parsed.autoReminderDispatchEnabled === "true",
reminderDispatchIntervalMinutes:
parsed.reminderDispatchIntervalMinutes ?? current.reminderDispatchIntervalMinutes,
trafficSyncEnabled: parsed.trafficSyncEnabled === "true",
trafficSyncIntervalSeconds:
parsed.trafficSyncIntervalSeconds ?? current.trafficSyncIntervalSeconds,
inviteRewardEnabled: parsed.inviteRewardEnabled === "true",
inviteRewardRate: parsed.inviteRewardRate ?? Number(current.inviteRewardRate),
inviteRewardCouponId: parsed.inviteRewardCouponId || null,
turnstileSiteKey: parsed.turnstileSiteKey || null,
turnstileSecretKey: parsed.turnstileSecretKey || null,
};
await prisma.appConfig.upsert({
where: { id: current.id },
create: { id: current.id, ...next },
update: next,
});
await recordAuditLog({
actor: actorFromSession(session),
action: "settings.update",
targetType: "AppConfig",
targetId: current.id,
targetLabel: next.siteName,
message: "更新系统设置",
});
revalidatePath("/admin/settings");
revalidatePath("/login");
revalidatePath("/register");
revalidatePath("/dashboard");
revalidatePath("/subscriptions");
revalidatePath("/admin/nodes");
revalidatePath("/account");
revalidatePath("/admin/commerce");
}

View File

@@ -0,0 +1,452 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/require-auth";
import { actorFromSession, recordAuditLog } from "@/services/audit";
import { createNotification } from "@/services/notifications";
import { createPanelAdapter } from "@/services/node-panel/factory";
async function setProxyClientEnabled(subscriptionId: string, enable: boolean) {
const client = await prisma.nodeClient.findUnique({
where: { subscriptionId },
select: {
id: true,
uuid: true,
inbound: {
select: {
serverId: true,
panelInboundId: true,
server: true,
},
},
},
});
if (!client) {
return null;
}
if (client.inbound.panelInboundId == null) {
throw new Error("3x-ui 入站 ID 缺失,请重新同步节点入站");
}
const adapter = createPanelAdapter(client.inbound.server);
await adapter.login();
await adapter.updateClientEnable(client.inbound.panelInboundId, client.uuid, enable);
await prisma.nodeClient.update({
where: { id: client.id },
data: { isEnabled: enable },
});
return client.inbound.serverId;
}
async function hardDeleteProxyClient(subscriptionId: string) {
const client = await prisma.nodeClient.findUnique({
where: { subscriptionId },
select: {
id: true,
uuid: true,
inbound: {
select: {
serverId: true,
panelInboundId: true,
server: true,
},
},
},
});
if (!client) {
return null;
}
if (client.inbound.panelInboundId == null) {
throw new Error("3x-ui 入站 ID 缺失,请重新同步节点入站");
}
const adapter = createPanelAdapter(client.inbound.server);
await adapter.login();
await adapter.deleteClient(client.inbound.panelInboundId, client.uuid);
await prisma.nodeClient.delete({
where: { id: client.id },
});
return client.inbound.serverId;
}
async function hardDeleteSubscriptionInternal(
subscriptionId: string,
options: {
actor: ReturnType<typeof actorFromSession>;
revalidate?: boolean;
},
) {
const subscription = await prisma.userSubscription.findUniqueOrThrow({
where: { id: subscriptionId },
include: {
plan: true,
user: true,
streamingSlot: true,
},
});
if (subscription.plan.type === "PROXY") {
await hardDeleteProxyClient(subscription.id);
}
await prisma.$transaction(async (tx) => {
if (subscription.streamingSlot) {
await tx.streamingSlot.delete({
where: { id: subscription.streamingSlot.id },
});
await tx.streamingService.updateMany({
where: {
id: subscription.streamingSlot.serviceId,
usedSlots: { gt: 0 },
},
data: {
usedSlots: { decrement: 1 },
},
});
}
await tx.order.deleteMany({
where: {
OR: [
{ targetSubscriptionId: subscription.id },
{ subscriptionId: subscription.id },
],
},
});
await tx.userSubscription.delete({
where: { id: subscription.id },
});
});
await createNotification({
userId: subscription.userId,
type: "SUBSCRIPTION",
level: "WARNING",
title: "订阅已被删除",
body: `${subscription.plan.name} 已被管理员彻底删除。`,
link: "/subscriptions",
});
await recordAuditLog({
actor: options.actor,
action: "subscription.delete",
targetType: "UserSubscription",
targetId: subscription.id,
targetLabel: `${subscription.user.email} / ${subscription.plan.name}`,
message: `彻底删除订阅 ${subscription.plan.name}`,
});
if (options.revalidate !== false) {
revalidateSubscriptionViews();
}
}
function revalidateSubscriptionViews() {
revalidatePath("/admin/subscriptions");
revalidatePath("/admin/traffic");
revalidatePath("/subscriptions");
revalidatePath("/dashboard");
revalidatePath("/notifications");
}
export async function suspendSubscription(subscriptionId: string) {
const session = await requireAdmin();
const subscription = await prisma.userSubscription.findUniqueOrThrow({
where: { id: subscriptionId },
include: {
plan: true,
user: true,
},
});
if (subscription.status !== "ACTIVE") {
throw new Error("仅活跃订阅可暂停");
}
if (subscription.plan.type === "PROXY") {
await setProxyClientEnabled(subscription.id, false);
}
await prisma.userSubscription.update({
where: { id: subscription.id },
data: { status: "SUSPENDED" },
});
await createNotification({
userId: subscription.userId,
type: "SUBSCRIPTION",
level: "WARNING",
title: "订阅已暂停",
body: `${subscription.plan.name} 已被管理员暂停,如有疑问请联系管理员。`,
link: "/subscriptions",
});
await recordAuditLog({
actor: actorFromSession(session),
action: "subscription.suspend",
targetType: "UserSubscription",
targetId: subscription.id,
targetLabel: `${subscription.user.email} / ${subscription.plan.name}`,
message: `暂停订阅 ${subscription.plan.name}`,
});
revalidateSubscriptionViews();
}
export async function activateSubscription(subscriptionId: string) {
const session = await requireAdmin();
const subscription = await prisma.userSubscription.findUniqueOrThrow({
where: { id: subscriptionId },
include: {
plan: true,
user: true,
},
});
if (subscription.status !== "SUSPENDED") {
throw new Error("仅已暂停订阅可恢复");
}
if (subscription.endDate <= new Date()) {
throw new Error("订阅已过期,无法恢复");
}
if (subscription.plan.type === "PROXY") {
await setProxyClientEnabled(subscription.id, true);
}
await prisma.userSubscription.update({
where: { id: subscription.id },
data: { status: "ACTIVE" },
});
await createNotification({
userId: subscription.userId,
type: "SUBSCRIPTION",
level: "SUCCESS",
title: "订阅已恢复",
body: `${subscription.plan.name} 已恢复为可用状态。`,
link: "/subscriptions",
});
await recordAuditLog({
actor: actorFromSession(session),
action: "subscription.activate",
targetType: "UserSubscription",
targetId: subscription.id,
targetLabel: `${subscription.user.email} / ${subscription.plan.name}`,
message: `恢复订阅 ${subscription.plan.name}`,
});
revalidateSubscriptionViews();
}
export async function cancelSubscription(subscriptionId: string) {
const session = await requireAdmin();
const subscription = await prisma.userSubscription.findUniqueOrThrow({
where: { id: subscriptionId },
include: {
plan: true,
user: true,
streamingSlot: true,
},
});
if (subscription.status === "CANCELLED") {
throw new Error("订阅已取消");
}
if (subscription.plan.type === "PROXY") {
await setProxyClientEnabled(subscription.id, false);
}
await prisma.$transaction(async (tx) => {
if (subscription.streamingSlot) {
await tx.streamingSlot.delete({
where: { id: subscription.streamingSlot.id },
});
await tx.streamingService.update({
where: { id: subscription.streamingSlot.serviceId },
data: {
usedSlots: {
decrement: 1,
},
},
});
}
await tx.userSubscription.update({
where: { id: subscription.id },
data: { status: "CANCELLED" },
});
});
await createNotification({
userId: subscription.userId,
type: "SUBSCRIPTION",
level: "WARNING",
title: "订阅已取消",
body: `${subscription.plan.name} 已被管理员取消。`,
link: "/subscriptions",
});
await recordAuditLog({
actor: actorFromSession(session),
action: "subscription.cancel",
targetType: "UserSubscription",
targetId: subscription.id,
targetLabel: `${subscription.user.email} / ${subscription.plan.name}`,
message: `取消订阅 ${subscription.plan.name}`,
});
revalidateSubscriptionViews();
}
export async function deleteSubscriptionPermanently(subscriptionId: string) {
const session = await requireAdmin();
await hardDeleteSubscriptionInternal(subscriptionId, {
actor: actorFromSession(session),
});
}
export async function reassignStreamingSlot(
subscriptionId: string,
targetServiceId: string,
) {
const session = await requireAdmin();
const subscription = await prisma.userSubscription.findUniqueOrThrow({
where: { id: subscriptionId },
include: {
user: true,
plan: true,
streamingSlot: {
include: {
service: true,
},
},
},
});
if (subscription.plan.type !== "STREAMING") {
throw new Error("仅流媒体订阅支持调配槽位");
}
if (subscription.status === "CANCELLED" || subscription.status === "EXPIRED") {
throw new Error("当前订阅状态不支持调配槽位");
}
const targetService = await prisma.streamingService.findUniqueOrThrow({
where: { id: targetServiceId },
select: {
id: true,
name: true,
isActive: true,
usedSlots: true,
maxSlots: true,
},
});
if (!targetService.isActive) {
throw new Error("目标流媒体服务未启用");
}
if (subscription.streamingSlot?.serviceId === targetService.id) {
throw new Error("已在当前服务上,无需重复调配");
}
if (targetService.usedSlots >= targetService.maxSlots) {
throw new Error("目标流媒体服务已满");
}
await prisma.$transaction(async (tx) => {
if (subscription.streamingSlot) {
await tx.streamingSlot.update({
where: { id: subscription.streamingSlot.id },
data: {
serviceId: targetService.id,
assignedAt: new Date(),
},
});
await tx.streamingService.updateMany({
where: {
id: subscription.streamingSlot.serviceId,
usedSlots: { gt: 0 },
},
data: {
usedSlots: { decrement: 1 },
},
});
await tx.streamingService.update({
where: { id: targetService.id },
data: {
usedSlots: { increment: 1 },
},
});
} else {
await tx.streamingSlot.create({
data: {
subscriptionId: subscription.id,
userId: subscription.userId,
serviceId: targetService.id,
},
});
await tx.streamingService.update({
where: { id: targetService.id },
data: {
usedSlots: { increment: 1 },
},
});
}
});
await createNotification({
userId: subscription.userId,
type: "SUBSCRIPTION",
level: "INFO",
title: "流媒体服务已调整",
body: `${subscription.plan.name} 已调整到服务 ${targetService.name}`,
link: "/subscriptions",
});
await recordAuditLog({
actor: actorFromSession(session),
action: "streaming-slot.reassign",
targetType: "StreamingSlot",
targetId: subscription.streamingSlot?.id ?? subscription.id,
targetLabel: `${subscription.user.email} / ${subscription.plan.name}`,
message: `将流媒体订阅 ${subscription.plan.name} 调配到 ${targetService.name}`,
});
revalidateSubscriptionViews();
revalidatePath("/admin/services");
}
export async function batchSubscriptionOperation(formData: FormData) {
const action = String(formData.get("action") || "");
const subscriptionIds = formData.getAll("subscriptionIds").map(String).filter(Boolean);
if (subscriptionIds.length === 0) {
throw new Error("请至少选择一个订阅");
}
for (const subscriptionId of subscriptionIds) {
if (action === "suspend") {
await suspendSubscription(subscriptionId);
} else if (action === "activate") {
await activateSubscription(subscriptionId);
} else if (action === "cancel") {
await cancelSubscription(subscriptionId);
} else if (action === "delete") {
await deleteSubscriptionPermanently(subscriptionId);
} else {
throw new Error("不支持的批量操作");
}
}
}

View File

@@ -0,0 +1,158 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/require-auth";
import { actorFromSession, recordAuditLog } from "@/services/audit";
import { createNotification } from "@/services/notifications";
import {
createSupportAttachments,
deleteSupportTicketRecords,
parseSupportAttachments,
} from "@/services/support";
const replySchema = z.object({
body: z.string().trim().min(1, "回复内容不能为空"),
});
const supportStatusSchema = z.enum(["OPEN", "USER_REPLIED", "ADMIN_REPLIED", "CLOSED"]);
const supportPrioritySchema = z.enum(["LOW", "NORMAL", "HIGH", "URGENT"]);
export async function replySupportAsAdmin(ticketId: string, formData: FormData) {
const session = await requireAdmin();
const data = replySchema.parse(Object.fromEntries(formData));
const attachments = parseSupportAttachments(formData.getAll("attachments"));
const ticket = await prisma.supportTicket.findUniqueOrThrow({
where: { id: ticketId },
include: {
user: {
select: { id: true, email: true },
},
},
});
if (ticket.status === "CLOSED") {
throw new Error("已关闭的工单不能继续回复,请先重新打开");
}
const updated = await prisma.supportTicket.update({
where: { id: ticket.id },
data: {
status: "ADMIN_REPLIED",
closedAt: null,
lastReplyAt: new Date(),
replies: {
create: {
authorUserId: session.user.id,
isAdmin: true,
body: data.body,
},
},
},
include: {
replies: {
orderBy: { createdAt: "desc" },
take: 1,
},
},
});
const createdReply = updated.replies[0];
if (createdReply && attachments.length > 0) {
await createSupportAttachments({
ticketId: ticket.id,
replyId: createdReply.id,
files: attachments,
});
}
await createNotification({
userId: ticket.user.id,
type: "SYSTEM",
level: "INFO",
title: "工单有新回复",
body: `管理员已回复工单「${ticket.subject}」。`,
link: `/support/${ticket.id}`,
});
await recordAuditLog({
actor: actorFromSession(session),
action: "support.reply",
targetType: "SupportTicket",
targetId: ticket.id,
targetLabel: ticket.subject,
message: `回复工单 ${ticket.subject}`,
});
revalidatePath("/admin/support");
revalidatePath(`/admin/support/${ticket.id}`);
revalidatePath(`/support/${ticket.id}`);
}
export async function updateSupportTicketMeta(formData: FormData) {
const session = await requireAdmin();
const ticketId = String(formData.get("ticketId") || "");
const status = supportStatusSchema.parse(String(formData.get("status") || ""));
const priority = supportPrioritySchema.parse(String(formData.get("priority") || ""));
if (!ticketId) {
throw new Error("工单 ID 缺失");
}
const ticket = await prisma.supportTicket.update({
where: { id: ticketId },
data: {
status,
priority,
closedAt: status === "CLOSED" ? new Date() : null,
},
});
await recordAuditLog({
actor: actorFromSession(session),
action: "support.update",
targetType: "SupportTicket",
targetId: ticket.id,
targetLabel: ticket.subject,
message: `更新工单 ${ticket.subject} 状态/优先级`,
});
revalidatePath("/admin/support");
revalidatePath(`/admin/support/${ticket.id}`);
revalidatePath(`/support/${ticket.id}`);
}
export async function deleteSupportTicketAsAdmin(ticketId: string) {
const session = await requireAdmin();
const ticket = await prisma.supportTicket.findUnique({
where: { id: ticketId },
select: {
id: true,
subject: true,
},
});
if (!ticket) {
throw new Error("工单不存在");
}
await prisma.$transaction(async (tx) => {
await deleteSupportTicketRecords(ticket.id, tx);
await recordAuditLog(
{
actor: actorFromSession(session),
action: "support.delete",
targetType: "SupportTicket",
targetId: ticket.id,
targetLabel: ticket.subject,
message: `管理员删除工单 ${ticket.subject}`,
},
tx,
);
});
revalidatePath("/admin/support");
revalidatePath(`/admin/support/${ticket.id}`);
revalidatePath("/support");
revalidatePath(`/support/${ticket.id}`);
revalidatePath("/notifications");
}

110
src/actions/admin/tasks.ts Normal file
View File

@@ -0,0 +1,110 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireAdmin } from "@/lib/require-auth";
import { actorFromSession, recordAuditLog } from "@/services/audit";
import { dispatchSubscriptionReminders } from "@/services/notifications";
import { confirmPendingOrder } from "@/services/payment/process";
import { runTask, updateTaskRun } from "@/services/task-center";
import { prisma } from "@/lib/prisma";
function revalidateTaskViews() {
revalidatePath("/admin/tasks");
revalidatePath("/admin/health");
revalidatePath("/admin/traffic");
revalidatePath("/admin/audit-logs");
revalidatePath("/notifications");
}
export async function runReminderTask() {
const session = await requireAdmin();
const actor = actorFromSession(session);
const outcome = await runTask(
{
kind: "REMINDER_DISPATCH",
title: "手动派发提醒任务",
triggeredById: session.user.id,
},
async () => {
await dispatchSubscriptionReminders();
return { ok: true };
},
);
await recordAuditLog({
actor,
action: "task.run",
targetType: "TaskRun",
targetId: outcome.taskId,
targetLabel: "REMINDER_DISPATCH",
message: "手动执行提醒派发任务",
});
revalidateTaskViews();
}
export async function retryTaskRun(taskId: string) {
const session = await requireAdmin();
const actor = actorFromSession(session);
const task = await prisma.taskRun.findUniqueOrThrow({
where: { id: taskId },
});
await updateTaskRun(task.id, {
status: "RUNNING",
errorMessage: null,
startedAt: new Date(),
retryCountIncrement: true,
});
try {
let result: unknown = { ok: true };
if (task.kind === "ORDER_PROVISION_RETRY") {
const orderId = (task.payload as { orderId?: string } | null)?.orderId;
if (!orderId) {
throw new Error("任务缺少订单 ID");
}
result = await confirmPendingOrder(orderId);
} else if (task.kind === "REMINDER_DISPATCH") {
await dispatchSubscriptionReminders();
}
await updateTaskRun(task.id, {
status: "SUCCESS",
finishedAt: new Date(),
result: result as never,
});
await recordAuditLog({
actor,
action: "task.retry",
targetType: "TaskRun",
targetId: task.id,
targetLabel: task.kind,
message: `重试任务 ${task.title}`,
});
} catch (error) {
await updateTaskRun(task.id, {
status: "FAILED",
finishedAt: new Date(),
errorMessage: error instanceof Error ? error.message : "重试失败",
});
throw error;
}
revalidateTaskViews();
}
export async function batchRetryTaskRuns(formData: FormData) {
const taskIds = formData.getAll("taskIds").map(String).filter(Boolean);
if (taskIds.length === 0) {
throw new Error("请至少选择一个任务");
}
for (const taskId of taskIds) {
await retryTaskRun(taskId);
}
}

View File

@@ -0,0 +1,35 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireAdmin } from "@/lib/require-auth";
import { actorFromSession, recordAuditLog } from "@/services/audit";
import { syncNodeClientTraffic } from "@/services/traffic-sync";
export async function syncTrafficViews() {
const session = await requireAdmin();
const result = await syncNodeClientTraffic({ maxAgeMs: 0 });
await recordAuditLog({
actor: actorFromSession(session),
action: "traffic.sync",
targetType: "TrafficSync",
message: `同步 3x-ui 流量:成功 ${result.synced} 个,失败 ${result.failed}`,
metadata: {
scanned: result.scanned,
synced: result.synced,
skipped: result.skipped,
failed: result.failed,
uploadDelta: result.uploadDelta,
downloadDelta: result.downloadDelta,
errors: result.errors,
},
});
revalidatePath("/admin/traffic");
revalidatePath("/dashboard");
revalidatePath("/subscriptions");
revalidatePath("/notifications");
return result;
}
export async function revalidateTrafficViews() {
return syncTrafficViews();
}

134
src/actions/admin/users.ts Normal file
View File

@@ -0,0 +1,134 @@
"use server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/require-auth";
import { revalidatePath } from "next/cache";
import bcrypt from "bcryptjs";
import { z } from "zod";
import { actorFromSession, recordAuditLog } from "@/services/audit";
const createUserSchema = z.object({
email: z.string().email(),
password: z.string().min(6),
name: z.string().optional(),
role: z.enum(["ADMIN", "USER"]).default("USER"),
});
const updateUserSchema = z.object({
email: z.string().email(),
password: z.string().optional(),
name: z.string().optional(),
role: z.enum(["ADMIN", "USER"]),
});
export async function createUser(formData: FormData) {
const session = await requireAdmin();
const data = createUserSchema.parse(Object.fromEntries(formData));
const hashed = await bcrypt.hash(data.password, 12);
const user = await prisma.user.create({
data: { email: data.email, password: hashed, name: data.name || null, role: data.role },
});
await recordAuditLog({
actor: actorFromSession(session),
action: "user.create",
targetType: "User",
targetId: user.id,
targetLabel: user.email,
message: `创建用户 ${user.email}`,
});
revalidatePath("/admin/users");
}
export async function updateUser(id: string, formData: FormData) {
const session = await requireAdmin();
const data = updateUserSchema.parse(Object.fromEntries(formData));
const updateData: {
email: string;
name: string | null;
role: "ADMIN" | "USER";
password?: string;
} = {
email: data.email,
name: data.name || null,
role: data.role,
};
if (data.password && data.password.trim()) {
updateData.password = await bcrypt.hash(data.password.trim(), 12);
}
const user = await prisma.user.update({
where: { id },
data: updateData,
});
await recordAuditLog({
actor: actorFromSession(session),
action: "user.update",
targetType: "User",
targetId: user.id,
targetLabel: user.email,
message: `更新用户 ${user.email}`,
});
revalidatePath("/admin/users");
}
export async function updateUserStatus(id: string, status: "ACTIVE" | "DISABLED" | "BANNED") {
const session = await requireAdmin();
const user = await prisma.user.update({ where: { id }, data: { status } });
await recordAuditLog({
actor: actorFromSession(session),
action: "user.status",
targetType: "User",
targetId: user.id,
targetLabel: user.email,
message: `将用户 ${user.email} 状态改为 ${status}`,
});
revalidatePath("/admin/users");
}
export async function deleteUser(id: string) {
const session = await requireAdmin();
const user = await prisma.user.delete({ where: { id } });
await recordAuditLog({
actor: actorFromSession(session),
action: "user.delete",
targetType: "User",
targetId: user.id,
targetLabel: user.email,
message: `删除用户 ${user.email}`,
});
revalidatePath("/admin/users");
}
export async function batchUpdateUserStatus(formData: FormData) {
const session = await requireAdmin();
const status = formData.get("status");
const userIds = formData.getAll("userIds").map(String).filter(Boolean);
if (!status || !["ACTIVE", "DISABLED", "BANNED"].includes(String(status))) {
throw new Error("批量状态无效");
}
if (userIds.length === 0) {
throw new Error("请至少选择一个用户");
}
await prisma.user.updateMany({
where: { id: { in: userIds } },
data: { status: status as "ACTIVE" | "DISABLED" | "BANNED" },
});
await recordAuditLog({
actor: actorFromSession(session),
action: "user.batch_status",
targetType: "User",
message: `批量更新 ${userIds.length} 个用户状态为 ${status}`,
metadata: {
userIds,
status: String(status),
},
});
revalidatePath("/admin/users");
}

View File

@@ -0,0 +1,85 @@
"use server";
import bcrypt from "bcryptjs";
import { revalidatePath } from "next/cache";
import { randomBytes } from "crypto";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/require-auth";
const profileSchema = z.object({
name: z.string().trim().min(1, "昵称不能为空").max(50, "昵称不能超过 50 个字符"),
});
const passwordSchema = z.object({
currentPassword: z.string().min(6, "当前密码不能为空"),
newPassword: z.string().min(6, "新密码至少 6 位"),
confirmPassword: z.string().min(6, "确认密码至少 6 位"),
});
async function generateUniqueInviteCode(): Promise<string> {
for (let i = 0; i < 10; i += 1) {
const code = randomBytes(4).toString("hex").toUpperCase();
const exists = await prisma.user.findUnique({
where: { inviteCode: code },
select: { id: true },
});
if (!exists) {
return code;
}
}
throw new Error("邀请码生成失败,请稍后重试");
}
export async function updateAccountProfile(formData: FormData) {
const session = await requireAuth();
const data = profileSchema.parse(Object.fromEntries(formData));
await prisma.user.update({
where: { id: session.user.id },
data: { name: data.name },
});
revalidatePath("/account");
}
export async function changeAccountPassword(formData: FormData) {
const session = await requireAuth();
const data = passwordSchema.parse(Object.fromEntries(formData));
if (data.newPassword !== data.confirmPassword) {
throw new Error("两次输入的新密码不一致");
}
const user = await prisma.user.findUniqueOrThrow({
where: { id: session.user.id },
select: { password: true },
});
const valid = await bcrypt.compare(data.currentPassword, user.password);
if (!valid) {
throw new Error("当前密码不正确");
}
const hashed = await bcrypt.hash(data.newPassword, 12);
await prisma.user.update({
where: { id: session.user.id },
data: { password: hashed },
});
revalidatePath("/account");
}
export async function generateInviteCode() {
const session = await requireAuth();
const code = await generateUniqueInviteCode();
await prisma.user.update({
where: { id: session.user.id },
data: { inviteCode: code },
});
revalidatePath("/account");
return code;
}

266
src/actions/user/cart.ts Normal file
View File

@@ -0,0 +1,266 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/require-auth";
import { buildUnavailableMessage, getPlanAvailability } from "@/services/plan-availability";
import { getPlanPurchasePrice, calculateCheckoutDiscounts } from "@/services/commerce";
import { ensurePlanTrafficPoolCapacity } from "@/services/plan-traffic-pool";
async function assertNoPendingOrder(userId: string) {
const pendingOrder = await prisma.order.findFirst({
where: { userId, status: "PENDING" },
select: { id: true },
orderBy: { createdAt: "desc" },
});
if (pendingOrder) {
throw new Error("你还有一笔订单正在等待支付,请先完成或取消后再继续购买");
}
}
async function getProxyPlanForCart(planId: string) {
const plan = await prisma.subscriptionPlan.findUniqueOrThrow({
where: { id: planId },
include: {
inboundOptions: {
include: {
inbound: {
select: { id: true, serverId: true, isActive: true },
},
},
},
},
});
if (plan.type !== "PROXY") throw new Error("套餐类型错误");
if (!plan.isActive) throw new Error("套餐已下架");
return plan;
}
function assertInboundSelectable(
plan: Awaited<ReturnType<typeof getProxyPlanForCart>>,
selectedInboundId: string,
) {
const selectableInboundIds = plan.inboundOptions
.filter(
(item) =>
item.inbound.isActive
&& (!plan.nodeId || item.inbound.serverId === plan.nodeId),
)
.map((item) => item.inboundId);
const selectable = selectableInboundIds.length > 0
? selectableInboundIds
: plan.inboundId
? [plan.inboundId]
: [];
if (!selectedInboundId || !selectable.includes(selectedInboundId)) {
throw new Error("请选择有效的线路入口");
}
}
export async function addProxyPlanToCart(
planId: string,
trafficGb: number,
selectedInboundId: string,
) {
const session = await requireAuth();
const plan = await getProxyPlanForCart(planId);
assertInboundSelectable(plan, selectedInboundId);
const price = getPlanPurchasePrice(plan, trafficGb);
if (price.trafficGb != null) {
await ensurePlanTrafficPoolCapacity(plan.id, price.trafficGb, {
messagePrefix: "这款套餐额度暂时不足",
});
}
const availability = await getPlanAvailability(plan, { userId: session.user.id });
if (!availability.available) {
throw new Error(buildUnavailableMessage(availability));
}
const existing = await prisma.shoppingCartItem.findFirst({
where: {
userId: session.user.id,
planId,
selectedInboundId,
trafficGb: price.trafficGb,
},
select: { id: true },
});
if (existing) {
await prisma.shoppingCartItem.update({
where: { id: existing.id },
data: { updatedAt: new Date() },
});
} else {
await prisma.shoppingCartItem.create({
data: {
userId: session.user.id,
planId,
selectedInboundId,
trafficGb: price.trafficGb,
},
});
}
revalidatePath("/cart");
revalidatePath("/store");
}
export async function addStreamingPlanToCart(planId: string) {
const session = await requireAuth();
const plan = await prisma.subscriptionPlan.findUniqueOrThrow({ where: { id: planId } });
if (plan.type !== "STREAMING") throw new Error("套餐类型错误");
if (!plan.isActive) throw new Error("套餐已下架");
const availability = await getPlanAvailability(plan, { userId: session.user.id });
if (!availability.available) {
throw new Error(buildUnavailableMessage(availability));
}
const existing = await prisma.shoppingCartItem.findFirst({
where: { userId: session.user.id, planId, selectedInboundId: null, trafficGb: null },
select: { id: true },
});
if (existing) {
await prisma.shoppingCartItem.update({
where: { id: existing.id },
data: { updatedAt: new Date() },
});
} else {
await prisma.shoppingCartItem.create({ data: { userId: session.user.id, planId } });
}
revalidatePath("/cart");
revalidatePath("/store");
}
export async function removeCartItem(itemId: string) {
const session = await requireAuth();
await prisma.shoppingCartItem.deleteMany({
where: { id: itemId, userId: session.user.id },
});
revalidatePath("/cart");
revalidatePath("/store");
}
export async function clearCart() {
const session = await requireAuth();
await prisma.shoppingCartItem.deleteMany({ where: { userId: session.user.id } });
revalidatePath("/cart");
revalidatePath("/store");
}
export async function checkoutCart(couponCode?: string | null): Promise<string> {
const session = await requireAuth();
await assertNoPendingOrder(session.user.id);
const items = await prisma.shoppingCartItem.findMany({
where: { userId: session.user.id },
include: {
plan: true,
selectedInbound: true,
},
orderBy: { createdAt: "asc" },
});
if (items.length === 0) throw new Error("购物车还是空的");
const orderItems: Array<{
planId: string;
selectedInboundId: string | null;
trafficGb: number | null;
unitAmount: number;
amount: number;
}> = [];
const trafficByPlan = new Map<string, number>();
for (const item of items) {
if (!item.plan.isActive) throw new Error(`${item.plan.name} 已下架,请先移出购物车`);
const availability = await getPlanAvailability(item.plan, { userId: session.user.id });
if (!availability.available) {
throw new Error(`${item.plan.name}${buildUnavailableMessage(availability)}`);
}
if (item.plan.type === "PROXY") {
if (!item.selectedInboundId) throw new Error(`${item.plan.name} 缺少线路入口`);
const plan = await getProxyPlanForCart(item.planId);
assertInboundSelectable(plan, item.selectedInboundId);
const price = getPlanPurchasePrice(item.plan, item.trafficGb);
if (!price.trafficGb) throw new Error(`${item.plan.name} 缺少流量配置`);
trafficByPlan.set(item.planId, (trafficByPlan.get(item.planId) ?? 0) + price.trafficGb);
orderItems.push({
planId: item.planId,
selectedInboundId: item.selectedInboundId,
trafficGb: price.trafficGb,
unitAmount: price.unitAmount,
amount: price.amount,
});
} else {
const price = getPlanPurchasePrice(item.plan);
orderItems.push({
planId: item.planId,
selectedInboundId: null,
trafficGb: null,
unitAmount: price.unitAmount,
amount: price.amount,
});
}
}
for (const [planId, trafficGb] of trafficByPlan) {
await ensurePlanTrafficPoolCapacity(planId, trafficGb, {
messagePrefix: "购物车中的代理套餐额度暂时不足",
});
}
const subtotal = orderItems.reduce((sum, item) => sum + item.amount, 0);
const discounts = await calculateCheckoutDiscounts({
userId: session.user.id,
subtotal,
couponCode,
});
const order = await prisma.$transaction(async (tx) => {
const created = await tx.order.create({
data: {
userId: session.user.id,
planId: orderItems[0].planId,
kind: "NEW_PURCHASE",
amount: discounts.payable,
subtotalAmount: discounts.subtotal,
discountAmount: discounts.totalDiscount,
couponId: discounts.coupon?.id ?? null,
couponCode: discounts.coupon?.code ?? null,
promotionName: discounts.promotion?.name ?? null,
status: "PENDING",
items: {
create: orderItems,
},
},
});
if (discounts.couponGrantId) {
await tx.couponGrant.update({
where: { id: discounts.couponGrantId },
data: { usedOrderId: created.id, usedAt: new Date() },
});
}
await tx.shoppingCartItem.deleteMany({ where: { userId: session.user.id } });
return created;
});
revalidatePath("/cart");
revalidatePath("/store");
revalidatePath("/orders");
return order.id;
}

View File

@@ -0,0 +1,34 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireAuth } from "@/lib/require-auth";
import {
deleteNotification,
deleteReadNotifications,
markAllNotificationsRead,
markNotificationRead,
} from "@/services/notifications";
export async function markNotificationAsRead(notificationId: string) {
const session = await requireAuth();
await markNotificationRead(notificationId, session.user.id);
revalidatePath("/notifications");
}
export async function markEveryNotificationAsRead() {
const session = await requireAuth();
await markAllNotificationsRead(session.user.id);
revalidatePath("/notifications");
}
export async function removeNotification(notificationId: string) {
const session = await requireAuth();
await deleteNotification(notificationId, session.user.id);
revalidatePath("/notifications");
}
export async function removeReadNotifications() {
const session = await requireAuth();
await deleteReadNotifications(session.user.id);
revalidatePath("/notifications");
}

View File

@@ -0,0 +1,71 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/require-auth";
async function findOwnPendingOrder(orderId: string, userId: string) {
const order = await prisma.order.findFirst({
where: {
id: orderId,
userId,
},
select: { id: true, status: true },
});
if (!order) {
throw new Error("订单不存在");
}
if (order.status !== "PENDING") {
throw new Error("这笔订单已经不在待支付状态");
}
return order;
}
export async function cancelOwnPendingOrder(orderId: string) {
const session = await requireAuth();
await findOwnPendingOrder(orderId, session.user.id);
await prisma.$transaction(async (tx) => {
await tx.order.update({
where: { id: orderId },
data: {
status: "CANCELLED",
paymentMethod: null,
paymentRef: null,
paymentUrl: null,
tradeNo: null,
expireAt: null,
},
});
await tx.couponGrant.updateMany({
where: { usedOrderId: orderId },
data: { usedOrderId: null, usedAt: null },
});
});
revalidatePath("/orders");
revalidatePath("/store");
revalidatePath(`/pay/${orderId}`);
}
export async function resetOwnPendingPaymentChoice(orderId: string) {
const session = await requireAuth();
await findOwnPendingOrder(orderId, session.user.id);
await prisma.order.update({
where: { id: orderId },
data: {
paymentMethod: null,
paymentRef: null,
paymentUrl: null,
tradeNo: null,
expireAt: null,
},
});
revalidatePath("/orders");
revalidatePath(`/pay/${orderId}`);
}

View File

@@ -0,0 +1,387 @@
"use server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { getErrorMessage } from "@/lib/errors";
import {
buildUnavailableMessage,
formatAvailabilityDateTime,
getPlanAvailability,
} from "@/services/plan-availability";
import {
ensurePlanTrafficPoolCapacity,
getPlanTrafficPoolState,
} from "@/services/plan-traffic-pool";
import { getPlanPurchasePrice, roundMoney } from "@/services/commerce";
async function assertNoPendingOrder(userId: string) {
const pendingOrder = await prisma.order.findFirst({
where: { userId, status: "PENDING" },
select: { id: true },
orderBy: { createdAt: "desc" },
});
if (pendingOrder) {
throw new Error("你还有一笔订单正在等待支付,请先完成或取消后再继续购买");
}
}
function getRenewalOrderPrice(
plan: {
durationDays: number;
renewalPrice: unknown;
renewalPricingMode: string;
renewalDurationDays: number | null;
renewalMinDays: number | null;
renewalMaxDays: number | null;
},
requestedDays?: number,
) {
const unitPrice = Number(plan.renewalPrice ?? 0);
if (!Number.isFinite(unitPrice) || unitPrice <= 0) {
throw new Error("这款套餐暂时不支持续费");
}
const mode = plan.renewalPricingMode === "PER_DAY" ? "PER_DAY" : "FIXED_DURATION";
if (mode === "PER_DAY") {
const minDays = plan.renewalMinDays ?? 1;
const maxDays = plan.renewalMaxDays ?? plan.durationDays;
const durationDays = requestedDays ?? minDays;
if (!Number.isInteger(durationDays) || durationDays < minDays || durationDays > maxDays) {
throw new Error(`续费天数范围: ${minDays}-${maxDays}`);
}
return {
durationDays,
amount: roundMoney(unitPrice * durationDays),
};
}
const unitDays = plan.renewalDurationDays ?? plan.durationDays;
const minDays = plan.renewalMinDays ?? unitDays;
const maxDays = plan.renewalMaxDays ?? unitDays;
const durationDays = requestedDays ?? unitDays;
if (!Number.isInteger(durationDays) || durationDays < minDays || durationDays > maxDays) {
throw new Error(`续费天数范围: ${minDays}-${maxDays}`);
}
if (durationDays % unitDays !== 0) {
throw new Error(`续费天数必须是 ${unitDays} 天的整数倍`);
}
return {
durationDays,
amount: roundMoney(unitPrice * (durationDays / unitDays)),
};
}
function getTrafficTopupOrderPrice(
plan: {
topupPricingMode: string;
topupPricePerGb: unknown;
topupFixedPrice: unknown;
minTopupGb: number | null;
maxTopupGb: number | null;
pricePerGb: unknown;
},
trafficGb: number,
) {
const minTopupGb = plan.minTopupGb ?? 1;
const maxTopupGb = plan.maxTopupGb ?? null;
if (trafficGb < minTopupGb) {
throw new Error(`单次至少增加 ${minTopupGb} GB`);
}
if (maxTopupGb != null && trafficGb > maxTopupGb) {
throw new Error(`单次最多增加 ${maxTopupGb} GB`);
}
if (plan.topupPricingMode === "FIXED_AMOUNT") {
const fixedAmount = Number(plan.topupFixedPrice ?? 0);
if (!Number.isFinite(fixedAmount) || fixedAmount <= 0) {
throw new Error("这款套餐暂时不能增加流量");
}
return roundMoney(fixedAmount);
}
const pricePerGb = Number(plan.topupPricePerGb ?? plan.pricePerGb ?? 0);
if (!Number.isFinite(pricePerGb) || pricePerGb <= 0) {
throw new Error("这款套餐暂时不能增加流量");
}
return roundMoney(pricePerGb * trafficGb);
}
export async function purchaseProxy(
planId: string,
trafficGb: number,
selectedInboundId: string,
): Promise<string> {
const session = await getServerSession(authOptions);
if (!session) throw new Error("未登录");
await assertNoPendingOrder(session.user.id);
const plan = await prisma.subscriptionPlan.findUniqueOrThrow({
where: { id: planId },
include: {
inboundOptions: {
include: {
inbound: {
select: {
id: true,
isActive: true,
serverId: true,
},
},
},
},
},
});
if (plan.type !== "PROXY") throw new Error("套餐类型错误");
if (!plan.isActive) throw new Error("套餐已下架");
const price = getPlanPurchasePrice(plan, trafficGb);
const poolState = await getPlanTrafficPoolState(plan.id);
if (poolState.enabled && price.trafficGb != null) {
await ensurePlanTrafficPoolCapacity(plan.id, price.trafficGb, {
messagePrefix: "这款套餐额度暂时不足",
});
}
const availability = await getPlanAvailability(plan, { userId: session.user.id });
if (!availability.available) {
throw new Error(buildUnavailableMessage(availability));
}
const selectableInboundIds = plan.inboundOptions
.filter(
(item) =>
item.inbound.isActive
&& (!plan.nodeId || item.inbound.serverId === plan.nodeId),
)
.map((item) => item.inboundId);
let fallbackInboundId = "";
if (plan.inboundId && plan.nodeId) {
const fallbackInbound = await prisma.nodeInbound.findFirst({
where: {
id: plan.inboundId,
serverId: plan.nodeId,
isActive: true,
},
select: { id: true },
});
fallbackInboundId = fallbackInbound?.id ?? "";
}
const selectable = selectableInboundIds.length > 0 ? selectableInboundIds : [fallbackInboundId];
if (!selectedInboundId || !selectable.filter(Boolean).includes(selectedInboundId)) {
throw new Error("请选择有效的线路入口");
}
if (!selectable[0]) {
throw new Error("这款套餐的线路入口正在整理中,暂时不能购买");
}
const order = await prisma.order.create({
data: {
userId: session.user.id,
planId,
kind: "NEW_PURCHASE",
amount: price.amount,
subtotalAmount: price.amount,
discountAmount: 0,
selectedInboundId,
status: "PENDING",
items: {
create: {
planId,
selectedInboundId,
trafficGb: price.trafficGb,
unitAmount: price.unitAmount,
amount: price.amount,
},
},
},
});
return order.id;
}
export async function purchaseStreaming(planId: string): Promise<string> {
const session = await getServerSession(authOptions);
if (!session) throw new Error("未登录");
await assertNoPendingOrder(session.user.id);
const plan = await prisma.subscriptionPlan.findUniqueOrThrow({
where: { id: planId },
});
if (plan.type !== "STREAMING") throw new Error("套餐类型错误");
if (!plan.isActive) throw new Error("套餐已下架");
const availability = await getPlanAvailability(plan, { userId: session.user.id });
if (!availability.available) {
throw new Error(buildUnavailableMessage(availability));
}
const price = getPlanPurchasePrice(plan);
const order = await prisma.order.create({
data: {
userId: session.user.id,
planId,
kind: "NEW_PURCHASE",
amount: price.amount,
subtotalAmount: price.amount,
discountAmount: 0,
status: "PENDING",
items: {
create: {
planId,
trafficGb: null,
unitAmount: price.unitAmount,
amount: price.amount,
},
},
},
});
return order.id;
}
export type PurchaseRenewalResult =
| { ok: true; orderId: string }
| { ok: false; error: string };
export async function purchaseRenewal(
subscriptionId: string,
renewalDays?: number,
): Promise<PurchaseRenewalResult> {
try {
const session = await getServerSession(authOptions);
if (!session) throw new Error("未登录");
await assertNoPendingOrder(session.user.id);
const subscription = await prisma.userSubscription.findFirst({
where: { id: subscriptionId, userId: session.user.id },
include: {
plan: true,
},
});
if (!subscription) throw new Error("订阅不存在");
if (subscription.status !== "ACTIVE" || subscription.endDate <= new Date()) {
throw new Error("仅支持对活跃订阅续费");
}
if (!subscription.plan.allowRenewal) {
throw new Error("这款套餐暂时不支持续费");
}
const price = getRenewalOrderPrice(subscription.plan, renewalDays);
const order = await prisma.order.create({
data: {
userId: session.user.id,
planId: subscription.planId,
kind: "RENEWAL",
targetSubscriptionId: subscription.id,
amount: price.amount,
subtotalAmount: price.amount,
discountAmount: 0,
durationDays: price.durationDays,
status: "PENDING",
},
});
return { ok: true, orderId: order.id };
} catch (error) {
return { ok: false, error: getErrorMessage(error, "创建续费订单失败") };
}
}
export async function purchaseTrafficTopup(
subscriptionId: string,
trafficGb: number,
): Promise<string> {
const session = await getServerSession(authOptions);
if (!session) throw new Error("未登录");
await assertNoPendingOrder(session.user.id);
if (!Number.isFinite(trafficGb) || trafficGb <= 0 || !Number.isInteger(trafficGb)) {
throw new Error("增流量必须是正整数 GB");
}
const subscription = await prisma.userSubscription.findFirst({
where: { id: subscriptionId, userId: session.user.id },
include: {
plan: true,
},
});
if (!subscription) throw new Error("订阅不存在");
if (subscription.plan.type !== "PROXY") {
throw new Error("仅代理订阅支持增流量");
}
if (subscription.status !== "ACTIVE" || subscription.endDate <= new Date()) {
throw new Error("增流量仅在当前套餐有效期内生效");
}
if (!subscription.plan.allowTrafficTopup) {
throw new Error("这款套餐暂时不支持增加流量");
}
const amount = getTrafficTopupOrderPrice(subscription.plan, trafficGb);
const poolState = await getPlanTrafficPoolState(subscription.planId);
if (poolState.enabled) {
const remainingGb = Math.max(0, Math.floor(poolState.remainingGb));
if (trafficGb > remainingGb) {
throw new Error(`剩余总流量不足,当前最多可增 ${remainingGb} GB`);
}
}
const order = await prisma.order.create({
data: {
userId: session.user.id,
planId: subscription.planId,
kind: "TRAFFIC_TOPUP",
targetSubscriptionId: subscription.id,
amount,
subtotalAmount: amount,
discountAmount: 0,
trafficGb,
status: "PENDING",
},
});
return order.id;
}
export async function queryPlanNextAvailability(planId: string): Promise<{
available: boolean;
message: string;
nextAvailableAt: string | null;
}> {
const session = await getServerSession(authOptions);
if (!session) throw new Error("未登录");
const plan = await prisma.subscriptionPlan.findUniqueOrThrow({
where: { id: planId },
select: {
id: true,
type: true,
totalLimit: true,
perUserLimit: true,
streamingServiceId: true,
isActive: true,
},
});
if (!plan.isActive) {
return {
available: false,
message: "这款套餐暂时不可购买",
nextAvailableAt: null,
};
}
const availability = await getPlanAvailability(plan, { userId: session.user.id });
return {
available: availability.available,
message: availability.available ? "这款套餐现在可以购买" : buildUnavailableMessage(availability),
nextAvailableAt: availability.nextAvailableAt
? formatAvailabilityDateTime(availability.nextAvailableAt)
: null,
};
}

View File

@@ -0,0 +1,108 @@
"use server";
import { revalidatePath } from "next/cache";
import { randomUUID } from "crypto";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/require-auth";
import { generateNodeClientCredential } from "@/services/node-client-credential";
import { bytesToGb } from "@/lib/utils";
import { createPanelAdapter } from "@/services/node-panel/factory";
import { createNotification } from "@/services/notifications";
import { recordAuditLog } from "@/services/audit";
function newDownloadToken() {
return randomUUID().replace(/-/g, "");
}
export async function rotateSubscriptionAccess(subscriptionId: string) {
const session = await requireAuth();
const subscription = await prisma.userSubscription.findFirst({
where: {
id: subscriptionId,
userId: session.user.id,
status: "ACTIVE",
},
include: {
plan: true,
nodeClient: {
include: {
inbound: {
include: {
server: true,
},
},
},
},
},
});
if (!subscription) {
throw new Error("订阅不存在或不可操作");
}
const nextToken = newDownloadToken();
if (subscription.plan.type === "PROXY" && subscription.nodeClient) {
const nextCredential = generateNodeClientCredential(
subscription.nodeClient.inbound.protocol,
subscription.nodeClient.inbound.settings,
);
const panelInboundId = subscription.nodeClient.inbound.panelInboundId;
if (panelInboundId == null) {
throw new Error("3x-ui 入站 ID 缺失,请重新同步节点入站");
}
const adapter = createPanelAdapter(subscription.nodeClient.inbound.server);
await adapter.login();
await adapter.deleteClient(panelInboundId, subscription.nodeClient.uuid);
await adapter.addClient({
inboundId: panelInboundId,
email: subscription.nodeClient.email,
uuid: nextCredential,
subId: subscription.id,
totalGB: subscription.trafficLimit ? bytesToGb(subscription.trafficLimit) : 0,
expiryTime: subscription.endDate.getTime(),
protocol: subscription.nodeClient.inbound.protocol,
});
await prisma.$transaction(async (tx) => {
await tx.nodeClient.update({
where: { id: subscription.nodeClient!.id },
data: { uuid: nextCredential },
});
await tx.userSubscription.update({
where: { id: subscription.id },
data: { downloadToken: nextToken },
});
});
} else {
await prisma.userSubscription.update({
where: { id: subscription.id },
data: { downloadToken: nextToken },
});
}
await createNotification({
userId: subscription.userId,
type: "SUBSCRIPTION",
level: "SUCCESS",
title: "订阅访问已重置",
body: `${subscription.plan.name} 的订阅链接和访问凭据已更新,旧配置已失效。`,
link: "/subscriptions",
});
await recordAuditLog({
actor: {
userId: session.user.id,
email: session.user.email ?? undefined,
role: session.user.role === "ADMIN" || session.user.role === "USER" ? session.user.role : undefined,
},
action: "subscription.rotate_access",
targetType: "UserSubscription",
targetId: subscription.id,
targetLabel: subscription.plan.name,
message: `用户重置订阅访问 ${subscription.plan.name}`,
});
revalidatePath("/subscriptions");
revalidatePath(`/subscriptions/${subscriptionId}`);
}

238
src/actions/user/support.ts Normal file
View File

@@ -0,0 +1,238 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/require-auth";
import { actorFromSession, recordAuditLog } from "@/services/audit";
import { createNotification } from "@/services/notifications";
import {
createSupportAttachments,
deleteSupportTicketRecords,
parseSupportAttachments,
} from "@/services/support";
const createTicketSchema = z.object({
subject: z.string().trim().min(1, "标题不能为空"),
category: z.string().trim().optional(),
priority: z.enum(["LOW", "NORMAL", "HIGH", "URGENT"]).default("NORMAL"),
body: z.string().trim().min(1, "内容不能为空"),
});
export async function createSupportTicket(formData: FormData) {
const session = await requireAuth();
const data = createTicketSchema.parse(Object.fromEntries(formData));
const attachments = parseSupportAttachments(formData.getAll("attachments"));
const ticket = await prisma.supportTicket.create({
data: {
userId: session.user.id,
subject: data.subject,
category: data.category || null,
priority: data.priority,
status: "OPEN",
lastReplyAt: new Date(),
replies: {
create: {
authorUserId: session.user.id,
isAdmin: false,
body: data.body,
},
},
},
include: {
replies: {
orderBy: { createdAt: "desc" },
take: 1,
},
},
});
const firstReply = ticket.replies[0];
if (firstReply && attachments.length > 0) {
await createSupportAttachments({
ticketId: ticket.id,
replyId: firstReply.id,
files: attachments,
});
}
const admins = await prisma.user.findMany({
where: { role: "ADMIN", status: "ACTIVE" },
select: { id: true },
});
for (const admin of admins) {
await createNotification({
userId: admin.id,
type: "SYSTEM",
level: "INFO",
title: "新工单待处理",
body: `收到新工单:${data.subject}`,
link: `/admin/support/${ticket.id}`,
dedupeKey: `support-created:${ticket.id}:${admin.id}`,
});
}
revalidatePath("/support");
revalidatePath(`/support/${ticket.id}`);
revalidatePath("/admin/support");
}
export async function replySupportTicket(ticketId: string, formData: FormData) {
const session = await requireAuth();
const body = String(formData.get("body") || "").trim();
const attachments = parseSupportAttachments(formData.getAll("attachments"));
if (!body) {
throw new Error("回复内容不能为空");
}
const ticket = await prisma.supportTicket.findFirst({
where: {
id: ticketId,
userId: session.user.id,
},
select: {
id: true,
subject: true,
status: true,
},
});
if (!ticket) {
throw new Error("工单不存在");
}
if (ticket.status === "CLOSED") {
throw new Error("已关闭的工单不能继续回复");
}
const updated = await prisma.supportTicket.update({
where: { id: ticket.id },
data: {
status: "USER_REPLIED",
closedAt: null,
lastReplyAt: new Date(),
replies: {
create: {
authorUserId: session.user.id,
isAdmin: false,
body,
},
},
},
include: {
replies: {
orderBy: { createdAt: "desc" },
take: 1,
},
},
});
const createdReply = updated.replies[0];
if (createdReply && attachments.length > 0) {
await createSupportAttachments({
ticketId: ticket.id,
replyId: createdReply.id,
files: attachments,
});
}
const admins = await prisma.user.findMany({
where: { role: "ADMIN", status: "ACTIVE" },
select: { id: true },
});
for (const admin of admins) {
await createNotification({
userId: admin.id,
type: "SYSTEM",
level: "INFO",
title: "工单有新回复",
body: `工单「${ticket.subject}」有用户新回复。`,
link: `/admin/support/${ticket.id}`,
});
}
revalidatePath(`/support/${ticket.id}`);
revalidatePath("/support");
revalidatePath(`/admin/support/${ticket.id}`);
}
export async function closeSupportTicket(ticketId: string) {
const session = await requireAuth();
const ticket = await prisma.supportTicket.findFirst({
where: {
id: ticketId,
userId: session.user.id,
},
select: {
id: true,
subject: true,
status: true,
},
});
if (!ticket) {
throw new Error("工单不存在");
}
if (ticket.status === "CLOSED") {
return;
}
await prisma.supportTicket.update({
where: { id: ticket.id },
data: {
status: "CLOSED",
closedAt: new Date(),
},
});
await recordAuditLog({
actor: actorFromSession(session),
action: "support.close",
targetType: "SupportTicket",
targetId: ticket.id,
targetLabel: ticket.subject,
message: `用户关闭工单 ${ticket.subject}`,
});
revalidatePath("/support");
revalidatePath(`/support/${ticket.id}`);
revalidatePath("/admin/support");
revalidatePath(`/admin/support/${ticket.id}`);
}
export async function deleteSupportTicket(ticketId: string) {
const session = await requireAuth();
const ticket = await prisma.supportTicket.findFirst({
where: {
id: ticketId,
userId: session.user.id,
},
select: {
id: true,
subject: true,
},
});
if (!ticket) {
throw new Error("工单不存在");
}
await prisma.$transaction(async (tx) => {
await deleteSupportTicketRecords(ticket.id, tx);
await recordAuditLog(
{
actor: actorFromSession(session),
action: "support.delete",
targetType: "SupportTicket",
targetId: ticket.id,
targetLabel: ticket.subject,
message: `用户删除工单 ${ticket.subject}`,
},
tx,
);
});
revalidatePath("/support");
revalidatePath(`/support/${ticket.id}`);
revalidatePath("/admin/support");
revalidatePath(`/admin/support/${ticket.id}`);
revalidatePath("/notifications");
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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 } };
}

View 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>
);
}

View File

@@ -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>
);
}

View 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}` : ""}`;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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 };
}

View 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>
);
}

View 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>
);
}

View 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 };
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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;
}

View 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>
);
}

View 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;
}

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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 };
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 } };
}

View 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>
);
}

View 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");
}

View 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>
);
}

View 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>
);
}

View 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),
}));
}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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;

View 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>
);
}

View 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";

View 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;
}

View 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>
);
}

View 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>
)}
</>
);
}

View 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,
};
}

View 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} />
</>
);
}

View 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>
</>
);
}

View 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,
};
}

View File

@@ -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>
);
}

View 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);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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&#10;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>
);
}

View 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 } };
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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 };
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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[],
};
}

View 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>
);
}

View File

@@ -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">
JPGPNGWEBPGIFAVIF 3 3MB
</p>
</div>
<Button type="submit" size="lg" className="w-full sm:w-auto"></Button>
</form>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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,
});
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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 } };
}

View File

@@ -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>
);
}

Some files were not shown because too many files have changed in this diff Show More