feat: polish internal value displays

This commit is contained in:
JetSprow
2026-04-30 16:38:38 +10:00
parent abc2d4aa72
commit 042c5b34ab
25 changed files with 498 additions and 163 deletions

View File

@@ -5,6 +5,7 @@ import { requireAdmin } from "@/lib/require-auth";
import { revalidatePath } from "next/cache";
import {
decryptPaymentConfigForUse,
getPaymentProviderName,
normalizePaymentConfig,
parsePaymentConfig,
preparePaymentConfigForStorage,
@@ -54,8 +55,8 @@ export async function savePaymentConfig(
action: "payment.config",
targetType: "PaymentConfig",
targetId: provider,
targetLabel: provider,
message: `${enabled ? "启用并更新" : "更新"}支付配置 ${provider}`,
targetLabel: getPaymentProviderName(provider),
message: `${enabled ? "启用并更新" : "更新"}支付配置 ${getPaymentProviderName(provider)}`,
});
revalidatePath("/admin/payments");
}

View File

@@ -11,24 +11,15 @@ import { normalizeSiteUrl } from "@/services/site-url";
import { encrypt, isEncryptedValue } from "@/lib/crypto";
import { getErrorMessage } from "@/lib/errors";
import { sendSmtpTestEmail } from "@/services/email";
import {
booleanAppSettingFields,
getBooleanAppSettingLabel,
type BooleanAppSettingField,
} from "@/lib/domain-labels";
const booleanSettingFields = [
"allowRegistration",
"emailVerificationRequired",
"requireInviteCode",
"autoReminderDispatchEnabled",
"trafficSyncEnabled",
"networkRecommendationsEnabled",
"networkInsightsEnabled",
"subscriptionRiskEnabled",
"subscriptionRiskAutoSuspend",
"nodeAccessRiskEnabled",
"inviteRewardEnabled",
"smtpEnabled",
"smtpSecure",
] as const;
const booleanSettingFields = booleanAppSettingFields;
export type BooleanSettingField = (typeof booleanSettingFields)[number];
export type BooleanSettingField = BooleanAppSettingField;
const settingsSchema = z.object({
siteName: z.string().trim().min(1, "站点名称不能为空"),
@@ -363,7 +354,7 @@ export async function saveBooleanAppSetting(input: {
targetType: "AppConfig",
targetId: current.id,
targetLabel: current.siteName,
message: `${parsed.value ? "开启" : "关闭"}系统开关 ${parsed.field}`,
message: `${parsed.value ? "开启" : "关闭"}${getBooleanAppSettingLabel(parsed.field)}开关`,
});
revalidateSettingsViews();

View File

@@ -7,6 +7,7 @@ import { dispatchSubscriptionReminders } from "@/services/notifications";
import { confirmPendingOrder } from "@/services/payment/process";
import { runTask, updateTaskRun } from "@/services/task-center";
import { prisma } from "@/lib/prisma";
import { getTaskKindLabel } from "@/lib/domain-labels";
function revalidateTaskViews() {
revalidatePath("/admin/tasks");
@@ -37,7 +38,7 @@ export async function runReminderTask() {
action: "task.run",
targetType: "TaskRun",
targetId: outcome.taskId,
targetLabel: "REMINDER_DISPATCH",
targetLabel: getTaskKindLabel("REMINDER_DISPATCH"),
message: "手动执行提醒派发任务",
});
@@ -82,7 +83,7 @@ export async function retryTaskRun(taskId: string) {
action: "task.retry",
targetType: "TaskRun",
targetId: task.id,
targetLabel: task.kind,
targetLabel: getTaskKindLabel(task.kind),
message: `重试任务 ${task.title}`,
});
} catch (error) {

View File

@@ -7,6 +7,7 @@ import bcrypt from "bcryptjs";
import { z } from "zod";
import { actorFromSession, recordAuditLog } from "@/services/audit";
import { createPanelAdapter } from "@/services/node-panel/factory";
import { getUserStatusLabel } from "@/lib/domain-labels";
const createUserSchema = z.object({
email: z.string().email(),
@@ -86,7 +87,7 @@ export async function updateUserStatus(id: string, status: "ACTIVE" | "DISABLED"
targetType: "User",
targetId: user.id,
targetLabel: user.email,
message: `将用户 ${user.email} 状态改为 ${status}`,
message: `将用户 ${user.email} 状态改为${getUserStatusLabel(status)}`,
});
revalidatePath("/admin/users");
revalidatePath(`/admin/users/${user.id}`);
@@ -291,7 +292,7 @@ export async function batchUpdateUserStatus(formData: FormData) {
actor: actorFromSession(session),
action: "user.batch_status",
targetType: "User",
message: `批量更新 ${userIds.length} 个用户状态为 ${status}`,
message: `批量更新 ${userIds.length} 个用户状态为${getUserStatusLabel(String(status))}`,
metadata: {
userIds,
status: String(status),

View File

@@ -6,6 +6,7 @@ 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";
import { getSubscriptionTypeLabel } from "@/lib/domain-labels";
async function assertNoPendingOrder(userId: string) {
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} 当前不可购买`);
return plan;
}
@@ -115,7 +116,7 @@ export async function addProxyPlanToCart(
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(`套餐类型不匹配:${plan.name} ${plan.type},不能作为流媒体套餐加入购物车`);
if (plan.type !== "STREAMING") throw new Error(`套餐类型不匹配:${plan.name}${getSubscriptionTypeLabel(plan.type)},不能作为流媒体套餐加入购物车`);
if (!plan.isActive) throw new Error(`套餐已下架:${plan.name} 当前不可购买`);
const availability = await getPlanAvailability(plan, { userId: session.user.id });

View File

@@ -13,6 +13,7 @@ import {
getPlanTrafficPoolState,
} from "@/services/plan-traffic-pool";
import { getPlanPurchasePrice, roundMoney } from "@/services/commerce";
import { getSubscriptionTypeLabel } from "@/lib/domain-labels";
async function assertNoPendingOrder(userId: string) {
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} 当前不可购买`);
const price = getPlanPurchasePrice(plan, trafficGb);
@@ -213,7 +214,7 @@ export async function purchaseStreaming(planId: string): Promise<string> {
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} 当前不可购买`);
const availability = await getPlanAvailability(plan, { userId: session.user.id });

View File

@@ -9,6 +9,13 @@ import {
DataTableHeaderRow,
DataTableRow,
} from "@/components/shared/data-table";
import {
formatAuditAction,
formatAuditActorRole,
formatAuditMessage,
formatAuditTargetLabel,
formatAuditTargetType,
} from "@/lib/audit-display";
import { formatDate } from "@/lib/utils";
export function AuditLogsTable({ logs }: { logs: AuditLog[] }) {
@@ -37,20 +44,22 @@ export function AuditLogsTable({ logs }: { logs: AuditLog[] }) {
<DataTableCell>
<div className="space-y-1">
<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>
</DataTableCell>
<DataTableCell className="whitespace-nowrap font-medium">{log.action}</DataTableCell>
<DataTableCell className="whitespace-nowrap font-medium">
{formatAuditAction(log.action)}
</DataTableCell>
<DataTableCell>
<div className="space-y-1">
<p>{log.targetType}</p>
<p>{formatAuditTargetType(log.targetType)}</p>
<p className="text-xs text-muted-foreground">
{log.targetLabel || log.targetId || "—"}
{formatAuditTargetLabel(log)}
</p>
</div>
</DataTableCell>
<DataTableCell className="max-w-xl whitespace-pre-wrap break-words text-muted-foreground">
{log.message}
{formatAuditMessage(log.message)}
</DataTableCell>
</DataTableRow>
))}

View File

@@ -4,6 +4,7 @@ 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 { auditActionFilterOptions } from "@/lib/audit-display";
import { AuditLogsTable } from "./_components/audit-logs-table";
import { buildAuditLogExportHref, getAuditLogs } from "./audit-logs-data";
@@ -42,17 +43,7 @@ export default async function AuditLogsPage({
{
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." },
{ label: "risk.", value: "risk." },
],
options: auditActionFilterOptions,
},
]}
/>

View File

@@ -4,6 +4,7 @@ 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 { getNodeStatusLabel } from "@/lib/domain-labels";
import { getNodeDetail } from "./node-detail-data";
import { NodeDetailTabs } from "./_components/node-detail-tabs";
@@ -34,7 +35,7 @@ export default async function NodeDetailPage({
description={`3x-ui · ${node.panelUrl || "未配置面板"}`}
actions={
<StatusBadge tone={node.status === "active" ? "success" : "neutral"}>
{node.status}
{getNodeStatusLabel(node.status)}
</StatusBadge>
}
className="flex-1"

View File

@@ -5,6 +5,7 @@ import { BatchActionBar, BatchActionButton } from "@/components/admin/batch-acti
import { EmptyState } from "@/components/shared/page-shell";
import { StatusBadge } from "@/components/shared/status-badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { getNodeStatusLabel } from "@/lib/domain-labels";
import { InboundDeleteButton } from "../inbound-delete-button";
import { InboundDisplayNameForm } from "../inbound-display-name-form";
import { NodeActions } from "../node-actions";
@@ -52,7 +53,7 @@ function NodeCard({ node, siteUrl }: { node: NodeServerRow; siteUrl: string | nu
</div>
<div className="flex flex-wrap items-center gap-2">
<StatusBadge tone={node.status === "active" ? "success" : "neutral"}>
{node.status}
{getNodeStatusLabel(node.status)}
</StatusBadge>
<NodeForm
node={{

View File

@@ -16,6 +16,10 @@ import {
} from "@/actions/admin/settings";
import { toast } from "sonner";
import { getErrorMessage } from "@/lib/errors";
import {
booleanAppSettingLabels,
type BooleanAppSettingField,
} from "@/lib/domain-labels";
interface AppConfig {
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";
type ToggleValues = Record<BooleanSettingField, boolean>;
type ToggleValues = Record<BooleanAppSettingField, boolean>;
const booleanSettingLabels: Record<BooleanSettingField, string> = {
allowRegistration: "开放注册",
emailVerificationRequired: "注册邮箱验证",
requireInviteCode: "邀请码注册",
autoReminderDispatchEnabled: "自动提醒派发",
trafficSyncEnabled: "3x-ui 流量定时同步",
networkRecommendationsEnabled: "三网推荐",
networkInsightsEnabled: "线路体验",
subscriptionRiskEnabled: "风控总控",
subscriptionRiskAutoSuspend: "自动暂停",
nodeAccessRiskEnabled: "节点日志风控",
inviteRewardEnabled: "自动发放奖励",
smtpEnabled: "邮件服务",
smtpSecure: "SSL 直连",
};
const booleanSettingLabels = booleanAppSettingLabels;
function initialToggleValues(config: AppConfig): ToggleValues {
return {

View File

@@ -4,6 +4,7 @@ import { useEffect, useEffectEvent, useMemo, useState } from "react";
import { cancelOwnPendingOrder, resetOwnPendingPaymentChoice } from "@/actions/user/orders";
import { fetchJson } from "@/lib/fetch-json";
import { getErrorMessage } from "@/lib/errors";
import { getOrderStatusLabel } from "@/lib/domain-labels";
import type {
OrderPaymentSnapshot,
PaymentInfo,
@@ -111,7 +112,7 @@ export function usePaymentFlow(orderId: string) {
if (order.status !== "PENDING") {
setStatus("idle");
setPageError(`这笔订单当前为 ${order.status},无法继续支付。`);
setPageError(`这笔订单当前为${getOrderStatusLabel(order.status)},无法继续支付。`);
return;
}
@@ -161,7 +162,7 @@ export function usePaymentFlow(orderId: string) {
setStatus("idle");
setPageError(
result.error || `订单状态更新:${orderStatusLabel[result.status] ?? result.status}`,
result.error || `订单状态更新:${orderStatusLabel[result.status] ?? "未知状态"}`,
);
} catch (error) {
setStatus("idle");

View File

@@ -1,5 +1,12 @@
import { prisma } from "@/lib/prisma";
import { requireAdminApiSession } from "@/lib/admin-api";
import {
formatAuditAction,
formatAuditActorRole,
formatAuditMessage,
formatAuditTargetLabel,
formatAuditTargetType,
} from "@/lib/audit-display";
export async function GET(req: Request) {
const { errorResponse } = await requireAdminApiSession();
@@ -9,24 +16,37 @@ export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const q = searchParams.get("q")?.trim() ?? "";
const action = searchParams.get("action") ?? "";
const logs = await prisma.auditLog.findMany({
where: q
? {
OR: [
{ action: { contains: q } },
{ targetType: { contains: q } },
{ targetLabel: { contains: q } },
{ actorEmail: { contains: q } },
{ message: { contains: q } },
],
}
: undefined,
where: {
...(action ? { action: { startsWith: action } } : {}),
...(q
? {
OR: [
{ action: { contains: q } },
{ targetType: { contains: q } },
{ targetLabel: { contains: q } },
{ actorEmail: { contains: q } },
{ message: { contains: q } },
],
}
: {}),
},
orderBy: { createdAt: "desc" },
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: {
"Content-Type": "application/json; charset=utf-8",
"Content-Disposition": 'attachment; filename="jboard-audit-logs.json"',

View File

@@ -6,6 +6,7 @@ import { getPaymentAdapter } from "@/services/payment/factory";
import { rateLimit } from "@/lib/rate-limit";
import { getSiteBaseUrl } from "@/services/site-url";
import { getActiveSubscriptionRiskRestriction } from "@/services/subscription-risk-review";
import { getOrderStatusLabel } from "@/lib/domain-labels";
import { v4 as uuidv4 } from "uuid";
const createPaymentSchema = z.object({
@@ -66,7 +67,7 @@ export async function POST(req: Request) {
}
if (order.status !== "PENDING") {
return jsonError(`订单当前状态为 ${order.status},无法继续支付`, {
return jsonError(`订单当前状态为${getOrderStatusLabel(order.status)},无法继续支付`, {
status: 400,
});
}

View File

@@ -11,6 +11,7 @@ import { rateLimit } from "@/lib/rate-limit";
import { getClientRequestContext } from "@/lib/request-context";
import { recordSubscriptionAccess } from "@/services/subscription-risk";
import { getAppConfig } from "@/services/app-config";
import { getSubscriptionStatusLabel } from "@/lib/domain-labels";
const SUBSCRIPTION_RATE_WINDOW_SECONDS = 60 * 60;
@@ -60,7 +61,7 @@ export async function GET(
allowed: false,
reason: "rate_limited",
});
return jsonError("Too many subscription requests", 429);
return jsonError("订阅请求过于频繁,请稍后再试", 429);
}
}
@@ -104,7 +105,7 @@ export async function GET(
allowed: false,
reason: "rate_limited",
});
return jsonError("Too many subscription requests", 429);
return jsonError("订阅请求过于频繁,请稍后再试", 429);
}
}
@@ -117,7 +118,7 @@ export async function GET(
allowed: false,
reason: "subscription_inactive",
});
return jsonError(`订阅当前状态为 ${sub.status},只有 ACTIVE 状态可以拉取配置`, 403);
return jsonError(`订阅当前状态为${getSubscriptionStatusLabel(sub.status)},只有活跃订阅可以拉取配置`, 403);
}
const risk = await recordSubscriptionAccess({
@@ -130,7 +131,7 @@ export async function GET(
});
if (risk.suspended) {
return jsonError("Subscription suspended by risk control", 403);
return jsonError("订阅已被风控暂停,请联系管理员处理", 403);
}
const format = resolveSubscriptionFormat(url.searchParams, req.headers.get("user-agent"));

View File

@@ -12,6 +12,7 @@ import { rateLimit } from "@/lib/rate-limit";
import { getClientRequestContext } from "@/lib/request-context";
import { recordSubscriptionAccess } from "@/services/subscription-risk";
import { getAppConfig } from "@/services/app-config";
import { getUserStatusLabel } from "@/lib/domain-labels";
const SUBSCRIPTION_RATE_WINDOW_SECONDS = 60 * 60;
@@ -41,7 +42,7 @@ export async function GET(req: Request) {
allowed: false,
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,
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,
reason: "user_inactive",
});
return jsonError("User inactive", 403);
return jsonError(`账户当前状态为${getUserStatusLabel(user.status)},暂时无法拉取总订阅`, 403);
}
const risk = await recordSubscriptionAccess({
@@ -111,7 +112,7 @@ export async function GET(req: Request) {
});
if (risk.suspended) {
return jsonError("Subscriptions suspended by risk control", 403);
return jsonError("总订阅已被风控暂停,请联系管理员处理", 403);
}
const format = resolveSubscriptionFormat(url.searchParams, req.headers.get("user-agent"));

View File

@@ -1,85 +1,37 @@
import type {
AnnouncementAudience,
AnnouncementDisplayType,
OrderKind,
OrderReviewStatus,
OrderStatus,
Role,
SubscriptionStatus,
SubscriptionType,
TaskKind,
TaskStatus,
UserStatus,
} from "@prisma/client";
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> = {
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 {
announcementAudienceLabels,
announcementDisplayTypeLabels,
orderKindLabels,
orderReviewStatusLabels,
orderStatusLabels,
subscriptionStatusLabels,
subscriptionTypeLabels,
taskKindLabels,
taskStatusLabels,
userRoleLabels,
userStatusLabels,
} from "@/lib/domain-labels";
export function getOrderStatusTone(status: OrderStatus): StatusTone {
if (status === "PAID") return "success";

View File

@@ -1,6 +1,7 @@
"use client";
import { CircleDot } from "lucide-react";
import { formatAuditAction, formatAuditMessage } from "@/lib/audit-display";
interface TimelineItem {
id: string;
@@ -28,11 +29,11 @@ export function SubscriptionTimeline({
</div>
<div className="min-w-0 flex-1">
<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>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{item.action}
{formatAuditAction(item.action)}
{item.actorEmail ? ` · ${item.actorEmail}` : " · 系统"}
</p>
</div>

View File

@@ -60,13 +60,15 @@ export function TrafficTrendChart({
<XAxis dataKey="date" />
<YAxis unit=" GB" width={60} />
<Tooltip
labelFormatter={(label) => `日期:${label}`}
formatter={(value) =>
`${Number(typeof value === "number" ? value : 0).toFixed(2)} GB`
[`${Number(typeof value === "number" ? value : 0).toFixed(2)} GB`, "流量"]
}
/>
<Area
type="monotone"
dataKey="valueGb"
name="流量"
stroke={color}
fill={color}
fillOpacity={0.15}

198
src/lib/audit-display.ts Normal file
View 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
View 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, "系统开关");
}

View File

@@ -200,7 +200,7 @@ export function parsePaymentConfig(
const schema = paymentConfigSchemas[provider as keyof typeof paymentConfigSchemas];
if (!schema) {
throw new Error(`未知支付方式${provider}`);
throw new Error("未知支付方式");
}
return schema.parse(normalized);

View File

@@ -18,7 +18,7 @@ export async function getPaymentAdapter(provider: string): Promise<PaymentAdapte
});
if (!config || !config.enabled) {
throw new Error(`Payment provider "${provider}" is not configured or disabled`);
throw new Error(`${getPaymentProviderName(realProvider)}未配置或未启用`);
}
const cfg = parsePaymentConfig(
@@ -34,7 +34,7 @@ export async function getPaymentAdapter(provider: string): Promise<PaymentAdapte
case "usdt_trc20":
return new UsdtTrc20Adapter(cfg as UsdtTrc20Config);
default:
throw new Error(`Unknown payment provider: ${provider}`);
throw new Error("未知支付方式");
}
}

View File

@@ -9,6 +9,7 @@ import { recordAuditLog } from "@/services/audit";
import { createNotification } from "@/services/notifications";
import { generateNodeClientCredential } from "@/services/node-client-credential";
import { createPanelAdapter } from "@/services/node-panel/factory";
import { getOrderKindLabel, getSubscriptionStatusLabel } from "@/lib/domain-labels";
import type {
NodeServer,
Order,
@@ -42,7 +43,7 @@ export async function provisionSubscriptionWithDb(
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[]> {
@@ -191,7 +192,7 @@ async function applyRenewal(order: PaidOrder, db: DbClient): Promise<string[]> {
throw new Error("续费目标订阅与订单不匹配");
}
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();

View File

@@ -6,6 +6,7 @@ import type {
} from "@prisma/client";
import { prisma, type DbClient } from "@/lib/prisma";
import { formatDate } from "@/lib/utils";
import { getSubscriptionStatusLabel, getSubscriptionTypeLabel } from "@/lib/domain-labels";
export const subscriptionRiskAccessLogSelect = {
id: true,
@@ -370,7 +371,7 @@ export function buildSubscriptionRiskReport(input: {
const { event, logs, user, subscription } = input;
const summary = buildSubscriptionRiskGeoSummary(logs);
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 windowRange = `${formatDate(event.windowStartedAt)}${formatDate(event.createdAt)}`;