mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
feat: polish internal value displays
This commit is contained in:
@@ -5,6 +5,7 @@ import { requireAdmin } from "@/lib/require-auth";
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import {
|
import {
|
||||||
decryptPaymentConfigForUse,
|
decryptPaymentConfigForUse,
|
||||||
|
getPaymentProviderName,
|
||||||
normalizePaymentConfig,
|
normalizePaymentConfig,
|
||||||
parsePaymentConfig,
|
parsePaymentConfig,
|
||||||
preparePaymentConfigForStorage,
|
preparePaymentConfigForStorage,
|
||||||
@@ -54,8 +55,8 @@ export async function savePaymentConfig(
|
|||||||
action: "payment.config",
|
action: "payment.config",
|
||||||
targetType: "PaymentConfig",
|
targetType: "PaymentConfig",
|
||||||
targetId: provider,
|
targetId: provider,
|
||||||
targetLabel: provider,
|
targetLabel: getPaymentProviderName(provider),
|
||||||
message: `${enabled ? "启用并更新" : "更新"}支付配置 ${provider}`,
|
message: `${enabled ? "启用并更新" : "更新"}支付配置 ${getPaymentProviderName(provider)}`,
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/payments");
|
revalidatePath("/admin/payments");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,24 +11,15 @@ import { normalizeSiteUrl } from "@/services/site-url";
|
|||||||
import { encrypt, isEncryptedValue } from "@/lib/crypto";
|
import { encrypt, isEncryptedValue } from "@/lib/crypto";
|
||||||
import { getErrorMessage } from "@/lib/errors";
|
import { getErrorMessage } from "@/lib/errors";
|
||||||
import { sendSmtpTestEmail } from "@/services/email";
|
import { sendSmtpTestEmail } from "@/services/email";
|
||||||
|
import {
|
||||||
|
booleanAppSettingFields,
|
||||||
|
getBooleanAppSettingLabel,
|
||||||
|
type BooleanAppSettingField,
|
||||||
|
} from "@/lib/domain-labels";
|
||||||
|
|
||||||
const booleanSettingFields = [
|
const booleanSettingFields = booleanAppSettingFields;
|
||||||
"allowRegistration",
|
|
||||||
"emailVerificationRequired",
|
|
||||||
"requireInviteCode",
|
|
||||||
"autoReminderDispatchEnabled",
|
|
||||||
"trafficSyncEnabled",
|
|
||||||
"networkRecommendationsEnabled",
|
|
||||||
"networkInsightsEnabled",
|
|
||||||
"subscriptionRiskEnabled",
|
|
||||||
"subscriptionRiskAutoSuspend",
|
|
||||||
"nodeAccessRiskEnabled",
|
|
||||||
"inviteRewardEnabled",
|
|
||||||
"smtpEnabled",
|
|
||||||
"smtpSecure",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export type BooleanSettingField = (typeof booleanSettingFields)[number];
|
export type BooleanSettingField = BooleanAppSettingField;
|
||||||
|
|
||||||
const settingsSchema = z.object({
|
const settingsSchema = z.object({
|
||||||
siteName: z.string().trim().min(1, "站点名称不能为空"),
|
siteName: z.string().trim().min(1, "站点名称不能为空"),
|
||||||
@@ -363,7 +354,7 @@ export async function saveBooleanAppSetting(input: {
|
|||||||
targetType: "AppConfig",
|
targetType: "AppConfig",
|
||||||
targetId: current.id,
|
targetId: current.id,
|
||||||
targetLabel: current.siteName,
|
targetLabel: current.siteName,
|
||||||
message: `${parsed.value ? "开启" : "关闭"}系统开关 ${parsed.field}`,
|
message: `${parsed.value ? "开启" : "关闭"}${getBooleanAppSettingLabel(parsed.field)}开关`,
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidateSettingsViews();
|
revalidateSettingsViews();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { dispatchSubscriptionReminders } from "@/services/notifications";
|
|||||||
import { confirmPendingOrder } from "@/services/payment/process";
|
import { confirmPendingOrder } from "@/services/payment/process";
|
||||||
import { runTask, updateTaskRun } from "@/services/task-center";
|
import { runTask, updateTaskRun } from "@/services/task-center";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getTaskKindLabel } from "@/lib/domain-labels";
|
||||||
|
|
||||||
function revalidateTaskViews() {
|
function revalidateTaskViews() {
|
||||||
revalidatePath("/admin/tasks");
|
revalidatePath("/admin/tasks");
|
||||||
@@ -37,7 +38,7 @@ export async function runReminderTask() {
|
|||||||
action: "task.run",
|
action: "task.run",
|
||||||
targetType: "TaskRun",
|
targetType: "TaskRun",
|
||||||
targetId: outcome.taskId,
|
targetId: outcome.taskId,
|
||||||
targetLabel: "REMINDER_DISPATCH",
|
targetLabel: getTaskKindLabel("REMINDER_DISPATCH"),
|
||||||
message: "手动执行提醒派发任务",
|
message: "手动执行提醒派发任务",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -82,7 +83,7 @@ export async function retryTaskRun(taskId: string) {
|
|||||||
action: "task.retry",
|
action: "task.retry",
|
||||||
targetType: "TaskRun",
|
targetType: "TaskRun",
|
||||||
targetId: task.id,
|
targetId: task.id,
|
||||||
targetLabel: task.kind,
|
targetLabel: getTaskKindLabel(task.kind),
|
||||||
message: `重试任务 ${task.title}`,
|
message: `重试任务 ${task.title}`,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import bcrypt from "bcryptjs";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||||
import { createPanelAdapter } from "@/services/node-panel/factory";
|
import { createPanelAdapter } from "@/services/node-panel/factory";
|
||||||
|
import { getUserStatusLabel } from "@/lib/domain-labels";
|
||||||
|
|
||||||
const createUserSchema = z.object({
|
const createUserSchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
@@ -86,7 +87,7 @@ export async function updateUserStatus(id: string, status: "ACTIVE" | "DISABLED"
|
|||||||
targetType: "User",
|
targetType: "User",
|
||||||
targetId: user.id,
|
targetId: user.id,
|
||||||
targetLabel: user.email,
|
targetLabel: user.email,
|
||||||
message: `将用户 ${user.email} 状态改为 ${status}`,
|
message: `将用户 ${user.email} 状态改为${getUserStatusLabel(status)}`,
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/users");
|
revalidatePath("/admin/users");
|
||||||
revalidatePath(`/admin/users/${user.id}`);
|
revalidatePath(`/admin/users/${user.id}`);
|
||||||
@@ -291,7 +292,7 @@ export async function batchUpdateUserStatus(formData: FormData) {
|
|||||||
actor: actorFromSession(session),
|
actor: actorFromSession(session),
|
||||||
action: "user.batch_status",
|
action: "user.batch_status",
|
||||||
targetType: "User",
|
targetType: "User",
|
||||||
message: `批量更新 ${userIds.length} 个用户状态为 ${status}`,
|
message: `批量更新 ${userIds.length} 个用户状态为${getUserStatusLabel(String(status))}`,
|
||||||
metadata: {
|
metadata: {
|
||||||
userIds,
|
userIds,
|
||||||
status: String(status),
|
status: String(status),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { requireAuth } from "@/lib/require-auth";
|
|||||||
import { buildUnavailableMessage, getPlanAvailability } from "@/services/plan-availability";
|
import { buildUnavailableMessage, getPlanAvailability } from "@/services/plan-availability";
|
||||||
import { getPlanPurchasePrice, calculateCheckoutDiscounts } from "@/services/commerce";
|
import { getPlanPurchasePrice, calculateCheckoutDiscounts } from "@/services/commerce";
|
||||||
import { ensurePlanTrafficPoolCapacity } from "@/services/plan-traffic-pool";
|
import { ensurePlanTrafficPoolCapacity } from "@/services/plan-traffic-pool";
|
||||||
|
import { getSubscriptionTypeLabel } from "@/lib/domain-labels";
|
||||||
|
|
||||||
async function assertNoPendingOrder(userId: string) {
|
async function assertNoPendingOrder(userId: string) {
|
||||||
const pendingOrder = await prisma.order.findFirst({
|
const pendingOrder = await prisma.order.findFirst({
|
||||||
@@ -33,7 +34,7 @@ async function getProxyPlanForCart(planId: string) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (plan.type !== "PROXY") throw new Error(`套餐类型不匹配:${plan.name} 是 ${plan.type},不能作为代理套餐加入购物车`);
|
if (plan.type !== "PROXY") throw new Error(`套餐类型不匹配:${plan.name} 是${getSubscriptionTypeLabel(plan.type)},不能作为代理套餐加入购物车`);
|
||||||
if (!plan.isActive) throw new Error(`套餐已下架:${plan.name} 当前不可购买`);
|
if (!plan.isActive) throw new Error(`套餐已下架:${plan.name} 当前不可购买`);
|
||||||
return plan;
|
return plan;
|
||||||
}
|
}
|
||||||
@@ -115,7 +116,7 @@ export async function addProxyPlanToCart(
|
|||||||
export async function addStreamingPlanToCart(planId: string) {
|
export async function addStreamingPlanToCart(planId: string) {
|
||||||
const session = await requireAuth();
|
const session = await requireAuth();
|
||||||
const plan = await prisma.subscriptionPlan.findUniqueOrThrow({ where: { id: planId } });
|
const plan = await prisma.subscriptionPlan.findUniqueOrThrow({ where: { id: planId } });
|
||||||
if (plan.type !== "STREAMING") throw new Error(`套餐类型不匹配:${plan.name} 是 ${plan.type},不能作为流媒体套餐加入购物车`);
|
if (plan.type !== "STREAMING") throw new Error(`套餐类型不匹配:${plan.name} 是${getSubscriptionTypeLabel(plan.type)},不能作为流媒体套餐加入购物车`);
|
||||||
if (!plan.isActive) throw new Error(`套餐已下架:${plan.name} 当前不可购买`);
|
if (!plan.isActive) throw new Error(`套餐已下架:${plan.name} 当前不可购买`);
|
||||||
|
|
||||||
const availability = await getPlanAvailability(plan, { userId: session.user.id });
|
const availability = await getPlanAvailability(plan, { userId: session.user.id });
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
getPlanTrafficPoolState,
|
getPlanTrafficPoolState,
|
||||||
} from "@/services/plan-traffic-pool";
|
} from "@/services/plan-traffic-pool";
|
||||||
import { getPlanPurchasePrice, roundMoney } from "@/services/commerce";
|
import { getPlanPurchasePrice, roundMoney } from "@/services/commerce";
|
||||||
|
import { getSubscriptionTypeLabel } from "@/lib/domain-labels";
|
||||||
|
|
||||||
async function assertNoPendingOrder(userId: string) {
|
async function assertNoPendingOrder(userId: string) {
|
||||||
const pendingOrder = await prisma.order.findFirst({
|
const pendingOrder = await prisma.order.findFirst({
|
||||||
@@ -133,7 +134,7 @@ export async function purchaseProxy(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (plan.type !== "PROXY") throw new Error(`套餐类型不匹配:${plan.name} 是 ${plan.type},不能作为代理套餐购买`);
|
if (plan.type !== "PROXY") throw new Error(`套餐类型不匹配:${plan.name} 是${getSubscriptionTypeLabel(plan.type)},不能作为代理套餐购买`);
|
||||||
if (!plan.isActive) throw new Error(`套餐已下架:${plan.name} 当前不可购买`);
|
if (!plan.isActive) throw new Error(`套餐已下架:${plan.name} 当前不可购买`);
|
||||||
|
|
||||||
const price = getPlanPurchasePrice(plan, trafficGb);
|
const price = getPlanPurchasePrice(plan, trafficGb);
|
||||||
@@ -213,7 +214,7 @@ export async function purchaseStreaming(planId: string): Promise<string> {
|
|||||||
where: { id: planId },
|
where: { id: planId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (plan.type !== "STREAMING") throw new Error(`套餐类型不匹配:${plan.name} 是 ${plan.type},不能作为流媒体套餐购买`);
|
if (plan.type !== "STREAMING") throw new Error(`套餐类型不匹配:${plan.name} 是${getSubscriptionTypeLabel(plan.type)},不能作为流媒体套餐购买`);
|
||||||
if (!plan.isActive) throw new Error(`套餐已下架:${plan.name} 当前不可购买`);
|
if (!plan.isActive) throw new Error(`套餐已下架:${plan.name} 当前不可购买`);
|
||||||
|
|
||||||
const availability = await getPlanAvailability(plan, { userId: session.user.id });
|
const availability = await getPlanAvailability(plan, { userId: session.user.id });
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ import {
|
|||||||
DataTableHeaderRow,
|
DataTableHeaderRow,
|
||||||
DataTableRow,
|
DataTableRow,
|
||||||
} from "@/components/shared/data-table";
|
} from "@/components/shared/data-table";
|
||||||
|
import {
|
||||||
|
formatAuditAction,
|
||||||
|
formatAuditActorRole,
|
||||||
|
formatAuditMessage,
|
||||||
|
formatAuditTargetLabel,
|
||||||
|
formatAuditTargetType,
|
||||||
|
} from "@/lib/audit-display";
|
||||||
import { formatDate } from "@/lib/utils";
|
import { formatDate } from "@/lib/utils";
|
||||||
|
|
||||||
export function AuditLogsTable({ logs }: { logs: AuditLog[] }) {
|
export function AuditLogsTable({ logs }: { logs: AuditLog[] }) {
|
||||||
@@ -37,20 +44,22 @@ export function AuditLogsTable({ logs }: { logs: AuditLog[] }) {
|
|||||||
<DataTableCell>
|
<DataTableCell>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p>{log.actorEmail || "系统"}</p>
|
<p>{log.actorEmail || "系统"}</p>
|
||||||
<p className="text-xs text-muted-foreground">{log.actorRole || "—"}</p>
|
<p className="text-xs text-muted-foreground">{formatAuditActorRole(log.actorRole)}</p>
|
||||||
</div>
|
</div>
|
||||||
</DataTableCell>
|
</DataTableCell>
|
||||||
<DataTableCell className="whitespace-nowrap font-medium">{log.action}</DataTableCell>
|
<DataTableCell className="whitespace-nowrap font-medium">
|
||||||
|
{formatAuditAction(log.action)}
|
||||||
|
</DataTableCell>
|
||||||
<DataTableCell>
|
<DataTableCell>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p>{log.targetType}</p>
|
<p>{formatAuditTargetType(log.targetType)}</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{log.targetLabel || log.targetId || "—"}
|
{formatAuditTargetLabel(log)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</DataTableCell>
|
</DataTableCell>
|
||||||
<DataTableCell className="max-w-xl whitespace-pre-wrap break-words text-muted-foreground">
|
<DataTableCell className="max-w-xl whitespace-pre-wrap break-words text-muted-foreground">
|
||||||
{log.message}
|
{formatAuditMessage(log.message)}
|
||||||
</DataTableCell>
|
</DataTableCell>
|
||||||
</DataTableRow>
|
</DataTableRow>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { AdminFilterBar } from "@/components/admin/filter-bar";
|
|||||||
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||||
import { Pagination } from "@/components/shared/pagination";
|
import { Pagination } from "@/components/shared/pagination";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
import { auditActionFilterOptions } from "@/lib/audit-display";
|
||||||
import { AuditLogsTable } from "./_components/audit-logs-table";
|
import { AuditLogsTable } from "./_components/audit-logs-table";
|
||||||
import { buildAuditLogExportHref, getAuditLogs } from "./audit-logs-data";
|
import { buildAuditLogExportHref, getAuditLogs } from "./audit-logs-data";
|
||||||
|
|
||||||
@@ -42,17 +43,7 @@ export default async function AuditLogsPage({
|
|||||||
{
|
{
|
||||||
name: "action",
|
name: "action",
|
||||||
value: filters.action,
|
value: filters.action,
|
||||||
options: [
|
options: auditActionFilterOptions,
|
||||||
{ 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." },
|
|
||||||
{ label: "risk.", value: "risk." },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ArrowLeft } from "lucide-react";
|
|||||||
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||||
import { StatusBadge } from "@/components/shared/status-badge";
|
import { StatusBadge } from "@/components/shared/status-badge";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
import { getNodeStatusLabel } from "@/lib/domain-labels";
|
||||||
import { getNodeDetail } from "./node-detail-data";
|
import { getNodeDetail } from "./node-detail-data";
|
||||||
import { NodeDetailTabs } from "./_components/node-detail-tabs";
|
import { NodeDetailTabs } from "./_components/node-detail-tabs";
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ export default async function NodeDetailPage({
|
|||||||
description={`3x-ui · ${node.panelUrl || "未配置面板"}`}
|
description={`3x-ui · ${node.panelUrl || "未配置面板"}`}
|
||||||
actions={
|
actions={
|
||||||
<StatusBadge tone={node.status === "active" ? "success" : "neutral"}>
|
<StatusBadge tone={node.status === "active" ? "success" : "neutral"}>
|
||||||
{node.status}
|
{getNodeStatusLabel(node.status)}
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
}
|
}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { BatchActionBar, BatchActionButton } from "@/components/admin/batch-acti
|
|||||||
import { EmptyState } from "@/components/shared/page-shell";
|
import { EmptyState } from "@/components/shared/page-shell";
|
||||||
import { StatusBadge } from "@/components/shared/status-badge";
|
import { StatusBadge } from "@/components/shared/status-badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { getNodeStatusLabel } from "@/lib/domain-labels";
|
||||||
import { InboundDeleteButton } from "../inbound-delete-button";
|
import { InboundDeleteButton } from "../inbound-delete-button";
|
||||||
import { InboundDisplayNameForm } from "../inbound-display-name-form";
|
import { InboundDisplayNameForm } from "../inbound-display-name-form";
|
||||||
import { NodeActions } from "../node-actions";
|
import { NodeActions } from "../node-actions";
|
||||||
@@ -52,7 +53,7 @@ function NodeCard({ node, siteUrl }: { node: NodeServerRow; siteUrl: string | nu
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<StatusBadge tone={node.status === "active" ? "success" : "neutral"}>
|
<StatusBadge tone={node.status === "active" ? "success" : "neutral"}>
|
||||||
{node.status}
|
{getNodeStatusLabel(node.status)}
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
<NodeForm
|
<NodeForm
|
||||||
node={{
|
node={{
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ import {
|
|||||||
} from "@/actions/admin/settings";
|
} from "@/actions/admin/settings";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { getErrorMessage } from "@/lib/errors";
|
import { getErrorMessage } from "@/lib/errors";
|
||||||
|
import {
|
||||||
|
booleanAppSettingLabels,
|
||||||
|
type BooleanAppSettingField,
|
||||||
|
} from "@/lib/domain-labels";
|
||||||
|
|
||||||
interface AppConfig {
|
interface AppConfig {
|
||||||
siteName: string;
|
siteName: string;
|
||||||
@@ -72,23 +76,9 @@ interface CouponOption {
|
|||||||
|
|
||||||
const selectClassName = "premium-input w-full appearance-none px-3.5 py-2 text-sm outline-none";
|
const selectClassName = "premium-input w-full appearance-none px-3.5 py-2 text-sm outline-none";
|
||||||
|
|
||||||
type ToggleValues = Record<BooleanSettingField, boolean>;
|
type ToggleValues = Record<BooleanAppSettingField, boolean>;
|
||||||
|
|
||||||
const booleanSettingLabels: Record<BooleanSettingField, string> = {
|
const booleanSettingLabels = booleanAppSettingLabels;
|
||||||
allowRegistration: "开放注册",
|
|
||||||
emailVerificationRequired: "注册邮箱验证",
|
|
||||||
requireInviteCode: "邀请码注册",
|
|
||||||
autoReminderDispatchEnabled: "自动提醒派发",
|
|
||||||
trafficSyncEnabled: "3x-ui 流量定时同步",
|
|
||||||
networkRecommendationsEnabled: "三网推荐",
|
|
||||||
networkInsightsEnabled: "线路体验",
|
|
||||||
subscriptionRiskEnabled: "风控总控",
|
|
||||||
subscriptionRiskAutoSuspend: "自动暂停",
|
|
||||||
nodeAccessRiskEnabled: "节点日志风控",
|
|
||||||
inviteRewardEnabled: "自动发放奖励",
|
|
||||||
smtpEnabled: "邮件服务",
|
|
||||||
smtpSecure: "SSL 直连",
|
|
||||||
};
|
|
||||||
|
|
||||||
function initialToggleValues(config: AppConfig): ToggleValues {
|
function initialToggleValues(config: AppConfig): ToggleValues {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useEffectEvent, useMemo, useState } from "react";
|
|||||||
import { cancelOwnPendingOrder, resetOwnPendingPaymentChoice } from "@/actions/user/orders";
|
import { cancelOwnPendingOrder, resetOwnPendingPaymentChoice } from "@/actions/user/orders";
|
||||||
import { fetchJson } from "@/lib/fetch-json";
|
import { fetchJson } from "@/lib/fetch-json";
|
||||||
import { getErrorMessage } from "@/lib/errors";
|
import { getErrorMessage } from "@/lib/errors";
|
||||||
|
import { getOrderStatusLabel } from "@/lib/domain-labels";
|
||||||
import type {
|
import type {
|
||||||
OrderPaymentSnapshot,
|
OrderPaymentSnapshot,
|
||||||
PaymentInfo,
|
PaymentInfo,
|
||||||
@@ -111,7 +112,7 @@ export function usePaymentFlow(orderId: string) {
|
|||||||
|
|
||||||
if (order.status !== "PENDING") {
|
if (order.status !== "PENDING") {
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
setPageError(`这笔订单当前为 ${order.status},无法继续支付。`);
|
setPageError(`这笔订单当前为${getOrderStatusLabel(order.status)},无法继续支付。`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +162,7 @@ export function usePaymentFlow(orderId: string) {
|
|||||||
|
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
setPageError(
|
setPageError(
|
||||||
result.error || `订单状态更新:${orderStatusLabel[result.status] ?? result.status}`,
|
result.error || `订单状态更新:${orderStatusLabel[result.status] ?? "未知状态"}`,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { requireAdminApiSession } from "@/lib/admin-api";
|
import { requireAdminApiSession } from "@/lib/admin-api";
|
||||||
|
import {
|
||||||
|
formatAuditAction,
|
||||||
|
formatAuditActorRole,
|
||||||
|
formatAuditMessage,
|
||||||
|
formatAuditTargetLabel,
|
||||||
|
formatAuditTargetType,
|
||||||
|
} from "@/lib/audit-display";
|
||||||
|
|
||||||
export async function GET(req: Request) {
|
export async function GET(req: Request) {
|
||||||
const { errorResponse } = await requireAdminApiSession();
|
const { errorResponse } = await requireAdminApiSession();
|
||||||
@@ -9,24 +16,37 @@ export async function GET(req: Request) {
|
|||||||
|
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const q = searchParams.get("q")?.trim() ?? "";
|
const q = searchParams.get("q")?.trim() ?? "";
|
||||||
|
const action = searchParams.get("action") ?? "";
|
||||||
|
|
||||||
const logs = await prisma.auditLog.findMany({
|
const logs = await prisma.auditLog.findMany({
|
||||||
where: q
|
where: {
|
||||||
? {
|
...(action ? { action: { startsWith: action } } : {}),
|
||||||
OR: [
|
...(q
|
||||||
{ action: { contains: q } },
|
? {
|
||||||
{ targetType: { contains: q } },
|
OR: [
|
||||||
{ targetLabel: { contains: q } },
|
{ action: { contains: q } },
|
||||||
{ actorEmail: { contains: q } },
|
{ targetType: { contains: q } },
|
||||||
{ message: { contains: q } },
|
{ targetLabel: { contains: q } },
|
||||||
],
|
{ actorEmail: { contains: q } },
|
||||||
}
|
{ message: { contains: q } },
|
||||||
: undefined,
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
take: 5000,
|
take: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
return new Response(JSON.stringify(logs, null, 2), {
|
const rows = logs.map((log) => ({
|
||||||
|
...log,
|
||||||
|
actorRoleLabel: formatAuditActorRole(log.actorRole),
|
||||||
|
actionLabel: formatAuditAction(log.action),
|
||||||
|
targetTypeLabel: formatAuditTargetType(log.targetType),
|
||||||
|
targetLabelDisplay: formatAuditTargetLabel(log),
|
||||||
|
messageDisplay: formatAuditMessage(log.message),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(rows, null, 2), {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json; charset=utf-8",
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
"Content-Disposition": 'attachment; filename="jboard-audit-logs.json"',
|
"Content-Disposition": 'attachment; filename="jboard-audit-logs.json"',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { getPaymentAdapter } from "@/services/payment/factory";
|
|||||||
import { rateLimit } from "@/lib/rate-limit";
|
import { rateLimit } from "@/lib/rate-limit";
|
||||||
import { getSiteBaseUrl } from "@/services/site-url";
|
import { getSiteBaseUrl } from "@/services/site-url";
|
||||||
import { getActiveSubscriptionRiskRestriction } from "@/services/subscription-risk-review";
|
import { getActiveSubscriptionRiskRestriction } from "@/services/subscription-risk-review";
|
||||||
|
import { getOrderStatusLabel } from "@/lib/domain-labels";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
const createPaymentSchema = z.object({
|
const createPaymentSchema = z.object({
|
||||||
@@ -66,7 +67,7 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (order.status !== "PENDING") {
|
if (order.status !== "PENDING") {
|
||||||
return jsonError(`订单当前状态为 ${order.status},无法继续支付`, {
|
return jsonError(`订单当前状态为${getOrderStatusLabel(order.status)},无法继续支付`, {
|
||||||
status: 400,
|
status: 400,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { rateLimit } from "@/lib/rate-limit";
|
|||||||
import { getClientRequestContext } from "@/lib/request-context";
|
import { getClientRequestContext } from "@/lib/request-context";
|
||||||
import { recordSubscriptionAccess } from "@/services/subscription-risk";
|
import { recordSubscriptionAccess } from "@/services/subscription-risk";
|
||||||
import { getAppConfig } from "@/services/app-config";
|
import { getAppConfig } from "@/services/app-config";
|
||||||
|
import { getSubscriptionStatusLabel } from "@/lib/domain-labels";
|
||||||
|
|
||||||
const SUBSCRIPTION_RATE_WINDOW_SECONDS = 60 * 60;
|
const SUBSCRIPTION_RATE_WINDOW_SECONDS = 60 * 60;
|
||||||
|
|
||||||
@@ -60,7 +61,7 @@ export async function GET(
|
|||||||
allowed: false,
|
allowed: false,
|
||||||
reason: "rate_limited",
|
reason: "rate_limited",
|
||||||
});
|
});
|
||||||
return jsonError("Too many subscription requests", 429);
|
return jsonError("订阅请求过于频繁,请稍后再试", 429);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +105,7 @@ export async function GET(
|
|||||||
allowed: false,
|
allowed: false,
|
||||||
reason: "rate_limited",
|
reason: "rate_limited",
|
||||||
});
|
});
|
||||||
return jsonError("Too many subscription requests", 429);
|
return jsonError("订阅请求过于频繁,请稍后再试", 429);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +118,7 @@ export async function GET(
|
|||||||
allowed: false,
|
allowed: false,
|
||||||
reason: "subscription_inactive",
|
reason: "subscription_inactive",
|
||||||
});
|
});
|
||||||
return jsonError(`订阅当前状态为 ${sub.status},只有 ACTIVE 状态可以拉取配置`, 403);
|
return jsonError(`订阅当前状态为${getSubscriptionStatusLabel(sub.status)},只有活跃订阅可以拉取配置`, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
const risk = await recordSubscriptionAccess({
|
const risk = await recordSubscriptionAccess({
|
||||||
@@ -130,7 +131,7 @@ export async function GET(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (risk.suspended) {
|
if (risk.suspended) {
|
||||||
return jsonError("Subscription suspended by risk control", 403);
|
return jsonError("订阅已被风控暂停,请联系管理员处理", 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
const format = resolveSubscriptionFormat(url.searchParams, req.headers.get("user-agent"));
|
const format = resolveSubscriptionFormat(url.searchParams, req.headers.get("user-agent"));
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { rateLimit } from "@/lib/rate-limit";
|
|||||||
import { getClientRequestContext } from "@/lib/request-context";
|
import { getClientRequestContext } from "@/lib/request-context";
|
||||||
import { recordSubscriptionAccess } from "@/services/subscription-risk";
|
import { recordSubscriptionAccess } from "@/services/subscription-risk";
|
||||||
import { getAppConfig } from "@/services/app-config";
|
import { getAppConfig } from "@/services/app-config";
|
||||||
|
import { getUserStatusLabel } from "@/lib/domain-labels";
|
||||||
|
|
||||||
const SUBSCRIPTION_RATE_WINDOW_SECONDS = 60 * 60;
|
const SUBSCRIPTION_RATE_WINDOW_SECONDS = 60 * 60;
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@ export async function GET(req: Request) {
|
|||||||
allowed: false,
|
allowed: false,
|
||||||
reason: "rate_limited",
|
reason: "rate_limited",
|
||||||
});
|
});
|
||||||
return jsonError("Too many subscription requests", 429);
|
return jsonError("订阅请求过于频繁,请稍后再试", 429);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +88,7 @@ export async function GET(req: Request) {
|
|||||||
allowed: false,
|
allowed: false,
|
||||||
reason: "rate_limited",
|
reason: "rate_limited",
|
||||||
});
|
});
|
||||||
return jsonError("Too many subscription requests", 429);
|
return jsonError("订阅请求过于频繁,请稍后再试", 429);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +100,7 @@ export async function GET(req: Request) {
|
|||||||
allowed: false,
|
allowed: false,
|
||||||
reason: "user_inactive",
|
reason: "user_inactive",
|
||||||
});
|
});
|
||||||
return jsonError("User inactive", 403);
|
return jsonError(`账户当前状态为${getUserStatusLabel(user.status)},暂时无法拉取总订阅`, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
const risk = await recordSubscriptionAccess({
|
const risk = await recordSubscriptionAccess({
|
||||||
@@ -111,7 +112,7 @@ export async function GET(req: Request) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (risk.suspended) {
|
if (risk.suspended) {
|
||||||
return jsonError("Subscriptions suspended by risk control", 403);
|
return jsonError("总订阅已被风控暂停,请联系管理员处理", 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
const format = resolveSubscriptionFormat(url.searchParams, req.headers.get("user-agent"));
|
const format = resolveSubscriptionFormat(url.searchParams, req.headers.get("user-agent"));
|
||||||
|
|||||||
@@ -1,85 +1,37 @@
|
|||||||
import type {
|
import type {
|
||||||
AnnouncementAudience,
|
AnnouncementAudience,
|
||||||
AnnouncementDisplayType,
|
|
||||||
OrderKind,
|
|
||||||
OrderReviewStatus,
|
OrderReviewStatus,
|
||||||
OrderStatus,
|
OrderStatus,
|
||||||
Role,
|
Role,
|
||||||
SubscriptionStatus,
|
SubscriptionStatus,
|
||||||
SubscriptionType,
|
SubscriptionType,
|
||||||
TaskKind,
|
|
||||||
TaskStatus,
|
TaskStatus,
|
||||||
UserStatus,
|
UserStatus,
|
||||||
} from "@prisma/client";
|
} from "@prisma/client";
|
||||||
import { StatusBadge, type StatusTone } from "@/components/shared/status-badge";
|
import { StatusBadge, type StatusTone } from "@/components/shared/status-badge";
|
||||||
|
import {
|
||||||
|
orderReviewStatusLabels,
|
||||||
|
orderStatusLabels,
|
||||||
|
subscriptionStatusLabels,
|
||||||
|
subscriptionTypeLabels,
|
||||||
|
taskStatusLabels,
|
||||||
|
userRoleLabels,
|
||||||
|
userStatusLabels,
|
||||||
|
} from "@/lib/domain-labels";
|
||||||
|
|
||||||
export const orderStatusLabels: Record<OrderStatus, string> = {
|
export {
|
||||||
PENDING: "待确认",
|
announcementAudienceLabels,
|
||||||
PAID: "已支付",
|
announcementDisplayTypeLabels,
|
||||||
CANCELLED: "已取消",
|
orderKindLabels,
|
||||||
REFUNDED: "已退款",
|
orderReviewStatusLabels,
|
||||||
};
|
orderStatusLabels,
|
||||||
|
subscriptionStatusLabels,
|
||||||
export const orderKindLabels: Record<OrderKind, string> = {
|
subscriptionTypeLabels,
|
||||||
NEW_PURCHASE: "新购",
|
taskKindLabels,
|
||||||
RENEWAL: "续费",
|
taskStatusLabels,
|
||||||
TRAFFIC_TOPUP: "增流量",
|
userRoleLabels,
|
||||||
};
|
userStatusLabels,
|
||||||
|
} from "@/lib/domain-labels";
|
||||||
export const orderReviewStatusLabels: Record<OrderReviewStatus, string> = {
|
|
||||||
NORMAL: "正常",
|
|
||||||
FLAGGED: "异常",
|
|
||||||
RESOLVED: "已解决",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const subscriptionStatusLabels: Record<SubscriptionStatus, string> = {
|
|
||||||
ACTIVE: "活跃",
|
|
||||||
EXPIRED: "已过期",
|
|
||||||
CANCELLED: "已取消",
|
|
||||||
SUSPENDED: "已暂停",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const subscriptionTypeLabels: Record<SubscriptionType, string> = {
|
|
||||||
PROXY: "代理",
|
|
||||||
STREAMING: "流媒体",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const userRoleLabels: Record<Role, string> = {
|
|
||||||
ADMIN: "管理员",
|
|
||||||
USER: "用户",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const userStatusLabels: Record<UserStatus, string> = {
|
|
||||||
ACTIVE: "正常",
|
|
||||||
PENDING_EMAIL: "待邮箱验证",
|
|
||||||
DISABLED: "禁用",
|
|
||||||
BANNED: "封禁",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const taskKindLabels: Record<TaskKind, string> = {
|
|
||||||
REMINDER_DISPATCH: "提醒派发",
|
|
||||||
ORDER_PROVISION_RETRY: "订单重试",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const taskStatusLabels: Record<TaskStatus, string> = {
|
|
||||||
PENDING: "待执行",
|
|
||||||
RUNNING: "运行中",
|
|
||||||
SUCCESS: "成功",
|
|
||||||
FAILED: "失败",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const announcementAudienceLabels: Record<AnnouncementAudience, string> = {
|
|
||||||
PUBLIC: "公开",
|
|
||||||
USERS: "全部用户",
|
|
||||||
ADMINS: "全部管理员",
|
|
||||||
SPECIFIC_USER: "指定用户",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const announcementDisplayTypeLabels: Record<AnnouncementDisplayType, string> = {
|
|
||||||
INLINE: "普通公告",
|
|
||||||
BIG: "大公告",
|
|
||||||
POPUP: "弹窗公告",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getOrderStatusTone(status: OrderStatus): StatusTone {
|
export function getOrderStatusTone(status: OrderStatus): StatusTone {
|
||||||
if (status === "PAID") return "success";
|
if (status === "PAID") return "success";
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { CircleDot } from "lucide-react";
|
import { CircleDot } from "lucide-react";
|
||||||
|
import { formatAuditAction, formatAuditMessage } from "@/lib/audit-display";
|
||||||
|
|
||||||
interface TimelineItem {
|
interface TimelineItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -28,11 +29,11 @@ export function SubscriptionTimeline({
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<p className="text-sm font-semibold text-pretty">{item.message}</p>
|
<p className="text-sm font-semibold text-pretty">{formatAuditMessage(item.message)}</p>
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">{item.createdAt}</span>
|
<span className="shrink-0 text-xs text-muted-foreground">{item.createdAt}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
{item.action}
|
{formatAuditAction(item.action)}
|
||||||
{item.actorEmail ? ` · ${item.actorEmail}` : " · 系统"}
|
{item.actorEmail ? ` · ${item.actorEmail}` : " · 系统"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -60,13 +60,15 @@ export function TrafficTrendChart({
|
|||||||
<XAxis dataKey="date" />
|
<XAxis dataKey="date" />
|
||||||
<YAxis unit=" GB" width={60} />
|
<YAxis unit=" GB" width={60} />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
|
labelFormatter={(label) => `日期:${label}`}
|
||||||
formatter={(value) =>
|
formatter={(value) =>
|
||||||
`${Number(typeof value === "number" ? value : 0).toFixed(2)} GB`
|
[`${Number(typeof value === "number" ? value : 0).toFixed(2)} GB`, "流量"]
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="valueGb"
|
dataKey="valueGb"
|
||||||
|
name="流量"
|
||||||
stroke={color}
|
stroke={color}
|
||||||
fill={color}
|
fill={color}
|
||||||
fillOpacity={0.15}
|
fillOpacity={0.15}
|
||||||
|
|||||||
198
src/lib/audit-display.ts
Normal file
198
src/lib/audit-display.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import {
|
||||||
|
booleanAppSettingLabels,
|
||||||
|
getBooleanAppSettingLabel,
|
||||||
|
getPaymentProviderLabel,
|
||||||
|
getTaskKindLabel,
|
||||||
|
getUserRoleLabel,
|
||||||
|
nodeStatusLabels,
|
||||||
|
orderStatusLabels,
|
||||||
|
paymentProviderLabels,
|
||||||
|
subscriptionStatusLabels,
|
||||||
|
subscriptionTypeLabels,
|
||||||
|
taskKindLabels,
|
||||||
|
userStatusLabels,
|
||||||
|
} from "@/lib/domain-labels";
|
||||||
|
|
||||||
|
export const auditActionFilterOptions = [
|
||||||
|
{ label: "全部动作", value: "" },
|
||||||
|
{ label: "用户操作", value: "user." },
|
||||||
|
{ label: "订单操作", value: "order." },
|
||||||
|
{ label: "订阅操作", value: "subscription." },
|
||||||
|
{ label: "套餐操作", value: "plan." },
|
||||||
|
{ label: "服务操作", value: "service." },
|
||||||
|
{ label: "节点操作", value: "node." },
|
||||||
|
{ label: "入站操作", value: "inbound." },
|
||||||
|
{ label: "任务操作", value: "task." },
|
||||||
|
{ label: "风控操作", value: "risk." },
|
||||||
|
{ label: "系统设置", value: "settings." },
|
||||||
|
{ label: "支付配置", value: "payment." },
|
||||||
|
{ label: "公告操作", value: "announcement." },
|
||||||
|
{ label: "工单操作", value: "support." },
|
||||||
|
{ label: "优惠规则", value: "coupon." },
|
||||||
|
{ label: "满减规则", value: "promotion." },
|
||||||
|
{ label: "备份恢复", value: "backup." },
|
||||||
|
{ label: "流量同步", value: "traffic." },
|
||||||
|
{ label: "流媒体槽位", value: "streaming-slot." },
|
||||||
|
];
|
||||||
|
|
||||||
|
const auditActionLabels: Record<string, string> = {
|
||||||
|
"announcement.create": "创建公告",
|
||||||
|
"announcement.update": "更新公告",
|
||||||
|
"announcement.enable": "启用公告",
|
||||||
|
"announcement.disable": "停用公告",
|
||||||
|
"announcement.delete": "删除公告",
|
||||||
|
"backup.restore": "恢复数据库",
|
||||||
|
"coupon.create": "创建优惠券",
|
||||||
|
"coupon.toggle": "切换优惠券状态",
|
||||||
|
"inbound.delete": "删除线路入口",
|
||||||
|
"inbound.display_name.update": "更新线路名称",
|
||||||
|
"node.create": "创建节点",
|
||||||
|
"node.update": "更新节点",
|
||||||
|
"node.delete": "删除节点",
|
||||||
|
"node.test": "同步节点入站",
|
||||||
|
"node.probe_token.generate": "生成探测 Token",
|
||||||
|
"node.probe_token.revoke": "撤销探测 Token",
|
||||||
|
"order.confirm": "确认订单",
|
||||||
|
"order.cancel": "取消订单",
|
||||||
|
"order.review": "更新订单审查",
|
||||||
|
"payment.config": "更新支付配置",
|
||||||
|
"plan.create": "创建套餐",
|
||||||
|
"plan.update": "更新套餐",
|
||||||
|
"plan.enable": "上架套餐",
|
||||||
|
"plan.disable": "下架套餐",
|
||||||
|
"plan.delete": "删除套餐",
|
||||||
|
"plan.batch_enable": "批量上架套餐",
|
||||||
|
"plan.batch_disable": "批量下架套餐",
|
||||||
|
"promotion.create": "创建满减规则",
|
||||||
|
"promotion.toggle": "切换满减规则状态",
|
||||||
|
"risk.node_access.suspend": "节点风控暂停订阅",
|
||||||
|
"risk.node_access.warning": "记录节点访问警告",
|
||||||
|
"risk.subscription.finalize": "完成风控处置",
|
||||||
|
"risk.subscription.report.generate": "生成风控报告",
|
||||||
|
"risk.subscription.report.send": "发送风控通知",
|
||||||
|
"risk.subscription.review": "更新订阅风控事件",
|
||||||
|
"risk.subscription.suspend": "风控暂停订阅",
|
||||||
|
"risk.subscription.warning": "记录订阅风险警告",
|
||||||
|
"service.create": "创建流媒体服务",
|
||||||
|
"service.update": "更新流媒体服务",
|
||||||
|
"service.delete": "删除流媒体服务",
|
||||||
|
"service.enable": "启用流媒体服务",
|
||||||
|
"service.disable": "停用流媒体服务",
|
||||||
|
"service.batch_enable": "批量启用流媒体服务",
|
||||||
|
"service.batch_disable": "批量停用流媒体服务",
|
||||||
|
"settings.toggle": "切换系统开关",
|
||||||
|
"settings.update": "更新系统设置",
|
||||||
|
"streaming-slot.reassign": "调配流媒体槽位",
|
||||||
|
"subscription.activate": "恢复订阅",
|
||||||
|
"subscription.auto_suspend": "自动暂停订阅",
|
||||||
|
"subscription.cancel": "取消订阅",
|
||||||
|
"subscription.create": "创建订阅",
|
||||||
|
"subscription.delete": "删除订阅",
|
||||||
|
"subscription.renew": "续费订阅",
|
||||||
|
"subscription.rotate_access": "重置订阅访问密钥",
|
||||||
|
"subscription.suspend": "暂停订阅",
|
||||||
|
"subscription.topup": "追加流量",
|
||||||
|
"support.close": "关闭工单",
|
||||||
|
"support.delete": "删除工单",
|
||||||
|
"support.reply": "回复工单",
|
||||||
|
"support.update": "更新工单",
|
||||||
|
"task.retry": "重试任务",
|
||||||
|
"task.run": "执行任务",
|
||||||
|
"traffic.sync": "同步流量视图",
|
||||||
|
"user.batch_status": "批量更新用户状态",
|
||||||
|
"user.create": "创建用户",
|
||||||
|
"user.force_delete": "强制删除用户",
|
||||||
|
"user.status": "更新用户状态",
|
||||||
|
"user.update": "更新用户",
|
||||||
|
};
|
||||||
|
|
||||||
|
const auditTargetTypeLabels: Record<string, string> = {
|
||||||
|
Announcement: "公告",
|
||||||
|
AppConfig: "系统设置",
|
||||||
|
Coupon: "优惠券",
|
||||||
|
Database: "数据库",
|
||||||
|
NodeInbound: "线路入口",
|
||||||
|
NodeServer: "节点",
|
||||||
|
Order: "订单",
|
||||||
|
PaymentConfig: "支付配置",
|
||||||
|
PromotionRule: "满减规则",
|
||||||
|
StreamingService: "流媒体服务",
|
||||||
|
StreamingSlot: "流媒体槽位",
|
||||||
|
SubscriptionPlan: "套餐",
|
||||||
|
SupportTicket: "工单",
|
||||||
|
TaskRun: "任务",
|
||||||
|
TrafficSync: "流量同步",
|
||||||
|
User: "用户",
|
||||||
|
UserSubscription: "订阅",
|
||||||
|
};
|
||||||
|
|
||||||
|
const tokenLabels: Record<string, string> = {
|
||||||
|
...booleanAppSettingLabels,
|
||||||
|
...nodeStatusLabels,
|
||||||
|
...orderStatusLabels,
|
||||||
|
...paymentProviderLabels,
|
||||||
|
...subscriptionStatusLabels,
|
||||||
|
...subscriptionTypeLabels,
|
||||||
|
...taskKindLabels,
|
||||||
|
...userStatusLabels,
|
||||||
|
};
|
||||||
|
|
||||||
|
function escapeRegExp(value: string) {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceKnownTokens(value: string) {
|
||||||
|
return Object.entries(tokenLabels)
|
||||||
|
.sort(([left], [right]) => right.length - left.length)
|
||||||
|
.reduce((text, [token, label]) => {
|
||||||
|
const pattern = new RegExp(`\\b${escapeRegExp(token)}\\b`, "g");
|
||||||
|
return text.replace(pattern, label);
|
||||||
|
}, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAuditAction(action: string) {
|
||||||
|
const exact = auditActionLabels[action];
|
||||||
|
if (exact) return exact;
|
||||||
|
|
||||||
|
const prefix = auditActionFilterOptions.find((option) => option.value && action.startsWith(option.value));
|
||||||
|
return prefix?.label ?? "系统操作";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAuditTargetType(targetType: string | null | undefined) {
|
||||||
|
if (!targetType) return "系统";
|
||||||
|
return auditTargetTypeLabels[targetType] ?? "业务对象";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAuditTargetLabel({
|
||||||
|
targetType,
|
||||||
|
targetLabel,
|
||||||
|
targetId,
|
||||||
|
}: {
|
||||||
|
targetType: string | null | undefined;
|
||||||
|
targetLabel?: string | null;
|
||||||
|
targetId?: string | null;
|
||||||
|
}) {
|
||||||
|
if (targetLabel) {
|
||||||
|
if (targetType === "PaymentConfig") return getPaymentProviderLabel(targetLabel);
|
||||||
|
if (targetType === "TaskRun") return getTaskKindLabel(targetLabel);
|
||||||
|
return replaceKnownTokens(targetLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetType === "Database") return "数据库";
|
||||||
|
if (targetType === "TrafficSync") return "全站流量";
|
||||||
|
if (targetId) return `ID ${targetId.slice(0, 8)}`;
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAuditActorRole(role: string | null | undefined) {
|
||||||
|
if (!role) return "系统";
|
||||||
|
return getUserRoleLabel(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAuditMessage(message: string) {
|
||||||
|
const withSettingLabels = message.replace(
|
||||||
|
/系统开关\s+([A-Za-z][A-Za-z0-9_]*)/g,
|
||||||
|
(_match, field: string) => `${getBooleanAppSettingLabel(field)}开关`,
|
||||||
|
);
|
||||||
|
return replaceKnownTokens(withSettingLabels);
|
||||||
|
}
|
||||||
168
src/lib/domain-labels.ts
Normal file
168
src/lib/domain-labels.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import type {
|
||||||
|
AnnouncementAudience,
|
||||||
|
AnnouncementDisplayType,
|
||||||
|
OrderKind,
|
||||||
|
OrderReviewStatus,
|
||||||
|
OrderStatus,
|
||||||
|
Role,
|
||||||
|
SubscriptionStatus,
|
||||||
|
SubscriptionType,
|
||||||
|
TaskKind,
|
||||||
|
TaskStatus,
|
||||||
|
UserStatus,
|
||||||
|
} from "@prisma/client";
|
||||||
|
|
||||||
|
export const orderStatusLabels: Record<OrderStatus, string> = {
|
||||||
|
PENDING: "待确认",
|
||||||
|
PAID: "已支付",
|
||||||
|
CANCELLED: "已取消",
|
||||||
|
REFUNDED: "已退款",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const orderKindLabels: Record<OrderKind, string> = {
|
||||||
|
NEW_PURCHASE: "新购",
|
||||||
|
RENEWAL: "续费",
|
||||||
|
TRAFFIC_TOPUP: "增流量",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const orderReviewStatusLabels: Record<OrderReviewStatus, string> = {
|
||||||
|
NORMAL: "正常",
|
||||||
|
FLAGGED: "异常",
|
||||||
|
RESOLVED: "已解决",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const subscriptionStatusLabels: Record<SubscriptionStatus, string> = {
|
||||||
|
ACTIVE: "活跃",
|
||||||
|
EXPIRED: "已过期",
|
||||||
|
CANCELLED: "已取消",
|
||||||
|
SUSPENDED: "已暂停",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const subscriptionTypeLabels: Record<SubscriptionType, string> = {
|
||||||
|
PROXY: "代理",
|
||||||
|
STREAMING: "流媒体",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const userRoleLabels: Record<Role, string> = {
|
||||||
|
ADMIN: "管理员",
|
||||||
|
USER: "用户",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const userStatusLabels: Record<UserStatus, string> = {
|
||||||
|
ACTIVE: "正常",
|
||||||
|
PENDING_EMAIL: "待邮箱验证",
|
||||||
|
DISABLED: "禁用",
|
||||||
|
BANNED: "封禁",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const taskKindLabels: Record<TaskKind, string> = {
|
||||||
|
REMINDER_DISPATCH: "提醒派发",
|
||||||
|
ORDER_PROVISION_RETRY: "订单重试",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const taskStatusLabels: Record<TaskStatus, string> = {
|
||||||
|
PENDING: "待执行",
|
||||||
|
RUNNING: "运行中",
|
||||||
|
SUCCESS: "成功",
|
||||||
|
FAILED: "失败",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const announcementAudienceLabels: Record<AnnouncementAudience, string> = {
|
||||||
|
PUBLIC: "公开",
|
||||||
|
USERS: "全部用户",
|
||||||
|
ADMINS: "全部管理员",
|
||||||
|
SPECIFIC_USER: "指定用户",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const announcementDisplayTypeLabels: Record<AnnouncementDisplayType, string> = {
|
||||||
|
INLINE: "普通公告",
|
||||||
|
BIG: "大公告",
|
||||||
|
POPUP: "弹窗公告",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const booleanAppSettingLabels = {
|
||||||
|
allowRegistration: "开放注册",
|
||||||
|
emailVerificationRequired: "注册邮箱验证",
|
||||||
|
requireInviteCode: "邀请码注册",
|
||||||
|
autoReminderDispatchEnabled: "自动提醒派发",
|
||||||
|
trafficSyncEnabled: "3x-ui 流量定时同步",
|
||||||
|
networkRecommendationsEnabled: "三网推荐",
|
||||||
|
networkInsightsEnabled: "线路体验",
|
||||||
|
subscriptionRiskEnabled: "订阅访问风控",
|
||||||
|
subscriptionRiskAutoSuspend: "风控自动暂停",
|
||||||
|
nodeAccessRiskEnabled: "节点日志风控",
|
||||||
|
inviteRewardEnabled: "自动发放奖励",
|
||||||
|
smtpEnabled: "邮件服务",
|
||||||
|
smtpSecure: "SMTP SSL 直连",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type BooleanAppSettingField = keyof typeof booleanAppSettingLabels;
|
||||||
|
|
||||||
|
export const booleanAppSettingFields = Object.keys(booleanAppSettingLabels) as [
|
||||||
|
BooleanAppSettingField,
|
||||||
|
...BooleanAppSettingField[],
|
||||||
|
];
|
||||||
|
|
||||||
|
export const nodeStatusLabels: Record<string, string> = {
|
||||||
|
active: "已启用",
|
||||||
|
inactive: "已停用",
|
||||||
|
disabled: "已停用",
|
||||||
|
error: "异常",
|
||||||
|
offline: "离线",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const paymentProviderLabels: Record<string, string> = {
|
||||||
|
epay: "易支付",
|
||||||
|
alipay_f2f: "支付宝当面付",
|
||||||
|
usdt_trc20: "USDT (TRC20)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const paymentChannelLabels: Record<string, string> = {
|
||||||
|
alipay: "支付宝",
|
||||||
|
wxpay: "微信支付",
|
||||||
|
};
|
||||||
|
|
||||||
|
function labelFromMap(map: Partial<Record<string, string>>, value: string | null | undefined, fallback: string) {
|
||||||
|
if (!value) return fallback;
|
||||||
|
return map[value] ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOrderStatusLabel(status: string | null | undefined) {
|
||||||
|
return labelFromMap(orderStatusLabels, status, "未知订单状态");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOrderKindLabel(kind: string | null | undefined) {
|
||||||
|
return labelFromMap(orderKindLabels, kind, "未知订单类型");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSubscriptionStatusLabel(status: string | null | undefined) {
|
||||||
|
return labelFromMap(subscriptionStatusLabels, status, "未知订阅状态");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSubscriptionTypeLabel(type: string | null | undefined) {
|
||||||
|
return labelFromMap(subscriptionTypeLabels, type, "未知套餐类型");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserStatusLabel(status: string | null | undefined) {
|
||||||
|
return labelFromMap(userStatusLabels, status, "未知用户状态");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserRoleLabel(role: string | null | undefined) {
|
||||||
|
return labelFromMap(userRoleLabels, role, "未知角色");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTaskKindLabel(kind: string | null | undefined) {
|
||||||
|
return labelFromMap(taskKindLabels, kind, "任务");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNodeStatusLabel(status: string | null | undefined) {
|
||||||
|
return labelFromMap(nodeStatusLabels, status, "未知状态");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPaymentProviderLabel(provider: string | null | undefined) {
|
||||||
|
return labelFromMap(paymentProviderLabels, provider, "支付方式");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBooleanAppSettingLabel(field: string | null | undefined) {
|
||||||
|
return labelFromMap(booleanAppSettingLabels, field, "系统开关");
|
||||||
|
}
|
||||||
@@ -200,7 +200,7 @@ export function parsePaymentConfig(
|
|||||||
const schema = paymentConfigSchemas[provider as keyof typeof paymentConfigSchemas];
|
const schema = paymentConfigSchemas[provider as keyof typeof paymentConfigSchemas];
|
||||||
|
|
||||||
if (!schema) {
|
if (!schema) {
|
||||||
throw new Error(`未知支付方式:${provider}`);
|
throw new Error("未知支付方式");
|
||||||
}
|
}
|
||||||
|
|
||||||
return schema.parse(normalized);
|
return schema.parse(normalized);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export async function getPaymentAdapter(provider: string): Promise<PaymentAdapte
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!config || !config.enabled) {
|
if (!config || !config.enabled) {
|
||||||
throw new Error(`Payment provider "${provider}" is not configured or disabled`);
|
throw new Error(`${getPaymentProviderName(realProvider)}未配置或未启用`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cfg = parsePaymentConfig(
|
const cfg = parsePaymentConfig(
|
||||||
@@ -34,7 +34,7 @@ export async function getPaymentAdapter(provider: string): Promise<PaymentAdapte
|
|||||||
case "usdt_trc20":
|
case "usdt_trc20":
|
||||||
return new UsdtTrc20Adapter(cfg as UsdtTrc20Config);
|
return new UsdtTrc20Adapter(cfg as UsdtTrc20Config);
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown payment provider: ${provider}`);
|
throw new Error("未知支付方式");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { recordAuditLog } from "@/services/audit";
|
|||||||
import { createNotification } from "@/services/notifications";
|
import { createNotification } from "@/services/notifications";
|
||||||
import { generateNodeClientCredential } from "@/services/node-client-credential";
|
import { generateNodeClientCredential } from "@/services/node-client-credential";
|
||||||
import { createPanelAdapter } from "@/services/node-panel/factory";
|
import { createPanelAdapter } from "@/services/node-panel/factory";
|
||||||
|
import { getOrderKindLabel, getSubscriptionStatusLabel } from "@/lib/domain-labels";
|
||||||
import type {
|
import type {
|
||||||
NodeServer,
|
NodeServer,
|
||||||
Order,
|
Order,
|
||||||
@@ -42,7 +43,7 @@ export async function provisionSubscriptionWithDb(
|
|||||||
return applyTrafficTopup(order, db);
|
return applyTrafficTopup(order, db);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`开通订阅失败:不支持的订单类型 ${String(order.kind)}`);
|
throw new Error(`开通订阅失败:不支持的订单类型${getOrderKindLabel(order.kind)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getNewPurchaseItems(order: PaidOrder, db: DbClient): Promise<NewOrderItem[]> {
|
async function getNewPurchaseItems(order: PaidOrder, db: DbClient): Promise<NewOrderItem[]> {
|
||||||
@@ -191,7 +192,7 @@ async function applyRenewal(order: PaidOrder, db: DbClient): Promise<string[]> {
|
|||||||
throw new Error("续费目标订阅与订单不匹配");
|
throw new Error("续费目标订阅与订单不匹配");
|
||||||
}
|
}
|
||||||
if (subscription.status !== "ACTIVE" || subscription.endDate <= new Date()) {
|
if (subscription.status !== "ACTIVE" || subscription.endDate <= new Date()) {
|
||||||
throw new Error(`续费失败:目标订阅状态为 ${subscription.status},到期时间为 ${subscription.endDate.toISOString()}`);
|
throw new Error(`续费失败:目标订阅状态为${getSubscriptionStatusLabel(subscription.status)},到期时间为 ${subscription.endDate.toISOString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
} from "@prisma/client";
|
} from "@prisma/client";
|
||||||
import { prisma, type DbClient } from "@/lib/prisma";
|
import { prisma, type DbClient } from "@/lib/prisma";
|
||||||
import { formatDate } from "@/lib/utils";
|
import { formatDate } from "@/lib/utils";
|
||||||
|
import { getSubscriptionStatusLabel, getSubscriptionTypeLabel } from "@/lib/domain-labels";
|
||||||
|
|
||||||
export const subscriptionRiskAccessLogSelect = {
|
export const subscriptionRiskAccessLogSelect = {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -370,7 +371,7 @@ export function buildSubscriptionRiskReport(input: {
|
|||||||
const { event, logs, user, subscription } = input;
|
const { event, logs, user, subscription } = input;
|
||||||
const summary = buildSubscriptionRiskGeoSummary(logs);
|
const summary = buildSubscriptionRiskGeoSummary(logs);
|
||||||
const target = subscription
|
const target = subscription
|
||||||
? `${subscription.plan.name}(${subscription.plan.type},当前状态:${subscription.status})`
|
? `${subscription.plan.name}(${getSubscriptionTypeLabel(subscription.plan.type)},当前状态:${getSubscriptionStatusLabel(subscription.status)})`
|
||||||
: "用户总订阅";
|
: "用户总订阅";
|
||||||
const userLabel = user ? `${user.email}${user.name ? `(${user.name})` : ""}` : event.userId ?? "未知用户";
|
const userLabel = user ? `${user.email}${user.name ? `(${user.name})` : ""}` : event.userId ?? "未知用户";
|
||||||
const windowRange = `${formatDate(event.windowStartedAt)} 至 ${formatDate(event.createdAt)}`;
|
const windowRange = `${formatDate(event.windowStartedAt)} 至 ${formatDate(event.createdAt)}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user