feat(admin-frontend): 新增系统与订阅管理后台页面

扩展管理端侧边栏与路由,新增系统配置真实页面、订阅套餐
管理页、节点管理页及多个结构化占位页

补齐前端 API、类型与工具层,并增强仪表盘刷新、趋势切换、
失败作业详情与流量排行 limit 联动能力

同步后端 traffic rank limit 支持与知识库归档、设计约束、
验证配置及视觉验收产物
This commit is contained in:
yinjianm
2026-04-24 15:32:09 +08:00
parent 9ce345eb76
commit 16203b14f6
74 changed files with 6737 additions and 119 deletions
+83 -3
View File
@@ -1,7 +1,14 @@
import { adminClient } from './client'
import type {
AdminConfigGroupKey,
AdminConfigMappings,
AdminNodeItem,
AdminNodeUpdatePayload,
AdminQueueFailedJobResult,
AdminPaginationResult,
AdminPlanOption,
AdminPlanListItem,
AdminPlanSavePayload,
AdminServerGroupItem,
AdminTicketDetail,
AdminTicketFetchParams,
AdminTicketListItem,
@@ -63,6 +70,7 @@ export function getTrafficRank(params: {
type: 'node' | 'user'
startTime: number
endTime: number
limit?: 10 | 20
}): Promise<TrafficRankResponse> {
return adminClient
.get<TrafficRankResponse>('/stat/getTrafficRank', {
@@ -70,6 +78,7 @@ export function getTrafficRank(params: {
type: params.type,
start_time: params.startTime,
end_time: params.endTime,
limit: params.limit,
},
})
.then((res) => res.data)
@@ -83,8 +92,79 @@ export function getQueueStats(): Promise<ApiResponse<QueueStats>> {
return unwrap<QueueStats>('/system/getQueueStats')
}
export function getPlans(): Promise<ApiResponse<AdminPlanOption[]>> {
return unwrap<AdminPlanOption[]>('/plan/fetch')
export function getHorizonFailedJobs(params: {
current?: number
pageSize?: number
} = {}): Promise<AdminQueueFailedJobResult> {
return adminClient
.get<AdminQueueFailedJobResult>('/system/getHorizonFailedJobs', {
params: {
current: params.current,
page_size: params.pageSize,
},
})
.then((res) => res.data)
}
export function getPlans(): Promise<ApiResponse<AdminPlanListItem[]>> {
return unwrap<AdminPlanListItem[]>('/plan/fetch')
}
export function fetchAdminConfig(key?: AdminConfigGroupKey): Promise<ApiResponse<AdminConfigMappings>> {
return unwrap<AdminConfigMappings>('/config/fetch', key ? { key } : undefined)
}
export function saveAdminConfig(payload: Record<string, unknown>): Promise<ApiResponse<boolean>> {
return unwrapPost<boolean>('/config/save', payload)
}
export function testAdminMail(): Promise<ApiResponse<Record<string, unknown>>> {
return unwrapPost<Record<string, unknown>>('/config/testSendMail', {})
}
export function setTelegramWebhook(payload: {
telegram_bot_token?: string
} = {}): Promise<ApiResponse<Record<string, unknown>>> {
return unwrapPost<Record<string, unknown>>('/config/setTelegramWebhook', payload)
}
export function savePlan(payload: AdminPlanSavePayload): Promise<ApiResponse<boolean>> {
return unwrapPost<boolean>('/plan/save', payload as unknown as Record<string, unknown>)
}
export function updatePlan(id: number, payload: Partial<Pick<AdminPlanListItem, 'show' | 'renew' | 'sell'>>): Promise<ApiResponse<boolean>> {
return unwrapPost<boolean>('/plan/update', {
id,
...payload,
})
}
export function deletePlan(id: number): Promise<ApiResponse<boolean>> {
return unwrapPost<boolean>('/plan/drop', { id })
}
export function sortPlans(ids: number[]): Promise<ApiResponse<boolean>> {
return unwrapPost<boolean>('/plan/sort', { ids })
}
export function getServerGroups(): Promise<ApiResponse<AdminServerGroupItem[]>> {
return unwrap<AdminServerGroupItem[]>('/server/group/fetch')
}
export function fetchNodes(): Promise<ApiResponse<AdminNodeItem[]>> {
return unwrap<AdminNodeItem[]>('/server/manage/getNodes')
}
export function updateNode(payload: AdminNodeUpdatePayload): Promise<ApiResponse<boolean>> {
return unwrapPost<boolean>('/server/manage/update', payload as unknown as Record<string, unknown>)
}
export function copyNode(id: number): Promise<ApiResponse<boolean>> {
return unwrapPost<boolean>('/server/manage/copy', { id })
}
export function deleteNode(id: number): Promise<ApiResponse<boolean>> {
return unwrapPost<boolean>('/server/manage/drop', { id })
}
export function fetchUsers(params: AdminUserFetchParams): Promise<AdminPaginationResult<AdminUserListItem>> {
+8
View File
@@ -0,0 +1,8 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
export default component
}
+126 -3
View File
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useAppStore } from '@/stores/app'
import {
Connection,
Odometer,
Tickets,
SwitchButton,
@@ -11,6 +12,19 @@ import {
Expand,
User,
UserFilled,
Lock,
Share,
ShoppingBag,
CollectionTag,
Document,
Discount,
Present,
Setting,
Box,
Brush,
Bell,
CreditCard,
Reading,
} from '@element-plus/icons-vue'
const route = useRoute()
@@ -23,15 +37,45 @@ const sidebarWidth = computed(() => app.sidebarCollapsed ? '72px' : '220px')
const currentTitle = computed(() => String(route.meta.title || '控制台'))
const currentKicker = computed(() => String(route.meta.kicker || 'Xboard Admin'))
const menuItems = [
type MenuItem = {
index: string
title: string
icon: unknown
disabled?: boolean
badge?: string
}
const menuItems: MenuItem[] = [
{ index: '/dashboard', title: '仪表盘', icon: Odometer },
]
const managementItems = [
const nodeManagementItems: MenuItem[] = [
{ index: '/nodes', title: '节点管理', icon: Connection },
{ index: '/node-groups', title: '权限组管理', icon: Lock },
{ index: '/node-routes', title: '路由管理', icon: Share },
]
const managementItems: MenuItem[] = [
{ index: '/users', title: '用户管理', icon: User },
{ index: '/tickets', title: '工单管理', icon: Tickets },
]
const subscriptionItems: MenuItem[] = [
{ index: '/subscriptions/plans', title: '套餐管理', icon: CollectionTag },
{ index: '/subscriptions/orders', title: '订单管理', icon: Document, disabled: true, badge: '即将开放' },
{ index: '/subscriptions/coupons', title: '优惠券管理', icon: Discount, disabled: true, badge: '即将开放' },
{ index: '/subscriptions/gift-cards', title: '礼品卡管理', icon: Present, disabled: true, badge: '即将开放' },
]
const systemManagementItems: MenuItem[] = [
{ index: '/system/config', title: '系统配置', icon: Setting },
{ index: '/system/plugins', title: '插件管理', icon: Box },
{ index: '/system/themes', title: '主题配置', icon: Brush },
{ index: '/system/notices', title: '公告管理', icon: Bell },
{ index: '/system/payments', title: '支付配置', icon: CreditCard },
{ index: '/system/knowledge', title: '知识库管理', icon: Reading },
]
function syncViewport() {
isMobile.value = window.innerWidth < 960
if (isMobile.value) {
@@ -74,7 +118,7 @@ onBeforeUnmount(() => {
<ElMenu
:default-active="route.path"
:default-openeds="['management']"
:default-openeds="['node-management', 'management', 'subscription', 'system-management']"
:collapse="app.sidebarCollapsed"
:collapse-transition="false"
router
@@ -90,6 +134,22 @@ onBeforeUnmount(() => {
<template #title>{{ item.title }}</template>
</ElMenuItem>
<ElSubMenu index="node-management">
<template #title>
<ElIcon><Connection /></ElIcon>
<span>节点管理</span>
</template>
<ElMenuItem
v-for="item in nodeManagementItems"
:key="item.index"
:index="item.index"
>
<ElIcon><component :is="item.icon" /></ElIcon>
<template #title>{{ item.title }}</template>
</ElMenuItem>
</ElSubMenu>
<ElSubMenu index="management">
<template #title>
<ElIcon><UserFilled /></ElIcon>
@@ -105,6 +165,44 @@ onBeforeUnmount(() => {
<template #title>{{ item.title }}</template>
</ElMenuItem>
</ElSubMenu>
<ElSubMenu index="subscription">
<template #title>
<ElIcon><ShoppingBag /></ElIcon>
<span>订阅管理</span>
</template>
<ElMenuItem
v-for="item in subscriptionItems"
:key="item.index"
:index="item.index"
:disabled="item.disabled"
>
<ElIcon><component :is="item.icon" /></ElIcon>
<template #title>
<span class="menu-title">{{ item.title }}</span>
<span v-if="item.badge && !app.sidebarCollapsed" class="menu-badge">
{{ item.badge }}
</span>
</template>
</ElMenuItem>
</ElSubMenu>
<ElSubMenu index="system-management">
<template #title>
<ElIcon><Setting /></ElIcon>
<span>系统管理</span>
</template>
<ElMenuItem
v-for="item in systemManagementItems"
:key="item.index"
:index="item.index"
>
<ElIcon><component :is="item.icon" /></ElIcon>
<template #title>{{ item.title }}</template>
</ElMenuItem>
</ElSubMenu>
</ElMenu>
</ElAside>
@@ -226,6 +324,31 @@ onBeforeUnmount(() => {
margin-left: 8px;
}
.admin-menu :deep(.el-menu-item.is-disabled) {
opacity: 0.72;
}
.menu-title {
display: inline-flex;
align-items: center;
}
.menu-badge {
margin-left: 8px;
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 8px;
border-radius: 999px;
background: rgba(0, 113, 227, 0.08);
color: #0071e3;
font-size: 11px;
}
.admin-menu :deep(.el-sub-menu.is-opened > .el-sub-menu__title) {
color: var(--xboard-text-strong);
}
.admin-stage {
background: #f5f5f7;
}
+60
View File
@@ -29,12 +29,72 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/users/UsersView.vue'),
meta: { title: '用户管理', kicker: 'Users' },
},
{
path: 'nodes',
name: 'Nodes',
component: () => import('@/views/nodes/NodesView.vue'),
meta: { title: '节点管理', kicker: 'Nodes' },
},
{
path: 'node-groups',
name: 'NodeGroups',
component: () => import('@/views/nodes/NodeGroupsView.vue'),
meta: { title: '权限组管理', kicker: 'Node Groups' },
},
{
path: 'node-routes',
name: 'NodeRoutes',
component: () => import('@/views/nodes/NodeRoutesView.vue'),
meta: { title: '路由管理', kicker: 'Node Routes' },
},
{
path: 'tickets',
name: 'Tickets',
component: () => import('@/views/tickets/TicketsView.vue'),
meta: { title: '工单管理', kicker: 'Tickets' },
},
{
path: 'subscriptions/plans',
name: 'SubscriptionPlans',
component: () => import('@/views/subscriptions/PlansView.vue'),
meta: { title: '订阅套餐', kicker: 'Plans' },
},
{
path: 'system/config',
name: 'SystemConfig',
component: () => import('@/views/system/SystemConfigView.vue'),
meta: { title: '系统配置', kicker: 'System Management' },
},
{
path: 'system/plugins',
name: 'SystemPlugins',
component: () => import('@/views/system/SystemPlaceholderView.vue'),
meta: { title: '插件管理', kicker: 'System Management' },
},
{
path: 'system/themes',
name: 'SystemThemes',
component: () => import('@/views/system/SystemPlaceholderView.vue'),
meta: { title: '主题配置', kicker: 'System Management' },
},
{
path: 'system/notices',
name: 'SystemNotices',
component: () => import('@/views/system/SystemPlaceholderView.vue'),
meta: { title: '公告管理', kicker: 'System Management' },
},
{
path: 'system/payments',
name: 'SystemPayments',
component: () => import('@/views/system/SystemPlaceholderView.vue'),
meta: { title: '支付配置', kicker: 'System Management' },
},
{
path: 'system/knowledge',
name: 'SystemKnowledge',
component: () => import('@/views/system/SystemPlaceholderView.vue'),
meta: { title: '知识库管理', kicker: 'System Management' },
},
],
},
]
+125
View File
@@ -116,6 +116,23 @@ export interface QueueStats {
wait?: QueueWaitEntry[]
}
export interface AdminQueueFailedJob {
id?: number | string | null
uuid?: string | null
name?: string | null
queue?: string | null
connection?: string | null
exception?: string | null
failed_at?: number | string | null
payload?: unknown
[key: string]: unknown
}
export interface AdminQueueFailedJobResult extends AdminPaginationResult<AdminQueueFailedJob> {
current?: number
page_size?: number
}
export interface AdminPaginationResult<T> {
data: T[]
total: number
@@ -126,6 +143,11 @@ export interface AdminGroupOption {
name: string
}
export interface AdminServerGroupItem extends AdminGroupOption {
users_count?: number
server_count?: number
}
export interface AdminPlanOption {
id: number
name: string
@@ -137,6 +159,65 @@ export interface AdminPlanOption {
group?: AdminGroupOption | null
}
export type AdminConfigGroupKey =
| 'invite'
| 'site'
| 'subscribe'
| 'frontend'
| 'server'
| 'email'
| 'telegram'
| 'app'
| 'safe'
| 'subscribe_template'
export type AdminConfigGroupValue = Record<string, unknown>
export type AdminConfigMappings = Partial<Record<AdminConfigGroupKey, AdminConfigGroupValue>>
export interface AdminPlanPriceMap {
monthly?: number | null
quarterly?: number | null
half_yearly?: number | null
yearly?: number | null
two_yearly?: number | null
three_yearly?: number | null
onetime?: number | null
reset_traffic?: number | null
[key: string]: number | null | undefined
}
export interface AdminPlanListItem extends AdminPlanOption {
show: boolean
renew: boolean
sell: boolean
prices?: AdminPlanPriceMap | null
tags?: string[] | null
content?: string | null
reset_traffic_method?: number | null
capacity_limit?: number | null
device_limit?: number | null
speed_limit?: number | null
sort: number
created_at: number
updated_at: number
}
export interface AdminPlanSavePayload {
id?: number
name: string
content?: string
reset_traffic_method?: number | null
transfer_enable: number
prices?: AdminPlanPriceMap
group_id?: number | null
speed_limit?: number | null
device_limit?: number | null
capacity_limit?: number | null
tags?: string[]
force_update?: boolean
}
export interface AdminUserRef {
id: number
email: string
@@ -284,6 +365,50 @@ export interface AdminTrafficLogResult extends AdminPaginationResult<AdminTraffi
summary: TrafficAmount
}
export interface AdminNodeParentRef {
id: number
name: string
}
export interface AdminNodeMetrics {
active_connections?: number
active_users?: number
kernel_status?: boolean
updated_at?: number
}
export interface AdminNodeItem {
id: number
name: string
type: string
host: string
port: number | string | null
server_port?: number | null
group_ids?: Array<number | string> | null
route_ids?: Array<number | string> | null
show: boolean
enabled?: boolean
parent_id?: number | null
rate?: number | null
sort?: number | null
online: number
online_conn: number
is_online: number
available_status: number
last_check_at?: number | null
last_push_at?: number | null
metrics?: AdminNodeMetrics | null
groups?: AdminServerGroupItem[]
parent?: AdminNodeParentRef | null
}
export interface AdminNodeUpdatePayload {
id: number
show?: boolean | number
enabled?: boolean
machine_id?: number | null
}
declare global {
interface Window {
settings?: {
+2
View File
@@ -22,6 +22,7 @@ declare module 'vue' {
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElHeader: typeof import('element-plus/es')['ElHeader']
@@ -36,6 +37,7 @@ declare module 'vue' {
ElProgress: typeof import('element-plus/es')['ElProgress']
ElSegmented: typeof import('element-plus/es')['ElSegmented']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
+18 -3
View File
@@ -36,6 +36,8 @@ export interface TrendChartModel {
labels: ChartLabelPoint[]
}
export type TrendMetric = 'amount' | 'count'
const TREND_WIDTH = 760
const TREND_HEIGHT = 260
const PADDING_X = 24
@@ -97,6 +99,10 @@ export function formatCompactNumber(value: number): string {
}).format(value || 0)
}
export function formatCountLabel(value: number): string {
return `${formatCompactNumber(Math.max(0, Math.round(value || 0)))}`
}
export function formatTraffic(bytes: number): string {
const value = Math.max(0, bytes || 0)
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
@@ -159,7 +165,10 @@ function getVisibleLabels(points: TrendChartPoint[]): ChartLabelPoint[] {
}))
}
export function buildTrendChart(points: OrderTrendPoint[]): TrendChartModel {
export function buildTrendChart(
points: OrderTrendPoint[],
options: { metric?: TrendMetric } = {},
): TrendChartModel {
if (!points.length) {
return {
path: '',
@@ -170,7 +179,11 @@ export function buildTrendChart(points: OrderTrendPoint[]): TrendChartModel {
}
}
const values = points.map((point) => Math.max(0, toNumber(point.paid_total)))
const metric = options.metric ?? 'amount'
const values = points.map((point) => {
const value = metric === 'count' ? point.paid_count : point.paid_total
return Math.max(0, toNumber(value))
})
const maxValue = Math.max(...values, 1)
const innerWidth = TREND_WIDTH - PADDING_X * 2
const innerHeight = TREND_HEIGHT - PADDING_TOP - PADDING_BOTTOM
@@ -197,7 +210,9 @@ export function buildTrendChart(points: OrderTrendPoint[]): TrendChartModel {
: ''
const gridLines = [1, 0.75, 0.5, 0.25, 0].map((ratio) => ({
label: formatCompactCurrency(maxValue * ratio),
label: metric === 'count'
? formatCountLabel(maxValue * ratio)
: formatCompactCurrency(maxValue * ratio),
y: PADDING_TOP + innerHeight - innerHeight * ratio,
}))
+151
View File
@@ -0,0 +1,151 @@
import type { AdminNodeItem } from '@/types/api'
export interface NodeStatusMeta {
label: string
dotClass: 'online' | 'pending' | 'offline' | 'disabled'
tagType: 'success' | 'warning' | 'danger' | 'info'
}
const NODE_TYPE_LABELS: Record<string, string> = {
shadowsocks: 'Shadowsocks',
trojan: 'Trojan',
vmess: 'VMess',
vless: 'VLESS',
hysteria: 'Hysteria 2',
tuic: 'TUIC',
anytls: 'AnyTLS',
socks: 'SOCKS',
http: 'HTTP',
naive: 'Naive',
mieru: 'Mieru',
}
function normalizeText(value: unknown): string {
return String(value ?? '').trim().toLowerCase()
}
export function getNodeTypeLabel(type: string): string {
const normalized = normalizeText(type)
return NODE_TYPE_LABELS[normalized] ?? String(type || '未知协议').toUpperCase()
}
export function getNodeStatusMeta(node: AdminNodeItem): NodeStatusMeta {
if (node.enabled === false) {
return {
label: '已停用',
dotClass: 'disabled',
tagType: 'info',
}
}
if (node.available_status === 2) {
return {
label: '在线',
dotClass: 'online',
tagType: 'success',
}
}
if (node.available_status === 1) {
return {
label: '待同步',
dotClass: 'pending',
tagType: 'warning',
}
}
return {
label: '离线',
dotClass: 'offline',
tagType: 'danger',
}
}
export function getNodeIdLabel(node: AdminNodeItem): string {
return node.parent_id ? `${node.id}${node.parent_id}` : String(node.id)
}
export function getNodeAddress(node: AdminNodeItem): { primary: string; secondary: string } {
const host = node.host || '--'
const publicPort = node.server_port ?? node.port ?? '--'
const innerPort = node.port ?? node.server_port ?? '--'
return {
primary: `${host}:${publicPort}`,
secondary: `内部端口 ${innerPort}`,
}
}
export function formatNodeRate(rate?: number | null): string {
const normalized = Number.isFinite(Number(rate)) ? Number(rate) : 1
return `${normalized.toFixed(2)} x`
}
export function getNodeGroupNames(node: AdminNodeItem): string[] {
return (node.groups ?? [])
.map((group) => group.name)
.filter(Boolean)
}
export function buildNodeTypeOptions(nodes: AdminNodeItem[]): Array<{ label: string; value: string }> {
const uniqueTypes = [...new Set(nodes.map((node) => normalizeText(node.type)).filter(Boolean))]
return uniqueTypes.map((value) => ({
value,
label: getNodeTypeLabel(value),
}))
}
function buildNodeSearchText(node: AdminNodeItem): string {
return [
node.id,
node.parent_id,
node.name,
node.host,
node.port,
node.server_port,
getNodeTypeLabel(node.type),
...getNodeGroupNames(node),
]
.map((item) => String(item ?? '').trim())
.filter(Boolean)
.join(' ')
.toLowerCase()
}
export function filterNodes(
nodes: AdminNodeItem[],
keyword: string,
typeFilter: string,
groupFilter: string,
): AdminNodeItem[] {
const normalizedKeyword = normalizeText(keyword)
const normalizedType = normalizeText(typeFilter)
const normalizedGroup = normalizeText(groupFilter)
return nodes.filter((node) => {
if (normalizedKeyword && !buildNodeSearchText(node).includes(normalizedKeyword)) {
return false
}
if (normalizedType !== '' && normalizedType !== 'all' && normalizeText(node.type) !== normalizedType) {
return false
}
if (normalizedGroup !== '' && normalizedGroup !== 'all') {
const belongsToGroup = (node.groups ?? []).some((group) => String(group.id) === normalizedGroup)
if (!belongsToGroup) {
return false
}
}
return true
})
}
export function countOnlineNodes(nodes: AdminNodeItem[]): number {
return nodes.filter((node) => getNodeStatusMeta(node).dotClass === 'online').length
}
export function countVisibleNodes(nodes: AdminNodeItem[]): number {
return nodes.filter((node) => Boolean(node.show)).length
}
+242
View File
@@ -0,0 +1,242 @@
import MarkdownIt from 'markdown-it'
import type { AdminPlanListItem, AdminPlanPriceMap, AdminPlanSavePayload } from '@/types/api'
export type PlanPricePeriod =
| 'monthly'
| 'quarterly'
| 'half_yearly'
| 'yearly'
| 'two_yearly'
| 'three_yearly'
| 'onetime'
| 'reset_traffic'
export type PlanResetMethodValue = number | 'follow'
export interface PlanPricePeriodOption {
key: PlanPricePeriod
label: string
badgeLabel: string
}
export interface PlanFormModel {
id?: number
name: string
tags: string[]
groupId: number | null
transferEnableGb: number | null
speedLimit: number | null
deviceLimit: number | null
capacityLimit: number | null
resetTrafficMethod: PlanResetMethodValue
prices: Record<PlanPricePeriod, string>
content: string
forceUpdate: boolean
}
const markdown = new MarkdownIt({
html: true,
breaks: true,
linkify: true,
})
export const PLAN_PRICE_PERIODS: PlanPricePeriodOption[] = [
{ key: 'monthly', label: '月付', badgeLabel: '月付' },
{ key: 'quarterly', label: '季付', badgeLabel: '季付' },
{ key: 'half_yearly', label: '半年付', badgeLabel: '半年付' },
{ key: 'yearly', label: '年付', badgeLabel: '年付' },
{ key: 'two_yearly', label: '两年付', badgeLabel: '两年付' },
{ key: 'three_yearly', label: '三年付', badgeLabel: '三年付' },
{ key: 'onetime', label: '流量包', badgeLabel: '流量包' },
{ key: 'reset_traffic', label: '重置包', badgeLabel: '重置包' },
]
export const RESET_TRAFFIC_METHOD_OPTIONS: Array<{ label: string; value: PlanResetMethodValue }> = [
{ label: '跟随系统设置', value: 'follow' },
{ label: '每月 1 号', value: 0 },
{ label: '按月重置', value: 1 },
{ label: '不重置', value: 2 },
{ label: '每年 1 月 1 日', value: 3 },
{ label: '按年重置', value: 4 },
]
export const DEFAULT_PLAN_DESCRIPTION_TEMPLATE = [
'- 节点覆盖多个国家与地区',
'- 包含家庭宽带与专线节点',
'- 少量用户无滥用,稳定高速',
'- 支持流媒体与下载场景',
'- 不限制使用人数',
'- 不限制到期时间',
'- 不限制网络速度',
].join('\n')
function createEmptyPriceMap(): Record<PlanPricePeriod, string> {
return PLAN_PRICE_PERIODS.reduce((acc, item) => {
acc[item.key] = ''
return acc
}, {} as Record<PlanPricePeriod, string>)
}
function trimTrailingZeros(value: number): string {
return value
.toFixed(2)
.replace(/\.00$/, '')
.replace(/(\.\d)0$/, '$1')
}
function normalizeNumericInput(value: string): string {
return value.replace(/[^\d.]/g, '').replace(/(\..*)\./g, '$1')
}
export function createEmptyPlanForm(): PlanFormModel {
return {
name: '',
tags: [],
groupId: null,
transferEnableGb: null,
speedLimit: null,
deviceLimit: null,
capacityLimit: null,
resetTrafficMethod: 'follow',
prices: createEmptyPriceMap(),
content: '',
forceUpdate: false,
}
}
export function toPlanFormModel(plan?: AdminPlanListItem | null): PlanFormModel {
const form = createEmptyPlanForm()
if (!plan) {
return form
}
const nextPrices = createEmptyPriceMap()
for (const option of PLAN_PRICE_PERIODS) {
const rawValue = plan.prices?.[option.key]
nextPrices[option.key] = rawValue ? trimTrailingZeros(Number(rawValue)) : ''
}
return {
id: plan.id,
name: plan.name || '',
tags: [...(plan.tags ?? [])],
groupId: plan.group_id ?? null,
transferEnableGb: Number(plan.transfer_enable) || null,
speedLimit: plan.speed_limit ?? null,
deviceLimit: plan.device_limit ?? null,
capacityLimit: plan.capacity_limit ?? null,
resetTrafficMethod: typeof plan.reset_traffic_method === 'number' ? plan.reset_traffic_method : 'follow',
prices: nextPrices,
content: plan.content || '',
forceUpdate: false,
}
}
export function sanitizePlanPriceInput(value: string): string {
return normalizeNumericInput(value)
}
export function normalizePlanTag(raw: string): string {
return raw.trim().replace(/\s+/g, ' ')
}
export function toPlanSavePayload(form: PlanFormModel): AdminPlanSavePayload {
const prices = PLAN_PRICE_PERIODS.reduce((acc, item) => {
const rawValue = form.prices[item.key]
if (!rawValue) {
return acc
}
const numeric = Number(rawValue)
if (Number.isFinite(numeric) && numeric > 0) {
acc[item.key] = Number(trimTrailingZeros(numeric))
}
return acc
}, {} as AdminPlanPriceMap)
return {
id: form.id,
name: form.name.trim(),
content: form.content.trim(),
transfer_enable: Math.max(1, Math.round(Number(form.transferEnableGb) || 0)),
prices,
group_id: form.groupId,
speed_limit: form.speedLimit ?? null,
device_limit: form.deviceLimit ?? null,
capacity_limit: form.capacityLimit ?? null,
reset_traffic_method: form.resetTrafficMethod === 'follow' ? null : form.resetTrafficMethod,
tags: form.tags,
force_update: form.forceUpdate,
}
}
export function renderPlanContent(source: string): string {
return markdown.render(source || '')
}
export function formatPlanPrice(value: number | null | undefined, suffix = ''): string {
const numeric = Number(value)
if (!Number.isFinite(numeric) || numeric <= 0) {
return '未设置'
}
return `¥${trimTrailingZeros(numeric)}${suffix}`
}
export function getPlanPriceBadges(plan: Pick<AdminPlanListItem, 'prices'>): Array<{ key: PlanPricePeriod; label: string }> {
return PLAN_PRICE_PERIODS
.filter((item) => Number(plan.prices?.[item.key] || 0) > 0)
.map((item) => ({
key: item.key,
label: item.key === 'reset_traffic'
? `${item.badgeLabel} ${formatPlanPrice(plan.prices?.[item.key], '/次')}`
: `${item.badgeLabel} ${formatPlanPrice(plan.prices?.[item.key])}`,
}))
}
export function formatPlanTraffic(plan: Pick<AdminPlanListItem, 'transfer_enable'>): string {
const value = Number(plan.transfer_enable)
if (!Number.isFinite(value) || value <= 0) {
return '未设流量'
}
return `${value} GB`
}
export function filterPlans(plans: AdminPlanListItem[], keyword: string): AdminPlanListItem[] {
const normalized = keyword.trim().toLowerCase()
if (!normalized) {
return plans
}
return plans.filter((plan) => {
const haystack = [
plan.id,
plan.name,
plan.group?.name,
plan.tags?.join(' '),
]
.filter(Boolean)
.join(' ')
.toLowerCase()
return haystack.includes(normalized)
})
}
export function countEnabledPlans(plans: AdminPlanListItem[], field: 'show' | 'sell' | 'renew'): number {
return plans.filter((plan) => Boolean(plan[field])).length
}
export function movePlanOrder(plans: AdminPlanListItem[], fromIndex: number, direction: -1 | 1): AdminPlanListItem[] {
const targetIndex = fromIndex + direction
if (targetIndex < 0 || targetIndex >= plans.length) {
return plans
}
const next = [...plans]
const [current] = next.splice(fromIndex, 1)
next.splice(targetIndex, 0, current)
return next
}
+401
View File
@@ -0,0 +1,401 @@
import type { AdminConfigMappings, AdminPlanListItem } from '@/types/api'
export type SystemConfigSectionKey =
| 'site'
| 'safe'
| 'subscribe'
| 'invite'
| 'server'
| 'email'
| 'telegram'
| 'app'
| 'subscribe_template'
export type SystemConfigFieldType =
| 'text'
| 'url'
| 'textarea'
| 'switch'
| 'number'
| 'select'
| 'password'
export type SystemConfigFieldValue = string | number | boolean | string[] | null
export type SystemConfigFormState = Record<string, SystemConfigFieldValue>
interface SystemConfigOption {
label: string
value: string | number
}
export interface SystemConfigFieldSchema {
key: string
label: string
type: SystemConfigFieldType
valueType?: 'string' | 'number'
placeholder?: string
helper?: string
fullWidth?: boolean
rows?: number
min?: number
max?: number
step?: number
defaultValue?: SystemConfigFieldValue
nullable?: boolean
multiple?: boolean
allowCreate?: boolean
preserveWhitespace?: boolean
options?: SystemConfigOption[]
optionSource?: 'plans'
}
export interface SystemConfigSectionSchema {
key: SystemConfigSectionKey
navLabel: string
title: string
description: string
fields: SystemConfigFieldSchema[]
}
const emailWhitelistOptions: SystemConfigOption[] = [
'gmail.com',
'qq.com',
'163.com',
'yahoo.com',
'sina.com',
'126.com',
'outlook.com',
'yeah.net',
'foxmail.com',
].map((value) => ({ label: value, value }))
const withdrawMethodOptions: SystemConfigOption[] = [
'支付宝',
'USDT',
'Paypal',
].map((value) => ({ label: value, value }))
const resetTrafficOptions: SystemConfigOption[] = [
{ label: '每月 1 号重置', value: 0 },
{ label: '按月重置', value: 1 },
{ label: '不重置', value: 2 },
{ label: '每年 1 月 1 日重置', value: 3 },
{ label: '按年重置', value: 4 },
]
const orderEventOptions: SystemConfigOption[] = [
{ label: '不执行额外事件', value: 0 },
{ label: '执行事件 1', value: 1 },
]
const deviceLimitOptions: SystemConfigOption[] = [
{ label: '按在线设备数统计', value: 0 },
{ label: '按去重 IP 统计', value: 1 },
]
const captchaOptions: SystemConfigOption[] = [
{ label: 'reCAPTCHA', value: 'recaptcha' },
{ label: 'Turnstile', value: 'turnstile' },
{ label: 'reCAPTCHA v3', value: 'recaptcha-v3' },
]
const emailEncryptionOptions: SystemConfigOption[] = [
{ label: '无加密', value: '' },
{ label: 'SSL', value: 'ssl' },
{ label: 'TLS', value: 'tls' },
]
export const systemConfigSections: SystemConfigSectionSchema[] = [
{
key: 'site',
navLabel: '站点设置',
title: '站点设置',
description: '配置站点基础信息,包括站点名称、地址、试用策略与货币展示。',
fields: [
{ key: 'app_name', label: '站点名称', type: 'text', placeholder: '请输入站点名称' },
{ key: 'app_description', label: '站点描述', type: 'textarea', fullWidth: true, rows: 3, placeholder: '用于首页与后台展示的站点描述' },
{ key: 'app_url', label: '站点网址', type: 'url', fullWidth: true, nullable: true, placeholder: 'https://example.com' },
{ key: 'force_https', label: '强制 HTTPS', type: 'switch', helper: '当站点已启用 HTTPS 或 CDN 回源使用 HTTPS 时建议开启。' },
{ key: 'logo', label: 'LOGO', type: 'url', fullWidth: true, nullable: true, placeholder: 'https://cdn.example.com/logo.png' },
{ key: 'subscribe_url', label: '订阅 URL', type: 'textarea', fullWidth: true, rows: 3, nullable: true, placeholder: '可填写一个或多个订阅入口地址' },
{ key: 'tos_url', label: '用户条款 (TOS) URL', type: 'url', fullWidth: true, nullable: true, placeholder: 'https://example.com/tos' },
{ key: 'stop_register', label: '停止新用户注册', type: 'switch' },
{ key: 'ticket_must_wait_reply', label: '工单等待回复限制', type: 'switch' },
{ key: 'try_out_plan_id', label: '注册试用套餐', type: 'select', optionSource: 'plans', valueType: 'number', defaultValue: 0, helper: '选择 0 表示关闭试用。' },
{ key: 'try_out_hour', label: '试用时长(小时)', type: 'number', min: 0, step: 1, valueType: 'number', defaultValue: 1 },
{ key: 'currency', label: '货币单位', type: 'text', placeholder: 'CNY' },
{ key: 'currency_symbol', label: '货币符号', type: 'text', placeholder: '¥' },
],
},
{
key: 'safe',
navLabel: '安全设置',
title: '安全设置',
description: '控制注册、验证码、后台路径与限流等安全相关策略。',
fields: [
{ key: 'email_verify', label: '开启邮箱验证', type: 'switch' },
{ key: 'safe_mode_enable', label: '开启安全模式', type: 'switch' },
{ key: 'secure_path', label: '后台安全路径', type: 'text', placeholder: '至少 8 位,仅字母数字和中划线' },
{ key: 'email_whitelist_enable', label: '启用邮箱白名单', type: 'switch' },
{ key: 'email_whitelist_suffix', label: '邮箱白名单后缀', type: 'select', multiple: true, allowCreate: true, fullWidth: true, options: emailWhitelistOptions, helper: '支持自定义后缀,回车即可添加。' },
{ key: 'email_gmail_limit_enable', label: '限制 Gmail 别名注册', type: 'switch' },
{ key: 'captcha_enable', label: '开启人机验证', type: 'switch' },
{ key: 'captcha_type', label: '人机验证类型', type: 'select', valueType: 'string', options: captchaOptions, defaultValue: 'recaptcha' },
{ key: 'recaptcha_key', label: 'reCAPTCHA Secret Key', type: 'text', nullable: true, fullWidth: true },
{ key: 'recaptcha_site_key', label: 'reCAPTCHA Site Key', type: 'text', nullable: true, fullWidth: true },
{ key: 'recaptcha_v3_secret_key', label: 'reCAPTCHA v3 Secret Key', type: 'text', nullable: true, fullWidth: true },
{ key: 'recaptcha_v3_site_key', label: 'reCAPTCHA v3 Site Key', type: 'text', nullable: true, fullWidth: true },
{ key: 'recaptcha_v3_score_threshold', label: 'reCAPTCHA v3 分数阈值', type: 'number', min: 0, max: 1, step: 0.1, valueType: 'number', defaultValue: 0.5 },
{ key: 'turnstile_secret_key', label: 'Turnstile Secret Key', type: 'text', nullable: true, fullWidth: true },
{ key: 'turnstile_site_key', label: 'Turnstile Site Key', type: 'text', nullable: true, fullWidth: true },
{ key: 'register_limit_by_ip_enable', label: '开启按 IP 限制注册', type: 'switch' },
{ key: 'register_limit_count', label: 'IP 注册次数限制', type: 'number', min: 0, step: 1, valueType: 'number', defaultValue: 3 },
{ key: 'register_limit_expire', label: 'IP 限制周期(分钟)', type: 'number', min: 0, step: 1, valueType: 'number', defaultValue: 60 },
{ key: 'password_limit_enable', label: '开启密码尝试限制', type: 'switch' },
{ key: 'password_limit_count', label: '密码尝试次数', type: 'number', min: 0, step: 1, valueType: 'number', defaultValue: 5 },
{ key: 'password_limit_expire', label: '密码限制周期(分钟)', type: 'number', min: 0, step: 1, valueType: 'number', defaultValue: 60 },
],
},
{
key: 'subscribe',
navLabel: '订阅设置',
title: '订阅设置',
description: '管理续费、流量重置、订单事件与订阅路径等全局订阅行为。',
fields: [
{ key: 'plan_change_enable', label: '允许变更订阅', type: 'switch' },
{ key: 'reset_traffic_method', label: '系统流量重置方式', type: 'select', valueType: 'number', options: resetTrafficOptions, defaultValue: 0 },
{ key: 'surplus_enable', label: '启用旧套餐折抵', type: 'switch' },
{ key: 'new_order_event_id', label: '新购订单事件', type: 'select', valueType: 'number', options: orderEventOptions, defaultValue: 0 },
{ key: 'renew_order_event_id', label: '续费订单事件', type: 'select', valueType: 'number', options: orderEventOptions, defaultValue: 0 },
{ key: 'change_order_event_id', label: '升级订单事件', type: 'select', valueType: 'number', options: orderEventOptions, defaultValue: 0 },
{ key: 'show_info_to_server_enable', label: '向节点展示用户信息', type: 'switch' },
{ key: 'show_protocol_to_server_enable', label: '向节点展示协议信息', type: 'switch' },
{ key: 'default_remind_expire', label: '默认开启到期提醒', type: 'switch', defaultValue: true },
{ key: 'default_remind_traffic', label: '默认开启流量提醒', type: 'switch', defaultValue: true },
{ key: 'subscribe_path', label: '订阅路径', type: 'text', placeholder: '例如 s' },
],
},
{
key: 'invite',
navLabel: '邀请&佣金设置',
title: '邀请 & 佣金设置',
description: '控制邀请注册、佣金比例与提现方式等分销策略。',
fields: [
{ key: 'invite_force', label: '强制填写邀请码', type: 'switch' },
{ key: 'invite_commission', label: '默认邀请佣金比例 (%)', type: 'number', min: 0, step: 1, valueType: 'number', defaultValue: 10 },
{ key: 'invite_gen_limit', label: '邀请码生成上限', type: 'number', min: 0, step: 1, valueType: 'number', defaultValue: 5 },
{ key: 'invite_never_expire', label: '邀请码永不过期', type: 'switch' },
{ key: 'commission_first_time_enable', label: '仅首单发放佣金', type: 'switch', defaultValue: true },
{ key: 'commission_auto_check_enable', label: '自动确认佣金', type: 'switch', defaultValue: true },
{ key: 'commission_withdraw_limit', label: '佣金提现门槛', type: 'number', min: 0, step: 1, valueType: 'number', nullable: true },
{ key: 'commission_withdraw_method', label: '佣金提现方式', type: 'select', multiple: true, allowCreate: true, fullWidth: true, options: withdrawMethodOptions },
{ key: 'withdraw_close_enable', label: '关闭佣金提现', type: 'switch' },
{ key: 'commission_distribution_enable', label: '开启三级分销', type: 'switch' },
{ key: 'commission_distribution_l1', label: '一级分销比例 (%)', type: 'number', min: 0, step: 1, valueType: 'number', nullable: true },
{ key: 'commission_distribution_l2', label: '二级分销比例 (%)', type: 'number', min: 0, step: 1, valueType: 'number', nullable: true },
{ key: 'commission_distribution_l3', label: '三级分销比例 (%)', type: 'number', min: 0, step: 1, valueType: 'number', nullable: true },
],
},
{
key: 'server',
navLabel: '节点配置',
title: '节点配置',
description: '管理面板与节点的通讯令牌、推拉频率与在线设备统计方式。',
fields: [
{ key: 'server_token', label: '通讯密钥', type: 'password', nullable: true, helper: '长度至少 16 位。' },
{ key: 'server_pull_interval', label: '拉取间隔(秒)', type: 'number', min: 1, step: 1, valueType: 'number', defaultValue: 60 },
{ key: 'server_push_interval', label: '推送间隔(秒)', type: 'number', min: 1, step: 1, valueType: 'number', defaultValue: 60 },
{ key: 'device_limit_mode', label: '设备数统计模式', type: 'select', valueType: 'number', options: deviceLimitOptions, defaultValue: 0 },
{ key: 'server_ws_enable', label: '启用节点 WebSocket', type: 'switch', defaultValue: true },
{ key: 'server_ws_url', label: '节点 WebSocket URL', type: 'url', fullWidth: true, nullable: true, placeholder: 'wss://example.com/ws' },
],
},
{
key: 'email',
navLabel: '邮件设置',
title: '邮件设置',
description: '配置 SMTP、发信地址与提醒邮件开关,支持保存后发送测试邮件。',
fields: [
{ key: 'email_host', label: 'SMTP Host', type: 'text', nullable: true, placeholder: 'smtp.example.com' },
{ key: 'email_port', label: 'SMTP Port', type: 'text', nullable: true, placeholder: '465' },
{ key: 'email_username', label: 'SMTP 用户名', type: 'text', nullable: true },
{ key: 'email_password', label: 'SMTP 密码', type: 'password', nullable: true },
{ key: 'email_encryption', label: '加密方式', type: 'select', valueType: 'string', options: emailEncryptionOptions, defaultValue: '' },
{ key: 'email_from_address', label: '发件人地址', type: 'text', nullable: true, placeholder: 'noreply@example.com' },
{ key: 'remind_mail_enable', label: '开启提醒邮件', type: 'switch' },
],
},
{
key: 'telegram',
navLabel: 'Telegram设置',
title: 'Telegram 设置',
description: '配置 Bot Token、Webhook 地址与讨论组链接,保存后可手动设置 Webhook。',
fields: [
{ key: 'telegram_bot_enable', label: '启用 Telegram Bot', type: 'switch' },
{ key: 'telegram_bot_token', label: 'Bot Token', type: 'password', nullable: true, fullWidth: true },
{ key: 'telegram_webhook_url', label: 'Webhook 基础地址', type: 'url', nullable: true, fullWidth: true, placeholder: 'https://example.com' },
{ key: 'telegram_discuss_link', label: '讨论组链接', type: 'url', nullable: true, fullWidth: true, placeholder: 'https://t.me/your-group' },
],
},
{
key: 'app',
navLabel: 'APP设置',
title: 'APP 设置',
description: '维护桌面端与移动端安装包版本、下载地址与更新展示信息。',
fields: [
{ key: 'windows_version', label: 'Windows 版本号', type: 'text', nullable: true },
{ key: 'windows_download_url', label: 'Windows 下载地址', type: 'url', nullable: true, fullWidth: true },
{ key: 'macos_version', label: 'macOS 版本号', type: 'text', nullable: true },
{ key: 'macos_download_url', label: 'macOS 下载地址', type: 'url', nullable: true, fullWidth: true },
{ key: 'android_version', label: 'Android 版本号', type: 'text', nullable: true },
{ key: 'android_download_url', label: 'Android 下载地址', type: 'url', nullable: true, fullWidth: true },
],
},
{
key: 'subscribe_template',
navLabel: '订阅模板',
title: '订阅模板',
description: '集中维护各客户端的订阅模板文本,保存后由后端按类型分发。',
fields: [
{ key: 'subscribe_template_singbox', label: 'Sing-box 模板', type: 'textarea', fullWidth: true, rows: 8, nullable: true, preserveWhitespace: true },
{ key: 'subscribe_template_clash', label: 'Clash 模板', type: 'textarea', fullWidth: true, rows: 8, nullable: true, preserveWhitespace: true },
{ key: 'subscribe_template_clashmeta', label: 'Clash Meta 模板', type: 'textarea', fullWidth: true, rows: 8, nullable: true, preserveWhitespace: true },
{ key: 'subscribe_template_stash', label: 'Stash 模板', type: 'textarea', fullWidth: true, rows: 8, nullable: true, preserveWhitespace: true },
{ key: 'subscribe_template_surge', label: 'Surge 模板', type: 'textarea', fullWidth: true, rows: 8, nullable: true, preserveWhitespace: true },
{ key: 'subscribe_template_surfboard', label: 'Surfboard 模板', type: 'textarea', fullWidth: true, rows: 8, nullable: true, preserveWhitespace: true },
],
},
]
function getDefaultValue(field: SystemConfigFieldSchema): SystemConfigFieldValue {
if (field.multiple) return []
if (field.type === 'switch') return Boolean(field.defaultValue ?? false)
if (field.type === 'number' || field.valueType === 'number') {
return field.defaultValue ?? (field.nullable ? null : 0)
}
return field.defaultValue ?? ''
}
function normalizeNumberValue(
value: unknown,
field: SystemConfigFieldSchema,
): number | null {
if (value === null || value === undefined || value === '') {
if (field.defaultValue !== undefined) {
return Number(field.defaultValue)
}
return field.nullable ? null : 0
}
const parsed = Number(value)
if (Number.isFinite(parsed)) return parsed
if (field.defaultValue !== undefined) return Number(field.defaultValue)
return field.nullable ? null : 0
}
function normalizeTextValue(value: unknown, field: SystemConfigFieldSchema): string | null {
if (value === null || value === undefined) {
return field.nullable ? null : String(field.defaultValue ?? '')
}
const normalized = String(value)
if (!normalized && field.nullable) return null
return normalized
}
export function createSystemConfigFormState(): SystemConfigFormState {
return systemConfigSections.reduce<SystemConfigFormState>((state, section) => {
section.fields.forEach((field) => {
state[field.key] = getDefaultValue(field)
})
return state
}, {})
}
export function normalizeSystemConfigMappings(config: AdminConfigMappings | null | undefined): SystemConfigFormState {
const state = createSystemConfigFormState()
systemConfigSections.forEach((section) => {
const group = config?.[section.key] ?? {}
section.fields.forEach((field) => {
const rawValue = group[field.key]
if (field.multiple) {
state[field.key] = Array.isArray(rawValue)
? rawValue.map((item) => String(item).trim()).filter(Boolean)
: []
return
}
if (field.type === 'switch') {
state[field.key] = Boolean(rawValue)
return
}
if (field.type === 'number' || field.valueType === 'number') {
state[field.key] = normalizeNumberValue(rawValue, field)
return
}
state[field.key] = normalizeTextValue(rawValue, field)
})
})
return state
}
function serializeTextValue(value: SystemConfigFieldValue, field: SystemConfigFieldSchema): string | null {
if (value === null || value === undefined) {
return field.nullable ? null : ''
}
const stringValue = typeof value === 'string' ? value : String(value)
const normalized = field.preserveWhitespace ? stringValue : stringValue.trim()
if (!normalized && field.nullable) return null
return normalized
}
export function serializeSystemConfigForm(form: SystemConfigFormState): Record<string, unknown> {
return systemConfigSections.reduce<Record<string, unknown>>((payload, section) => {
section.fields.forEach((field) => {
const value = form[field.key]
if (field.multiple) {
payload[field.key] = Array.isArray(value)
? value.map((item) => String(item).trim()).filter(Boolean)
: []
return
}
if (field.type === 'switch') {
payload[field.key] = Boolean(value)
return
}
if (field.type === 'number' || field.valueType === 'number') {
payload[field.key] = normalizeNumberValue(value, field)
return
}
payload[field.key] = serializeTextValue(value, field)
})
return payload
}, {})
}
export function getSystemConfigFieldOptions(
field: SystemConfigFieldSchema,
plans: AdminPlanListItem[],
): SystemConfigOption[] {
if (field.optionSource === 'plans') {
return [
{ label: '关闭试用', value: 0 },
...plans.map((plan) => ({
label: plan.name,
value: plan.id,
})),
]
}
return field.options ?? []
}
@@ -7,6 +7,7 @@ import {
DataAnalysis,
Discount,
Download,
RefreshRight,
Tickets,
Upload,
User,
@@ -29,6 +30,7 @@ import type {
} from '@/types/api'
import {
buildTrendChart,
formatCountLabel,
formatCompactNumber,
formatCurrency,
formatDateTime,
@@ -37,9 +39,11 @@ import {
getDateRangeFromPreset,
getQueueWaitName,
getQueueWaitSeconds,
type TrendMetric,
type TimePreset,
} from '@/utils/dashboard'
import { useAppStore } from '@/stores/app'
import QueueFailedJobsDialog from './QueueFailedJobsDialog.vue'
interface MetricCard {
key: string
@@ -56,7 +60,9 @@ const booting = ref(true)
const trendLoading = ref(false)
const rankLoading = ref(false)
const systemLoading = ref(false)
const lastRefreshedAt = ref<string | null>(null)
const trendPreset = ref<TimePreset>('30d')
const trendMetric = ref<TrendMetric>('amount')
const rankPreset = ref<TimePreset>('1d')
const overview = ref<DashboardStats | null>(null)
@@ -66,6 +72,7 @@ const nodeRanks = ref<TrafficRankItem[]>([])
const userRanks = ref<TrafficRankItem[]>([])
const systemStatus = ref<SystemStatus | null>(null)
const queueStats = ref<QueueStats | null>(null)
const failedJobsDialogVisible = ref(false)
const trendPresetOptions = [
{ label: '7天', value: '7d' },
@@ -73,12 +80,27 @@ const trendPresetOptions = [
{ label: '90天', value: '90d' },
] as const
const trendMetricOptions = [
{ label: '按金额', value: 'amount' },
{ label: '按数量', value: 'count' },
] as const
const rankPresetOptions = [
{ label: '24h', value: '1d' },
{ label: '7天', value: '7d' },
{ label: '30天', value: '30d' },
] as const
const rankDisplayOptions = [
{ label: '10个', value: 10 },
{ label: '20个', value: 20 },
] as const
type RankDisplayCount = (typeof rankDisplayOptions)[number]['value']
const nodeRankLimit = ref<RankDisplayCount>(10)
const userRankLimit = ref<RankDisplayCount>(10)
const dashboardStats = computed<DashboardStats>(() => overview.value ?? {
todayIncome: 0,
dayIncomeGrowth: 0,
@@ -195,7 +217,97 @@ const heroSummary = computed(() => [
},
])
const trendChart = computed(() => buildTrendChart(trendList.value))
const refreshButtonDisabled = computed(() => (
booting.value
|| trendLoading.value
|| rankLoading.value
|| systemLoading.value
))
const refreshStatusText = computed(() => {
if (booting.value) return '正在同步全部数据'
return '数据已同步'
})
const refreshStatusMeta = computed(() => {
if (booting.value) return '统计、趋势、排行与系统状态正在刷新'
if (!lastRefreshedAt.value) return '首次加载完成后可再次刷新'
return `上次刷新 ${formatDateTime(lastRefreshedAt.value)}`
})
const trendChart = computed(() => buildTrendChart(trendList.value, {
metric: trendMetric.value,
}))
const trendAverageCount = computed(() => {
if (!trendList.value.length) return 0
const total = trendList.value.reduce((sum, point) => sum + point.paid_count, 0)
return total / trendList.value.length
})
const trendPeakCount = computed(() => {
if (!trendList.value.length) return 0
return Math.max(...trendList.value.map((point) => point.paid_count))
})
const trendSummaryCards = computed(() => {
const summary = trendSummary.value
if (!summary) {
return trendMetric.value === 'count'
? [
{ label: '成交订单', value: formatCountLabel(0), detail: '总成交额 ¥0.00' },
{ label: '佣金订单', value: formatCountLabel(0), detail: '占成交 0.0%' },
{ label: '日均成交', value: formatCountLabel(0), detail: '峰值 0 笔' },
]
: [
{ label: '成交总额', value: formatCurrency(0), detail: '共 0 笔' },
{ label: '佣金支出', value: formatCurrency(0), detail: '佣金率 0.0%' },
{ label: '订单均价', value: formatCurrency(0), detail: '单笔均值' },
]
}
if (trendMetric.value === 'count') {
const commissionShare = summary.paid_count
? (summary.commission_count / summary.paid_count) * 100
: 0
return [
{
label: '成交订单',
value: formatCountLabel(summary.paid_count),
detail: `总成交额 ${formatCurrency(summary.paid_total)}`,
},
{
label: '佣金订单',
value: formatCountLabel(summary.commission_count),
detail: `占成交 ${formatPercent(commissionShare, false)}`,
},
{
label: '日均成交',
value: formatCountLabel(trendAverageCount.value),
detail: `峰值 ${formatCountLabel(trendPeakCount.value)}`,
},
]
}
return [
{
label: '成交总额',
value: formatCurrency(summary.paid_total),
detail: `${formatCompactNumber(summary.paid_count)}`,
},
{
label: '佣金支出',
value: formatCurrency(summary.commission_total),
detail: `佣金率 ${formatPercent(summary.commission_rate ?? 0, false)}`,
},
{
label: '订单均价',
value: formatCurrency(summary.avg_paid_amount),
detail: '单笔均值',
},
]
})
const latestTrendPoint = computed(() => {
if (!trendList.value.length) return null
@@ -203,12 +315,22 @@ const latestTrendPoint = computed(() => {
})
const trendSnapshot = computed(() => {
if (!latestTrendPoint.value) return null
const point = latestTrendPoint.value
if (!point) return null
return {
date: latestTrendPoint.value.date,
orderAmount: formatCurrency(latestTrendPoint.value.paid_total),
commissionAmount: formatCurrency(latestTrendPoint.value.commission_total),
orderCount: latestTrendPoint.value.paid_count,
date: point.date,
items: trendMetric.value === 'count'
? [
{ label: '成交订单', value: formatCountLabel(point.paid_count) },
{ label: '佣金订单', value: formatCountLabel(point.commission_count) },
{ label: '成交总额', value: formatCurrency(point.paid_total) },
]
: [
{ label: '收入', value: formatCurrency(point.paid_total) },
{ label: '佣金', value: formatCurrency(point.commission_total) },
{ label: '订单', value: formatCountLabel(point.paid_count) },
],
}
})
@@ -232,6 +354,9 @@ const queueHealthRows = computed(() => [
},
])
const displayedNodeRanks = computed(() => nodeRanks.value.slice(0, nodeRankLimit.value))
const displayedUserRanks = computed(() => userRanks.value.slice(0, userRankLimit.value))
const systemRows = computed(() => [
{
label: '调度器',
@@ -305,11 +430,13 @@ async function loadRankings() {
type: 'node',
startTime: range.startTime,
endTime: range.endTime,
limit: nodeRankLimit.value,
}),
getTrafficRank({
type: 'user',
startTime: range.startTime,
endTime: range.endTime,
limit: userRankLimit.value,
}),
])
@@ -320,35 +447,52 @@ async function loadRankings() {
}
}
async function refreshDashboard() {
async function refreshDashboard(options: { silentSuccess?: boolean } = {}) {
booting.value = true
const results = await Promise.allSettled([
loadOverviewPanels(),
loadTrend(),
loadRankings(),
])
try {
const results = await Promise.allSettled([
loadOverviewPanels(),
loadTrend(),
loadRankings(),
])
if (results.some((item) => item.status === 'rejected')) {
ElMessage.error('部分仪表盘数据加载失败,请稍后重试')
if (results.some((item) => item.status === 'rejected')) {
ElMessage.error('部分仪表盘数据加载失败,请稍后重试')
return
}
lastRefreshedAt.value = new Date().toISOString()
if (!options.silentSuccess) {
ElMessage.success('仪表盘数据已刷新')
}
} finally {
booting.value = false
}
}
booting.value = false
function handleRefresh() {
if (refreshButtonDisabled.value) return
void refreshDashboard()
}
function rankBarWidth(index: number): string {
return `${Math.max(28, 100 - index * 12)}%`
}
function rankScrollClass(limit: RankDisplayCount): string {
return limit === 20 ? 'rank-scroll rank-scroll--extended' : 'rank-scroll'
}
watch(trendPreset, () => {
void loadTrend().catch(() => ElMessage.error('趋势数据刷新失败'))
})
watch(rankPreset, () => {
watch([rankPreset, nodeRankLimit, userRankLimit], () => {
void loadRankings().catch(() => ElMessage.error('排行数据刷新失败'))
})
onMounted(() => {
void refreshDashboard()
void refreshDashboard({ silentSuccess: true })
})
</script>
@@ -365,8 +509,23 @@ onMounted(() => {
<div class="dashboard-hero-side">
<div class="hero-status">
<span>{{ booting ? '正在同步数据' : '数据已同步' }}</span>
<strong>/{{ app.securePath || 'admin' }}</strong>
<div class="hero-status__copy">
<span>{{ refreshStatusText }}</span>
<strong>/{{ app.securePath || 'admin' }}</strong>
<p>{{ refreshStatusMeta }}</p>
</div>
<button
type="button"
class="dashboard-refresh-button"
:disabled="refreshButtonDisabled"
@click="handleRefresh"
>
<ElIcon class="dashboard-refresh-button__icon" :class="{ spinning: booting }">
<RefreshRight />
</ElIcon>
<span>{{ booting ? '正在刷新全部数据' : '刷新全部数据' }}</span>
</button>
</div>
<div class="hero-highlights">
@@ -416,35 +575,46 @@ onMounted(() => {
</p>
</div>
<div class="filter-group">
<button
v-for="option in trendPresetOptions"
:key="option.value"
type="button"
class="filter-pill"
:class="{ active: option.value === trendPreset }"
@click="trendPreset = option.value"
>
{{ option.label }}
</button>
<div class="panel-actions">
<div class="filter-group filter-group--segmented" aria-label="趋势口径切换">
<button
v-for="option in trendMetricOptions"
:key="option.value"
type="button"
class="filter-pill"
:class="{ active: option.value === trendMetric }"
:aria-pressed="option.value === trendMetric"
@click="trendMetric = option.value"
>
{{ option.label }}
</button>
</div>
<div class="filter-group">
<button
v-for="option in trendPresetOptions"
:key="option.value"
type="button"
class="filter-pill"
:class="{ active: option.value === trendPreset }"
:aria-pressed="option.value === trendPreset"
@click="trendPreset = option.value"
>
{{ option.label }}
</button>
</div>
</div>
</header>
<div class="trend-summary">
<article class="trend-stat">
<span>成交总额</span>
<strong>{{ formatCurrency(trendSummary?.paid_total ?? 0) }}</strong>
<p> {{ formatCompactNumber(trendSummary?.paid_count ?? 0) }} </p>
</article>
<article class="trend-stat">
<span>佣金支出</span>
<strong>{{ formatCurrency(trendSummary?.commission_total ?? 0) }}</strong>
<p>佣金率 {{ formatPercent(trendSummary?.commission_rate ?? 0, false) }}</p>
</article>
<article class="trend-stat">
<span>订单均价</span>
<strong>{{ formatCurrency(trendSummary?.avg_paid_amount ?? 0) }}</strong>
<p>单笔均值</p>
<article
v-for="card in trendSummaryCards"
:key="card.label"
class="trend-stat"
>
<span>{{ card.label }}</span>
<strong>{{ card.value }}</strong>
<p>{{ card.detail }}</p>
</article>
</div>
@@ -490,17 +660,12 @@ onMounted(() => {
<span>最近记录</span>
<strong>{{ trendSnapshot.date }}</strong>
</div>
<div>
<span>收入</span>
<strong>{{ trendSnapshot.orderAmount }}</strong>
</div>
<div>
<span>佣金</span>
<strong>{{ trendSnapshot.commissionAmount }}</strong>
</div>
<div>
<span>订单</span>
<strong>{{ trendSnapshot.orderCount }} </strong>
<div
v-for="item in trendSnapshot.items"
:key="item.label"
>
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
</article>
@@ -514,6 +679,20 @@ onMounted(() => {
队列调度器和关键系统状态
</p>
</div>
<div class="panel-actions panel-actions--dark">
<button
type="button"
class="system-action-button"
aria-haspopup="dialog"
@click="failedJobsDialogVisible = true"
>
查看报错详情
</button>
<span class="system-panel__meta">
当前失败 {{ formatCompactNumber(queueStats?.failedJobs ?? 0) }}
</span>
</div>
</header>
<div class="panel-state panel-state--dark" v-if="systemLoading">系统状态同步中</div>
@@ -541,35 +720,62 @@ onMounted(() => {
<h2>节点流量排行</h2>
</div>
<div class="filter-group">
<button
v-for="option in rankPresetOptions"
:key="`node-${option.value}`"
type="button"
class="filter-pill"
:class="{ active: option.value === rankPreset }"
@click="rankPreset = option.value"
>
{{ option.label }}
</button>
<div class="panel-actions">
<div class="filter-group">
<button
v-for="option in rankPresetOptions"
:key="`node-${option.value}`"
type="button"
class="filter-pill"
:class="{ active: option.value === rankPreset }"
:aria-pressed="option.value === rankPreset"
@click="rankPreset = option.value"
>
{{ option.label }}
</button>
</div>
<div class="filter-group filter-group--segmented" aria-label="节点排行显示数量">
<button
v-for="option in rankDisplayOptions"
:key="`node-limit-${option.value}`"
type="button"
class="filter-pill"
:class="{ active: option.value === nodeRankLimit }"
:aria-pressed="option.value === nodeRankLimit"
@click="nodeRankLimit = option.value"
>
{{ option.label }}
</button>
</div>
</div>
</header>
<div class="panel-state" v-if="rankLoading">排行数据同步中</div>
<div v-if="nodeRanks.length" class="rank-list">
<div v-for="(item, index) in nodeRanks.slice(0, 6)" :key="item.id" class="rank-item">
<div class="rank-item__copy">
<strong>{{ item.name }}</strong>
<span>{{ formatTraffic(item.value) }}</span>
<div
v-else-if="nodeRanks.length"
:class="rankScrollClass(nodeRankLimit)"
>
<div class="rank-list">
<div
v-for="(item, index) in displayedNodeRanks"
:key="item.id"
class="rank-item"
>
<div class="rank-item__copy">
<strong>{{ item.name }}</strong>
<span>{{ formatTraffic(item.value) }}</span>
</div>
<div class="rank-item__bar">
<span :style="{ width: rankBarWidth(index) }" />
</div>
<em :class="Number(item.change) >= 0 ? 'positive' : 'negative'">
{{ formatPercent(item.change) }}
</em>
</div>
<div class="rank-item__bar">
<span :style="{ width: rankBarWidth(index) }" />
</div>
<em :class="Number(item.change) >= 0 ? 'positive' : 'negative'">
{{ formatPercent(item.change) }}
</em>
</div>
</div>
<div v-else class="panel-state">暂无节点排行数据</div>
</article>
<article class="panel rank-panel">
@@ -579,37 +785,66 @@ onMounted(() => {
<h2>用户流量排行</h2>
</div>
<div class="filter-group">
<button
v-for="option in rankPresetOptions"
:key="`user-${option.value}`"
type="button"
class="filter-pill"
:class="{ active: option.value === rankPreset }"
@click="rankPreset = option.value"
>
{{ option.label }}
</button>
<div class="panel-actions">
<div class="filter-group">
<button
v-for="option in rankPresetOptions"
:key="`user-${option.value}`"
type="button"
class="filter-pill"
:class="{ active: option.value === rankPreset }"
:aria-pressed="option.value === rankPreset"
@click="rankPreset = option.value"
>
{{ option.label }}
</button>
</div>
<div class="filter-group filter-group--segmented" aria-label="用户排行显示数量">
<button
v-for="option in rankDisplayOptions"
:key="`user-limit-${option.value}`"
type="button"
class="filter-pill"
:class="{ active: option.value === userRankLimit }"
:aria-pressed="option.value === userRankLimit"
@click="userRankLimit = option.value"
>
{{ option.label }}
</button>
</div>
</div>
</header>
<div class="panel-state" v-if="rankLoading">排行数据同步中</div>
<div v-if="userRanks.length" class="rank-list">
<div v-for="(item, index) in userRanks.slice(0, 6)" :key="item.id" class="rank-item">
<div class="rank-item__copy">
<strong>{{ item.name }}</strong>
<span>{{ formatTraffic(item.value) }}</span>
<div
v-else-if="userRanks.length"
:class="rankScrollClass(userRankLimit)"
>
<div class="rank-list">
<div
v-for="(item, index) in displayedUserRanks"
:key="item.id"
class="rank-item"
>
<div class="rank-item__copy">
<strong>{{ item.name }}</strong>
<span>{{ formatTraffic(item.value) }}</span>
</div>
<div class="rank-item__bar">
<span :style="{ width: rankBarWidth(index) }" />
</div>
<em :class="Number(item.change) >= 0 ? 'positive' : 'negative'">
{{ formatPercent(item.change) }}
</em>
</div>
<div class="rank-item__bar">
<span :style="{ width: rankBarWidth(index) }" />
</div>
<em :class="Number(item.change) >= 0 ? 'positive' : 'negative'">
{{ formatPercent(item.change) }}
</em>
</div>
</div>
<div v-else class="panel-state">暂无用户排行数据</div>
</article>
</section>
<QueueFailedJobsDialog v-model:visible="failedJobsDialogVisible" />
</div>
</template>
@@ -667,18 +902,68 @@ onMounted(() => {
.hero-status {
display: flex;
justify-content: space-between;
align-items: center;
align-items: flex-start;
gap: 16px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.72);
}
.hero-status__copy {
display: grid;
gap: 6px;
}
.hero-status strong {
color: #ffffff;
font-size: 14px;
font-weight: 600;
}
.hero-status__copy p {
margin: 0;
font-size: 13px;
color: rgba(255, 255, 255, 0.56);
}
.dashboard-refresh-button {
display: inline-flex;
align-items: center;
gap: 10px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
padding: 11px 18px;
min-height: 44px;
cursor: pointer;
transition: transform 180ms ease, background-color 180ms ease, border-color 180ms ease;
}
.dashboard-refresh-button:hover:not(:disabled) {
transform: translateY(-1px);
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.24);
}
.dashboard-refresh-button:focus-visible {
outline: 2px solid rgba(0, 113, 227, 0.88);
outline-offset: 2px;
}
.dashboard-refresh-button:disabled {
cursor: wait;
opacity: 0.78;
}
.dashboard-refresh-button__icon {
font-size: 15px;
}
.dashboard-refresh-button__icon.spinning {
animation: dashboard-refresh-spin 0.9s linear infinite;
}
.hero-highlights {
display: grid;
gap: 14px;
@@ -806,6 +1091,16 @@ onMounted(() => {
margin-bottom: 20px;
}
.panel-actions {
display: grid;
justify-items: end;
gap: 10px;
}
.panel-actions--dark {
align-items: end;
}
.panel-header h2 {
margin: 0;
font-size: 32px;
@@ -823,12 +1118,47 @@ onMounted(() => {
color: rgba(255, 255, 255, 0.72);
}
.system-action-button {
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
padding: 10px 16px;
cursor: pointer;
transition: background-color 0.18s ease, border-color 0.18s ease, transform 0.18s ease;
}
.system-action-button:hover {
background: rgba(255, 255, 255, 0.14);
border-color: rgba(255, 255, 255, 0.24);
}
.system-action-button:focus-visible {
outline: 2px solid rgba(41, 151, 255, 0.72);
outline-offset: 2px;
}
.system-action-button:active {
transform: translateY(1px);
}
.system-panel__meta {
color: rgba(255, 255, 255, 0.72);
font-size: 13px;
}
.filter-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.filter-group--segmented {
padding: 4px;
border-radius: 999px;
background: #f5f5f7;
}
.filter-pill {
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 999px;
@@ -836,6 +1166,7 @@ onMounted(() => {
padding: 10px 14px;
color: var(--xboard-text-secondary);
cursor: pointer;
transition: border-color 0.18s ease, background-color 0.18s ease, color 0.18s ease;
}
.filter-pill.active {
@@ -844,6 +1175,11 @@ onMounted(() => {
background: rgba(0, 113, 227, 0.08);
}
.filter-pill:focus-visible {
outline: 2px solid rgba(0, 113, 227, 0.36);
outline-offset: 2px;
}
.trend-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -928,6 +1264,34 @@ onMounted(() => {
gap: 14px;
}
.rank-scroll {
max-height: 368px;
overflow-y: auto;
padding-right: 6px;
margin-right: -6px;
scrollbar-gutter: stable;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgba(0, 113, 227, 0.22) transparent;
}
.rank-scroll--extended {
max-height: 516px;
}
.rank-scroll::-webkit-scrollbar {
width: 8px;
}
.rank-scroll::-webkit-scrollbar-track {
background: transparent;
}
.rank-scroll::-webkit-scrollbar-thumb {
border-radius: 999px;
background: rgba(0, 113, 227, 0.22);
}
.rank-item {
display: grid;
grid-template-columns: minmax(0, 1fr) 150px auto;
@@ -1048,6 +1412,25 @@ onMounted(() => {
padding: 28px 24px;
}
.hero-status,
.panel-actions {
width: 100%;
}
.hero-status {
flex-direction: column;
}
.dashboard-refresh-button {
width: 100%;
justify-content: center;
}
.rank-scroll,
.rank-scroll--extended {
max-height: 460px;
}
.metrics-grid,
.content-grid,
.rank-grid,
@@ -1061,4 +1444,14 @@ onMounted(() => {
grid-template-columns: 1fr;
}
}
@keyframes dashboard-refresh-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
@@ -0,0 +1,464 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { getHorizonFailedJobs } from '@/api/admin'
import type { AdminQueueFailedJob } from '@/types/api'
import { formatCompactNumber, formatDateTime } from '@/utils/dashboard'
const props = defineProps<{
visible: boolean
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
}>()
type LooseRecord = Record<string, unknown>
const loading = ref(false)
const records = ref<AdminQueueFailedJob[]>([])
const total = ref(0)
const current = ref(1)
const pageSize = ref(10)
const latestFailedJob = computed(() => records.value[0] ?? null)
const summaryCards = computed(() => [
{
label: '失败总数',
value: formatCompactNumber(total.value),
detail: `当前页 ${records.value.length}`,
},
{
label: '最近失败时间',
value: latestFailedJob.value ? formatFailedAt(latestFailedJob.value) : 'N/A',
detail: '按最新失败时间倒序展示',
},
{
label: '最近失败队列',
value: latestFailedJob.value ? getQueueName(latestFailedJob.value) : 'N/A',
detail: latestFailedJob.value ? getJobName(latestFailedJob.value) : '暂无失败作业',
},
])
function isLooseRecord(value: unknown): value is LooseRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function getByPath(source: LooseRecord | null, path: string): unknown {
if (!source) return undefined
return path.split('.').reduce<unknown>((current, segment) => {
if (!isLooseRecord(current)) return undefined
return current[segment]
}, source)
}
function firstText(...values: unknown[]): string | null {
for (const value of values) {
if (typeof value === 'string' && value.trim()) {
return value.trim()
}
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value)
}
}
return null
}
function getPayload(record: AdminQueueFailedJob): LooseRecord | null {
if (isLooseRecord(record.payload)) {
return record.payload
}
if (typeof record.payload === 'string' && record.payload.trim()) {
try {
const parsed = JSON.parse(record.payload)
return isLooseRecord(parsed) ? parsed : null
} catch {
return null
}
}
return null
}
function getIdentifier(record: AdminQueueFailedJob): string {
return firstText(record.id, record.uuid) ?? 'unknown'
}
function getJobName(record: AdminQueueFailedJob): string {
const payload = getPayload(record)
return firstText(
record.name,
getByPath(payload, 'displayName'),
getByPath(payload, 'data.commandName'),
getByPath(payload, 'job'),
record.uuid,
record.id,
) ?? '未知任务'
}
function getQueueName(record: AdminQueueFailedJob): string {
const payload = getPayload(record)
return firstText(
record.queue,
getByPath(payload, 'queue'),
record.connection,
) ?? 'N/A'
}
function getFailureTime(record: AdminQueueFailedJob): number | string | null {
const payload = getPayload(record)
return (
firstText(
record.failed_at,
record['failedAt'],
getByPath(payload, 'failed_at'),
record['completed_at'],
record['completedAt'],
) ?? null
)
}
function formatFailedAt(record: AdminQueueFailedJob): string {
return formatDateTime(getFailureTime(record))
}
function getErrorMessage(record: AdminQueueFailedJob): string {
const payload = getPayload(record)
return firstText(
record.exception,
record['message'],
getByPath(payload, 'exception'),
getByPath(payload, 'message'),
) ?? '暂无错误详情'
}
function getErrorSummary(record: AdminQueueFailedJob): string {
const rawMessage = getErrorMessage(record)
const lines = rawMessage
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
const firstLine = lines[0] ?? rawMessage
return firstLine.length > 180
? `${firstLine.slice(0, 177)}`
: firstLine
}
async function loadRecords() {
if (!props.visible) {
records.value = []
total.value = 0
return
}
loading.value = true
try {
const response = await getHorizonFailedJobs({
current: current.value,
pageSize: pageSize.value,
})
records.value = response.data ?? []
total.value = response.total ?? 0
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '失败作业列表加载失败')
} finally {
loading.value = false
}
}
function handleRefresh() {
if (current.value !== 1) {
current.value = 1
return
}
void loadRecords()
}
function closeDialog() {
emit('update:visible', false)
}
watch(
() => props.visible,
async (visible) => {
if (!visible) {
records.value = []
total.value = 0
return
}
current.value = 1
await loadRecords()
},
{ immediate: true },
)
watch([current, pageSize], () => {
if (!props.visible) {
return
}
void loadRecords()
})
</script>
<template>
<ElDialog
:model-value="props.visible"
width="860px"
class="queue-failed-jobs-dialog"
append-to-body
destroy-on-close
@close="closeDialog"
@update:model-value="emit('update:visible', $event)"
>
<template #header>
<div class="dialog-header">
<div>
<p>Queue Failures</p>
<h2>失败作业报错详情</h2>
</div>
<span> {{ total }} 条失败作业</span>
</div>
</template>
<div class="dialog-body">
<div class="dialog-toolbar">
<p>集中查看 Horizon 失败作业的报错摘要失败时间与队列信息</p>
<ElButton text class="ghost-action" :loading="loading" @click="handleRefresh">
重新加载
</ElButton>
</div>
<div class="summary-grid">
<article v-for="item in summaryCards" :key="item.label">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
<p>{{ item.detail }}</p>
</article>
</div>
<div v-if="!loading && !records.length" class="empty-shell">
<ElEmpty description="当前没有失败作业" />
</div>
<div v-else class="error-list" v-loading="loading">
<article
v-for="(record, index) in records"
:key="`${getIdentifier(record)}-${getFailureTime(record) ?? index}`"
class="error-card"
>
<div class="error-card__header">
<div>
<p>{{ getJobName(record) }}</p>
<span>#{{ getIdentifier(record) }}</span>
</div>
<strong>{{ getQueueName(record) }}</strong>
</div>
<div class="error-card__meta">
<span>失败时间</span>
<strong>{{ formatFailedAt(record) }}</strong>
<span>报错摘要</span>
<strong class="error-card__summary" :title="getErrorMessage(record)">
{{ getErrorSummary(record) }}
</strong>
</div>
</article>
</div>
<footer class="dialog-footer">
<span>当前第 {{ current }} 每页 {{ pageSize }} </span>
<ElPagination
v-model:current-page="current"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
:total="total"
background
/>
</footer>
</div>
</ElDialog>
</template>
<style scoped>
.dialog-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
}
.dialog-header p {
font-size: 11px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--xboard-text-muted);
}
.dialog-header h2 {
font-size: 30px;
line-height: 1.08;
color: var(--xboard-text-strong);
}
.dialog-header span {
color: var(--xboard-text-secondary);
}
.dialog-body {
display: grid;
gap: 16px;
}
.dialog-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.dialog-toolbar p {
margin: 0;
color: var(--xboard-text-secondary);
}
.ghost-action {
color: #0071e3;
padding-inline: 0;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.summary-grid article {
display: grid;
gap: 6px;
padding: 16px 18px;
border-radius: 16px;
background: #f5f5f7;
}
.summary-grid span,
.summary-grid p {
margin: 0;
color: var(--xboard-text-muted);
}
.summary-grid strong {
color: var(--xboard-text-strong);
font-size: 22px;
line-height: 1.14;
}
.error-list {
display: grid;
gap: 12px;
min-height: 120px;
}
.error-card {
display: grid;
gap: 12px;
padding: 18px 20px;
border-radius: 18px;
background: #fbfbfd;
border: 1px solid rgba(0, 0, 0, 0.04);
}
.error-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.error-card__header p,
.error-card__header span,
.error-card__meta span {
margin: 0;
}
.error-card__header p {
color: var(--xboard-text-strong);
font-size: 18px;
line-height: 1.24;
}
.error-card__header span {
color: var(--xboard-text-muted);
font-size: 12px;
}
.error-card__header strong,
.error-card__meta strong {
color: var(--xboard-text-strong);
}
.error-card__meta {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 8px 14px;
}
.error-card__meta span {
color: var(--xboard-text-muted);
font-size: 12px;
}
.error-card__summary {
line-height: 1.5;
color: #b42318;
word-break: break-word;
}
.empty-shell {
padding: 24px 0;
border-radius: 18px;
background: #fbfbfd;
}
.dialog-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.dialog-footer span {
color: var(--xboard-text-muted);
}
@media (max-width: 767px) {
.dialog-header,
.dialog-toolbar,
.error-card__header,
.dialog-footer {
flex-direction: column;
align-items: flex-start;
}
.summary-grid {
grid-template-columns: 1fr;
}
.error-card__meta {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,100 @@
<script setup lang="ts">
const milestones = [
'接入权限组列表与用户 / 节点引用统计',
'补齐新增、编辑、删除与使用冲突提示',
'联动节点页的权限组筛选与维护闭环',
]
</script>
<template>
<div class="placeholder-page">
<section class="placeholder-hero">
<div class="placeholder-copy">
<p class="placeholder-kicker">Node Groups</p>
<h1>权限组管理</h1>
<span>入口已预留本轮先完成节点列表主链路下一阶段继续接入权限组的真实维护能力</span>
</div>
</section>
<section class="placeholder-card">
<header>
<h2>下一阶段计划</h2>
<p>这一页不会空着结束而是明确告诉你后续要接什么</p>
</header>
<ol>
<li v-for="item in milestones" :key="item">{{ item }}</li>
</ol>
</section>
</div>
</template>
<style scoped>
.placeholder-page {
display: grid;
gap: 24px;
}
.placeholder-hero {
padding: 30px 32px;
border-radius: 28px;
background: #000000;
}
.placeholder-copy {
display: grid;
gap: 10px;
max-width: 720px;
}
.placeholder-kicker {
font-size: 11px;
letter-spacing: 0.24em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.68);
}
.placeholder-copy h1 {
font-size: clamp(34px, 5vw, 48px);
line-height: 1.08;
letter-spacing: -0.28px;
color: #ffffff;
}
.placeholder-copy span {
color: rgba(255, 255, 255, 0.72);
line-height: 1.47;
}
.placeholder-card {
display: grid;
gap: 18px;
padding: 28px;
border-radius: 24px;
background: #ffffff;
box-shadow: var(--xboard-shadow);
}
.placeholder-card header {
display: grid;
gap: 8px;
}
.placeholder-card h2 {
font-size: 28px;
line-height: 1.1;
color: var(--xboard-text-strong);
}
.placeholder-card p,
.placeholder-card li {
color: var(--xboard-text-secondary);
line-height: 1.6;
}
.placeholder-card ol {
display: grid;
gap: 12px;
padding-left: 20px;
}
</style>
@@ -0,0 +1,100 @@
<script setup lang="ts">
const milestones = [
'接入路由规则列表、动作类型与备注字段',
'补齐新增 / 编辑 / 删除路由的操作台',
'与节点页建立路由引用可视化关系,方便运营判断影响面',
]
</script>
<template>
<div class="placeholder-page">
<section class="placeholder-hero">
<div class="placeholder-copy">
<p class="placeholder-kicker">Node Routes</p>
<h1>路由管理</h1>
<span>侧边栏入口已对齐下一阶段将继续补齐路由规则列表与节点引用关系</span>
</div>
</section>
<section class="placeholder-card">
<header>
<h2>接下来会补什么</h2>
<p>本轮先把节点管理主链路落稳路由管理不留空白先把后续接入方向固定下来</p>
</header>
<ol>
<li v-for="item in milestones" :key="item">{{ item }}</li>
</ol>
</section>
</div>
</template>
<style scoped>
.placeholder-page {
display: grid;
gap: 24px;
}
.placeholder-hero {
padding: 30px 32px;
border-radius: 28px;
background: #000000;
}
.placeholder-copy {
display: grid;
gap: 10px;
max-width: 720px;
}
.placeholder-kicker {
font-size: 11px;
letter-spacing: 0.24em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.68);
}
.placeholder-copy h1 {
font-size: clamp(34px, 5vw, 48px);
line-height: 1.08;
letter-spacing: -0.28px;
color: #ffffff;
}
.placeholder-copy span {
color: rgba(255, 255, 255, 0.72);
line-height: 1.47;
}
.placeholder-card {
display: grid;
gap: 18px;
padding: 28px;
border-radius: 24px;
background: #ffffff;
box-shadow: var(--xboard-shadow);
}
.placeholder-card header {
display: grid;
gap: 8px;
}
.placeholder-card h2 {
font-size: 28px;
line-height: 1.1;
color: var(--xboard-text-strong);
}
.placeholder-card p,
.placeholder-card li {
color: var(--xboard-text-secondary);
line-height: 1.6;
}
.placeholder-card ol {
display: grid;
gap: 12px;
padding-left: 20px;
}
</style>
@@ -0,0 +1,628 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Connection,
MoreFilled,
Plus,
RefreshRight,
Search,
User,
} from '@element-plus/icons-vue'
import {
copyNode,
deleteNode,
fetchNodes,
getServerGroups,
updateNode,
} from '@/api/admin'
import type { AdminNodeItem, AdminServerGroupItem } from '@/types/api'
import {
buildNodeTypeOptions,
countOnlineNodes,
countVisibleNodes,
filterNodes,
formatNodeRate,
getNodeAddress,
getNodeGroupNames,
getNodeIdLabel,
getNodeStatusMeta,
getNodeTypeLabel,
} from '@/utils/nodes'
type NodeAction = 'edit' | 'copy' | 'delete'
const loading = ref(false)
const errorMessage = ref('')
const nodes = ref<AdminNodeItem[]>([])
const groups = ref<AdminServerGroupItem[]>([])
const keyword = ref('')
const typeFilter = ref('all')
const groupFilter = ref('all')
const switchingIds = ref<number[]>([])
const workingIds = ref<number[]>([])
const filteredNodes = computed(() => filterNodes(
nodes.value,
keyword.value,
typeFilter.value,
groupFilter.value,
))
const typeOptions = computed(() => buildNodeTypeOptions(nodes.value))
const hasActiveFilters = computed(() => keyword.value !== '' || typeFilter.value !== 'all' || groupFilter.value !== 'all')
const summaryCards = computed(() => [
{ label: '节点总数', value: String(nodes.value.length) },
{ label: '在线节点', value: String(countOnlineNodes(nodes.value)) },
{ label: '显示中', value: String(countVisibleNodes(nodes.value)) },
{ label: '当前结果', value: String(filteredNodes.value.length) },
])
function markPending(list: typeof switchingIds, id: number, pending: boolean) {
if (pending) {
if (!list.value.includes(id)) {
list.value = [...list.value, id]
}
return
}
list.value = list.value.filter((item) => item !== id)
}
function isSwitching(id: number): boolean {
return switchingIds.value.includes(id)
}
function isWorking(id: number): boolean {
return workingIds.value.includes(id)
}
function notifyPending(scope: string) {
ElMessage.info(`${scope} 会在下一阶段接入,本轮已先打通节点列表主链路。`)
}
async function loadNodeBoard() {
loading.value = true
errorMessage.value = ''
try {
const [nodesResponse, groupsResponse] = await Promise.all([
fetchNodes(),
getServerGroups(),
])
nodes.value = nodesResponse.data ?? []
groups.value = groupsResponse.data ?? []
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '节点数据加载失败'
} finally {
loading.value = false
}
}
function handleReset() {
keyword.value = ''
typeFilter.value = 'all'
groupFilter.value = 'all'
}
async function handleToggleShow(node: AdminNodeItem, nextValue: boolean) {
const previous = Boolean(node.show)
if (previous === nextValue) {
return
}
node.show = nextValue
markPending(switchingIds, node.id, true)
try {
await updateNode({
id: node.id,
show: nextValue ? 1 : 0,
})
ElMessage.success(nextValue ? '节点已显示' : '节点已隐藏')
} catch (error) {
node.show = previous
ElMessage.error(error instanceof Error ? error.message : '显隐状态更新失败')
} finally {
markPending(switchingIds, node.id, false)
}
}
async function handleAction(action: NodeAction, node: AdminNodeItem) {
if (action === 'edit') {
notifyPending(`编辑节点 #${node.id}`)
return
}
markPending(workingIds, node.id, true)
try {
if (action === 'copy') {
await copyNode(node.id)
ElMessage.success('节点已复制')
await loadNodeBoard()
return
}
await ElMessageBox.confirm(
`删除节点 “${node.name}” 后无法恢复,确认继续吗?`,
'删除节点',
{ type: 'warning' },
)
await deleteNode(node.id)
ElMessage.success('节点已删除')
await loadNodeBoard()
} catch (error) {
if (action === 'delete' && (error === 'cancel' || error === 'close')) {
return
}
ElMessage.error(error instanceof Error ? error.message : '节点操作失败')
} finally {
markPending(workingIds, node.id, false)
}
}
onMounted(() => {
void loadNodeBoard()
})
</script>
<template>
<div class="nodes-page">
<section class="nodes-hero">
<div class="nodes-copy">
<p class="nodes-kicker">Nodes</p>
<h1>节点管理</h1>
<span>
管理所有节点包括添加筛选显隐控制复制和删除等首批运营动作
</span>
</div>
<div class="hero-stats">
<article v-for="item in summaryCards" :key="item.label">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</article>
</div>
</section>
<section class="nodes-board">
<header class="board-toolbar">
<div class="toolbar-fields">
<ElButton type="primary" @click="notifyPending('添加节点')">
<ElIcon><Plus /></ElIcon>
添加节点
</ElButton>
<ElInput
v-model="keyword"
clearable
placeholder="搜索节点..."
class="toolbar-input"
>
<template #prefix>
<ElIcon><Search /></ElIcon>
</template>
</ElInput>
<ElSelect v-model="typeFilter" class="toolbar-select" placeholder="类型">
<ElOption label="全部类型" value="all" />
<ElOption
v-for="option in typeOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
<ElSelect v-model="groupFilter" class="toolbar-select" placeholder="权限组">
<ElOption label="全部权限组" value="all" />
<ElOption
v-for="group in groups"
:key="group.id"
:label="group.name"
:value="String(group.id)"
/>
</ElSelect>
</div>
<div class="toolbar-actions">
<ElButton @click="handleReset" :disabled="!hasActiveFilters">
<ElIcon><RefreshRight /></ElIcon>
重置筛选
</ElButton>
<ElButton @click="notifyPending('编辑排序')">编辑排序</ElButton>
</div>
</header>
<ElAlert
v-if="errorMessage"
type="error"
show-icon
:closable="false"
class="board-alert"
:title="errorMessage"
>
<template #default>
<ElButton text @click="loadNodeBoard">重新加载</ElButton>
</template>
</ElAlert>
<ElTable
:data="filteredNodes"
v-loading="loading"
row-key="id"
class="nodes-table"
>
<ElTableColumn label="节点ID" width="132">
<template #default="{ row }">
<ElTag
round
effect="plain"
:type="row.parent_id ? 'warning' : 'success'"
class="id-tag"
>
{{ getNodeIdLabel(row) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="显隐" width="96">
<template #default="{ row }">
<div
class="switch-shell"
:style="{ '--node-switch-color': row.parent_id ? '#7c5cff' : '#22c55e' }"
>
<ElSwitch
:model-value="Boolean(row.show)"
:loading="isSwitching(row.id)"
@change="(value) => handleToggleShow(row, Boolean(value))"
/>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="节点" min-width="280">
<template #default="{ row }">
<div class="node-cell">
<div class="node-cell__main">
<span class="node-dot" :class="getNodeStatusMeta(row).dotClass" />
<strong>{{ row.name }}</strong>
</div>
<div class="node-cell__sub">
<ElTag round effect="plain" :type="getNodeStatusMeta(row).tagType">
{{ getNodeStatusMeta(row).label }}
</ElTag>
<span>{{ getNodeTypeLabel(row.type) }}</span>
</div>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="地址" min-width="260">
<template #default="{ row }">
<div class="stack-cell">
<strong>{{ getNodeAddress(row).primary }}</strong>
<span>{{ getNodeAddress(row).secondary }}</span>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="在线人数" width="116">
<template #default="{ row }">
<div class="online-cell">
<span class="online-cell__primary">
<ElIcon><User /></ElIcon>
{{ row.online }}
</span>
<span>连接 {{ row.online_conn }}</span>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="倍率" width="96">
<template #default="{ row }">
<ElTag round effect="plain" class="rate-tag">
{{ formatNodeRate(row.rate) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="权限组" min-width="180">
<template #default="{ row }">
<div class="group-tags">
<ElTag
v-for="groupName in getNodeGroupNames(row)"
:key="`${row.id}-${groupName}`"
round
effect="plain"
>
{{ groupName }}
</ElTag>
<span v-if="getNodeGroupNames(row).length === 0" class="muted-copy">未分配</span>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="92" fixed="right">
<template #default="{ row }">
<ElDropdown
trigger="click"
@command="(command) => handleAction(command as NodeAction, row)"
>
<ElButton
text
class="action-trigger"
:loading="isWorking(row.id)"
>
<ElIcon><MoreFilled /></ElIcon>
</ElButton>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem command="edit">编辑节点下一阶段</ElDropdownItem>
<ElDropdownItem command="copy">复制节点</ElDropdownItem>
<ElDropdownItem command="delete" divided>删除节点</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</template>
</ElTableColumn>
<template #empty>
<div class="table-empty">
<ElEmpty
:description="hasActiveFilters ? '当前筛选条件下暂无节点。' : '暂无节点数据。'"
>
<ElButton v-if="hasActiveFilters" @click="handleReset">清空筛选</ElButton>
<ElButton v-else @click="loadNodeBoard">
<ElIcon><RefreshRight /></ElIcon>
重新加载
</ElButton>
</ElEmpty>
</div>
</template>
</ElTable>
<footer class="board-footer">
<span>已显示 {{ filteredNodes.length }} / {{ nodes.length }} 个节点</span>
<div class="footer-hint">
<ElIcon><Connection /></ElIcon>
<span>完整的节点创建编辑与排序流程将在下一阶段补齐</span>
</div>
</footer>
</section>
</div>
</template>
<style scoped>
.nodes-page {
display: grid;
gap: 24px;
}
.nodes-hero {
display: flex;
justify-content: space-between;
gap: 24px;
padding: 30px 32px;
border-radius: 28px;
background: #000000;
}
.nodes-copy {
display: grid;
gap: 10px;
max-width: 680px;
}
.nodes-kicker {
font-size: 11px;
letter-spacing: 0.24em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.68);
}
.nodes-copy h1 {
font-size: clamp(36px, 5vw, 52px);
line-height: 1.08;
letter-spacing: -0.28px;
color: #ffffff;
}
.nodes-copy span {
color: rgba(255, 255, 255, 0.72);
line-height: 1.47;
}
.hero-stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
min-width: 320px;
}
.hero-stats article {
display: grid;
gap: 6px;
padding: 18px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.08);
}
.hero-stats span {
color: rgba(255, 255, 255, 0.64);
font-size: 12px;
}
.hero-stats strong {
color: #ffffff;
font-size: 22px;
}
.nodes-board {
display: grid;
gap: 18px;
padding: 24px;
border-radius: 26px;
background: #ffffff;
box-shadow: var(--xboard-shadow);
}
.board-toolbar,
.toolbar-fields,
.toolbar-actions,
.board-footer,
.footer-hint {
display: flex;
align-items: center;
gap: 12px;
}
.board-toolbar,
.board-footer {
justify-content: space-between;
}
.toolbar-fields {
flex: 1;
flex-wrap: wrap;
}
.toolbar-input {
width: min(320px, 100%);
}
.toolbar-select {
width: 150px;
}
.board-alert {
border-radius: 16px;
}
.nodes-table :deep(th.el-table__cell) {
color: var(--xboard-text-secondary);
background: #fbfbfd;
}
.nodes-table :deep(.el-table__row td.el-table__cell) {
padding-top: 16px;
padding-bottom: 16px;
}
.switch-shell :deep(.el-switch) {
--el-switch-on-color: var(--node-switch-color);
}
.node-cell,
.stack-cell,
.online-cell {
display: grid;
gap: 6px;
}
.node-cell__main,
.node-cell__sub,
.online-cell__primary,
.footer-hint {
display: flex;
align-items: center;
gap: 8px;
}
.node-cell__main strong,
.stack-cell strong {
color: var(--xboard-text-strong);
}
.node-cell__sub span,
.stack-cell span,
.online-cell span,
.board-footer span,
.muted-copy {
color: var(--xboard-text-muted);
}
.node-dot {
width: 8px;
height: 8px;
border-radius: 999px;
flex-shrink: 0;
}
.node-dot.online {
background: #34c759;
}
.node-dot.pending {
background: #f5a623;
}
.node-dot.offline {
background: #ff5f57;
}
.node-dot.disabled {
background: #9ca3af;
}
.group-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.rate-tag,
.id-tag {
font-variant-numeric: tabular-nums;
}
.action-trigger {
font-size: 18px;
}
.table-empty {
padding: 24px 0;
}
.board-footer {
flex-wrap: wrap;
}
.footer-hint {
justify-content: flex-end;
color: var(--xboard-text-muted);
}
@media (max-width: 1180px) {
.nodes-hero,
.board-toolbar,
.board-footer {
flex-direction: column;
align-items: stretch;
}
.hero-stats {
min-width: 0;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.toolbar-actions {
justify-content: flex-end;
}
}
@media (max-width: 767px) {
.hero-stats {
grid-template-columns: 1fr;
}
.footer-hint {
justify-content: flex-start;
}
}
</style>
@@ -0,0 +1,159 @@
.drawer-shell,
.drawer-form {
display: grid;
gap: 20px;
}
.drawer-copy {
display: grid;
gap: 4px;
}
.drawer-copy p {
font-size: 12px;
color: var(--xboard-text-muted);
letter-spacing: 0.18em;
text-transform: uppercase;
}
.drawer-copy h2 {
font-size: 30px;
line-height: 1.08;
color: var(--xboard-text-strong);
}
.drawer-copy span {
color: var(--xboard-text-secondary);
line-height: 1.47;
}
.drawer-grid,
.price-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 16px;
}
.full-width {
width: 100%;
}
.tag-input-shell,
.description-panel {
display: grid;
gap: 12px;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.price-panel,
.description-panel {
padding: 18px;
border-radius: 20px;
border: 1px dashed rgba(0, 0, 0, 0.08);
background: #fbfbfd;
}
.section-header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
}
.section-header h3 {
font-size: 18px;
color: var(--xboard-text-strong);
}
.section-header span {
color: var(--xboard-text-muted);
line-height: 1.47;
}
.section-actions,
.drawer-actions,
.drawer-footer {
display: flex;
align-items: center;
gap: 12px;
}
.drawer-footer {
justify-content: space-between;
width: 100%;
}
.editor-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.editor-toolbar button {
min-width: 40px;
height: 34px;
border-radius: 10px;
border: 1px solid rgba(0, 0, 0, 0.08);
background: #ffffff;
color: var(--xboard-text-secondary);
cursor: pointer;
transition: border-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
}
.editor-toolbar button:hover {
color: #0071e3;
border-color: rgba(0, 113, 227, 0.24);
transform: translateY(-1px);
}
.description-editor,
.description-preview {
min-height: 220px;
border-radius: 16px;
border: 1px solid rgba(0, 0, 0, 0.08);
background: #ffffff;
}
.description-editor {
width: 100%;
padding: 16px;
resize: vertical;
outline: none;
color: var(--xboard-text-strong);
font: inherit;
line-height: 1.6;
}
.description-preview {
padding: 18px;
overflow: auto;
color: var(--xboard-text-secondary);
}
.markdown-body :deep(p),
.markdown-body :deep(ul),
.markdown-body :deep(ol),
.markdown-body :deep(blockquote) {
margin-bottom: 12px;
}
@media (max-width: 767px) {
.drawer-grid,
.price-grid,
.section-header,
.drawer-footer {
grid-template-columns: 1fr;
flex-direction: column;
align-items: stretch;
}
.section-actions,
.drawer-actions {
justify-content: flex-end;
}
}
@@ -0,0 +1,350 @@
<script setup lang="ts">
import { computed, nextTick, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { savePlan } from '@/api/admin'
import type { AdminPlanListItem, AdminServerGroupItem } from '@/types/api'
import {
DEFAULT_PLAN_DESCRIPTION_TEMPLATE,
PLAN_PRICE_PERIODS,
RESET_TRAFFIC_METHOD_OPTIONS,
createEmptyPlanForm,
normalizePlanTag,
renderPlanContent,
sanitizePlanPriceInput,
toPlanFormModel,
toPlanSavePayload,
type PlanFormModel,
} from '@/utils/plans'
const props = defineProps<{
visible: boolean
mode: 'create' | 'edit'
plan?: AdminPlanListItem | null
groups: AdminServerGroupItem[]
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
success: [message: string]
}>()
const formRef = ref<FormInstance>()
const submitting = ref(false)
const previewVisible = ref(false)
const tagInput = ref('')
const contentEditorRef = ref<HTMLTextAreaElement | null>(null)
const form = reactive<PlanFormModel>(createEmptyPlanForm())
const drawerTitle = computed(() => props.mode === 'create' ? '添加套餐' : '编辑套餐')
const renderedContent = computed(() => renderPlanContent(form.content))
const rules = computed<FormRules<PlanFormModel>>(() => ({
name: [{ required: true, message: '请输入套餐名称', trigger: 'blur' }],
transferEnableGb: [
{
validator: (_rule, value, callback) => {
if (!Number.isFinite(Number(value)) || Number(value) < 1) {
callback(new Error('请输入大于等于 1 的流量值'))
return
}
callback()
},
trigger: 'blur',
},
],
}))
function closeDrawer() {
emit('update:visible', false)
}
function syncForm() {
Object.assign(form, toPlanFormModel(props.plan))
tagInput.value = ''
previewVisible.value = false
}
function handleTagConfirm() {
const nextTag = normalizePlanTag(tagInput.value)
if (!nextTag) {
tagInput.value = ''
return
}
if (!form.tags.includes(nextTag)) {
form.tags.push(nextTag)
}
tagInput.value = ''
}
function removeTag(tag: string) {
form.tags = form.tags.filter((item) => item !== tag)
}
function applyTemplate() {
if (!form.content.trim()) {
form.content = DEFAULT_PLAN_DESCRIPTION_TEMPLATE
return
}
if (!form.content.includes(DEFAULT_PLAN_DESCRIPTION_TEMPLATE)) {
form.content = `${form.content.trim()}\n\n${DEFAULT_PLAN_DESCRIPTION_TEMPLATE}`
}
}
function insertSnippet(prefix: string, suffix = '', placeholder = '内容') {
const textarea = contentEditorRef.value
if (!textarea) {
form.content = `${form.content}${prefix}${placeholder}${suffix}`
return
}
const start = textarea.selectionStart
const end = textarea.selectionEnd
const selected = form.content.slice(start, end) || placeholder
form.content = `${form.content.slice(0, start)}${prefix}${selected}${suffix}${form.content.slice(end)}`
nextTick(() => {
textarea.focus()
const cursor = start + prefix.length + selected.length + suffix.length
textarea.setSelectionRange(cursor, cursor)
})
}
async function handleSubmit() {
const instance = formRef.value
if (!instance) {
return
}
const valid = await instance.validate().catch(() => false)
if (!valid) {
return
}
submitting.value = true
try {
await savePlan(toPlanSavePayload(form))
const message = props.mode === 'create' ? '套餐已创建' : '套餐已更新'
ElMessage.success(message)
emit('success', message)
closeDrawer()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '套餐保存失败')
} finally {
submitting.value = false
}
}
watch(
() => [props.visible, props.plan, props.mode],
([visible]) => {
if (!visible) {
return
}
syncForm()
nextTick(() => {
formRef.value?.clearValidate()
})
},
{ immediate: true },
)
</script>
<template>
<ElDrawer
:model-value="props.visible"
:title="drawerTitle"
size="min(560px, 100vw)"
destroy-on-close
class="plan-editor-drawer"
@close="closeDrawer"
@update:model-value="emit('update:visible', $event)"
>
<div class="drawer-shell">
<div class="drawer-copy">
<p>订阅管理</p>
<h2>{{ drawerTitle }}</h2>
<span>根据现有 `plan/*` 接口维护套餐结构价格与说明内容</span>
</div>
<ElForm
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
class="drawer-form"
>
<div class="drawer-grid">
<ElFormItem label="套餐名称" prop="name">
<ElInput v-model="form.name" placeholder="请输入套餐名称" />
</ElFormItem>
<ElFormItem label="标签">
<div class="tag-input-shell">
<div v-if="form.tags.length" class="tag-list">
<ElTag
v-for="tag in form.tags"
:key="tag"
closable
effect="plain"
round
@close="removeTag(tag)"
>
{{ tag }}
</ElTag>
</div>
<ElInput
v-model="tagInput"
placeholder="输入标签后按回车确认"
@keyup.enter.prevent="handleTagConfirm"
@blur="handleTagConfirm"
/>
</div>
</ElFormItem>
</div>
<div class="drawer-grid">
<ElFormItem label="服务器分组">
<ElSelect v-model="form.groupId" clearable placeholder="请选择分组">
<ElOption
v-for="group in props.groups"
:key="group.id"
:label="group.name"
:value="group.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="流量" prop="transferEnableGb">
<ElInputNumber
v-model="form.transferEnableGb"
:min="1"
:controls="false"
class="full-width"
/>
</ElFormItem>
<ElFormItem label="速度限制">
<ElInputNumber
v-model="form.speedLimit"
:min="0"
:controls="false"
class="full-width"
placeholder="请输入速度限制"
/>
</ElFormItem>
<ElFormItem label="设备限制">
<ElInputNumber
v-model="form.deviceLimit"
:min="0"
:controls="false"
class="full-width"
placeholder="请输入设备限制"
/>
</ElFormItem>
<ElFormItem label="容量限制">
<ElInputNumber
v-model="form.capacityLimit"
:min="0"
:controls="false"
class="full-width"
placeholder="请输入容量限制"
/>
</ElFormItem>
<ElFormItem label="流量重置方式">
<ElSelect v-model="form.resetTrafficMethod" placeholder="请选择重置方式">
<ElOption
v-for="option in RESET_TRAFFIC_METHOD_OPTIONS"
:key="String(option.value)"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</ElFormItem>
</div>
<section class="price-panel">
<header class="section-header">
<div>
<h3>价格设置</h3>
<span>留空表示该周期不开放购买</span>
</div>
</header>
<div class="price-grid">
<ElFormItem
v-for="period in PLAN_PRICE_PERIODS"
:key="period.key"
:label="period.label"
>
<ElInput
:model-value="form.prices[period.key]"
placeholder="请输入价格"
@update:model-value="form.prices[period.key] = sanitizePlanPriceInput($event)"
/>
</ElFormItem>
</div>
</section>
<section class="description-panel">
<header class="section-header">
<div>
<h3>套餐说明</h3>
<span>支持 Markdown 与基础 HTML 换行</span>
</div>
<div class="section-actions">
<ElButton @click="applyTemplate">使用模板</ElButton>
<ElButton @click="previewVisible = !previewVisible">
{{ previewVisible ? '继续编辑' : '显示预览' }}
</ElButton>
</div>
</header>
<div class="editor-toolbar">
<button type="button" @click="insertSnippet('**', '**', '加粗文本')">B</button>
<button type="button" @click="insertSnippet('*', '*', '斜体文本')">I</button>
<button type="button" @click="insertSnippet('<u>', '</u>', '下划线文本')">U</button>
<button type="button" @click="insertSnippet('- ', '', '列表项')">列表</button>
<button type="button" @click="insertSnippet('> ', '', '引用内容')">引用</button>
<button type="button" @click="insertSnippet('`', '`', '代码')">代码</button>
<button type="button" @click="insertSnippet('[', '](https://)', '链接文本')">链接</button>
<button type="button" @click="insertSnippet('<br>', '', '')">换行</button>
</div>
<div v-if="previewVisible" class="description-preview markdown-body" v-html="renderedContent" />
<textarea
v-else
ref="contentEditorRef"
v-model="form.content"
class="description-editor"
placeholder="请输入套餐说明,支持 Markdown 或 <br> 换行"
/>
</section>
</ElForm>
</div>
<template #footer>
<div class="drawer-footer">
<ElCheckbox v-if="props.mode === 'edit'" v-model="form.forceUpdate">
强制更新用户套餐
</ElCheckbox>
<span v-else />
<div class="drawer-actions">
<ElButton @click="closeDrawer">取消</ElButton>
<ElButton type="primary" :loading="submitting" @click="handleSubmit">
{{ props.mode === 'create' ? '提交' : '保存修改' }}
</ElButton>
</div>
</div>
</template>
</ElDrawer>
</template>
<style scoped lang="scss" src="./PlanEditorDrawer.scss"></style>
+188
View File
@@ -0,0 +1,188 @@
.plans-page {
display: grid;
gap: 24px;
}
.plans-hero {
display: flex;
justify-content: space-between;
gap: 24px;
padding: 30px 32px;
border-radius: 28px;
background: #000000;
}
.plans-copy {
display: grid;
gap: 10px;
max-width: 620px;
}
.plans-kicker {
font-size: 11px;
letter-spacing: 0.24em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.68);
}
.plans-copy h1 {
font-size: clamp(34px, 5vw, 52px);
line-height: 1.08;
letter-spacing: -0.28px;
color: #ffffff;
}
.plans-copy span {
color: rgba(255, 255, 255, 0.72);
line-height: 1.47;
}
.hero-stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
min-width: 320px;
}
.hero-stats article {
display: grid;
gap: 6px;
padding: 18px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.08);
}
.hero-stats span {
color: rgba(255, 255, 255, 0.64);
font-size: 12px;
}
.hero-stats strong {
color: #ffffff;
font-size: 22px;
}
.table-shell {
display: grid;
gap: 18px;
padding: 24px;
border-radius: 26px;
background: #ffffff;
box-shadow: var(--xboard-shadow);
}
.table-toolbar,
.toolbar-left,
.table-footer,
.action-group,
.sort-item,
.sort-item__main,
.sort-actions,
.sort-footer {
display: flex;
align-items: center;
gap: 12px;
}
.table-toolbar,
.table-footer {
justify-content: space-between;
}
.toolbar-left {
flex-wrap: wrap;
}
.toolbar-search {
width: min(320px, 100%);
}
.plans-table :deep(th.el-table__cell) {
color: var(--xboard-text-secondary);
background: #fbfbfd;
}
.plans-table :deep(.el-table__row td.el-table__cell) {
padding-top: 16px;
padding-bottom: 16px;
}
.name-cell,
.price-cell,
.metric-cell,
.sort-shell,
.sort-list,
.sort-meta {
display: grid;
gap: 8px;
}
.name-cell strong,
.sort-meta strong {
color: var(--xboard-text-strong);
}
.name-cell span,
.table-footer span,
.price-empty,
.sort-copy,
.sort-meta span {
color: var(--xboard-text-muted);
}
.price-cell,
.metric-cell {
grid-template-columns: repeat(auto-fit, minmax(104px, max-content));
align-items: start;
}
.action-btn {
font-size: 18px;
}
.danger-btn {
color: var(--xboard-danger);
}
.sort-copy {
line-height: 1.47;
}
.sort-item {
justify-content: space-between;
padding: 14px 16px;
border-radius: 16px;
background: #fbfbfd;
}
.sort-index {
width: 32px;
height: 32px;
border-radius: 999px;
display: grid;
place-items: center;
background: rgba(0, 113, 227, 0.08);
color: #0071e3;
font-weight: 600;
}
@media (max-width: 1080px) {
.plans-hero,
.table-toolbar,
.table-footer,
.sort-item,
.sort-item__main,
.sort-actions {
flex-direction: column;
align-items: stretch;
}
.hero-stats {
min-width: 0;
grid-template-columns: 1fr;
}
.sort-index {
align-self: flex-start;
}
}
@@ -0,0 +1,373 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
ArrowDown,
ArrowUp,
Delete,
EditPen,
Plus,
Search,
} from '@element-plus/icons-vue'
import {
deletePlan,
getPlans,
getServerGroups,
sortPlans,
updatePlan,
} from '@/api/admin'
import type { AdminPlanListItem, AdminServerGroupItem } from '@/types/api'
import {
countEnabledPlans,
filterPlans,
formatPlanTraffic,
getPlanPriceBadges,
movePlanOrder,
} from '@/utils/plans'
import PlanEditorDrawer from './PlanEditorDrawer.vue'
type DrawerMode = 'create' | 'edit'
type PlanToggleField = 'show' | 'sell' | 'renew'
const loading = ref(false)
const sortSubmitting = ref(false)
const drawerVisible = ref(false)
const drawerMode = ref<DrawerMode>('create')
const activePlan = ref<AdminPlanListItem | null>(null)
const sortDialogVisible = ref(false)
const keyword = ref('')
const current = ref(1)
const pageSize = ref(10)
const plans = ref<AdminPlanListItem[]>([])
const groups = ref<AdminServerGroupItem[]>([])
const sortDraft = ref<AdminPlanListItem[]>([])
const toggleLoadingMap = ref<Record<string, boolean>>({})
const filteredPlans = computed(() => filterPlans(plans.value, keyword.value))
const visiblePlans = computed(() => {
const start = (current.value - 1) * pageSize.value
return filteredPlans.value.slice(start, start + pageSize.value)
})
const heroStats = computed(() => [
{ label: '套餐总数', value: String(plans.value.length) },
{ label: '展示中', value: String(countEnabledPlans(plans.value, 'show')) },
{ label: '支持新购', value: String(countEnabledPlans(plans.value, 'sell')) },
{ label: '支持续费', value: String(countEnabledPlans(plans.value, 'renew')) },
])
function getToggleKey(id: number, field: PlanToggleField): string {
return `${id}:${field}`
}
function isToggleLoading(id: number, field: PlanToggleField): boolean {
return Boolean(toggleLoadingMap.value[getToggleKey(id, field)])
}
async function loadData() {
loading.value = true
try {
const [plansResponse, groupsResponse] = await Promise.all([getPlans(), getServerGroups()])
plans.value = [...(plansResponse.data ?? [])].sort((left, right) => (left.sort || 0) - (right.sort || 0))
groups.value = groupsResponse.data ?? []
} finally {
loading.value = false
}
}
function openCreateDrawer() {
drawerMode.value = 'create'
activePlan.value = null
drawerVisible.value = true
}
function openEditDrawer(plan: AdminPlanListItem) {
drawerMode.value = 'edit'
activePlan.value = plan
drawerVisible.value = true
}
async function handleToggle(plan: AdminPlanListItem, field: PlanToggleField, nextValue: boolean | string | number) {
const key = getToggleKey(plan.id, field)
toggleLoadingMap.value[key] = true
try {
await updatePlan(plan.id, { [field]: Boolean(nextValue) })
plan[field] = Boolean(nextValue)
ElMessage.success('套餐状态已更新')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '套餐状态更新失败')
} finally {
toggleLoadingMap.value[key] = false
}
}
async function handleDelete(plan: AdminPlanListItem) {
try {
await ElMessageBox.confirm(`删除套餐「${plan.name}」后无法恢复,确认继续吗?`, '删除套餐', {
type: 'warning',
})
await deletePlan(plan.id)
ElMessage.success('套餐已删除')
await loadData()
} catch (error) {
if (error === 'cancel' || error === 'close') {
return
}
ElMessage.error(error instanceof Error ? error.message : '套餐删除失败')
}
}
function openSortEditor() {
sortDraft.value = plans.value.map((plan) => ({ ...plan }))
sortDialogVisible.value = true
}
function moveDraft(index: number, direction: -1 | 1) {
sortDraft.value = movePlanOrder(sortDraft.value, index, direction)
}
async function submitSort() {
sortSubmitting.value = true
try {
await sortPlans(sortDraft.value.map((item) => item.id))
ElMessage.success('排序已保存')
sortDialogVisible.value = false
await loadData()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '排序保存失败')
} finally {
sortSubmitting.value = false
}
}
watch(keyword, () => {
current.value = 1
})
watch(filteredPlans, (list) => {
const maxPage = Math.max(1, Math.ceil(list.length / pageSize.value))
if (current.value > maxPage) {
current.value = maxPage
}
})
watch(pageSize, () => {
current.value = 1
})
onMounted(() => {
void loadData().catch((error) => {
ElMessage.error(error instanceof Error ? error.message : '套餐管理页面初始化失败')
})
})
</script>
<template>
<div class="plans-page">
<section class="plans-hero">
<div class="plans-copy">
<p class="plans-kicker">Subscriptions</p>
<h1>订阅套餐</h1>
<span>在这里可以配置订阅计划包括添加删除编辑排序与价格维护</span>
</div>
<div class="hero-stats">
<article v-for="item in heroStats" :key="item.label">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</article>
</div>
</section>
<section class="table-shell">
<header class="table-toolbar">
<div class="toolbar-left">
<ElButton type="primary" @click="openCreateDrawer">
<ElIcon><Plus /></ElIcon>
添加套餐
</ElButton>
<ElInput
v-model="keyword"
clearable
placeholder="搜索套餐..."
class="toolbar-search"
>
<template #prefix>
<ElIcon><Search /></ElIcon>
</template>
</ElInput>
</div>
<ElButton @click="openSortEditor">编辑排序</ElButton>
</header>
<ElTable
:data="visiblePlans"
v-loading="loading"
class="plans-table"
row-key="id"
empty-text="当前筛选条件下暂无套餐"
>
<ElTableColumn prop="id" label="ID" width="86" />
<ElTableColumn label="显示" width="92">
<template #default="{ row }">
<ElSwitch
:model-value="row.show"
:loading="isToggleLoading(row.id, 'show')"
@change="handleToggle(row, 'show', $event)"
/>
</template>
</ElTableColumn>
<ElTableColumn label="新购" width="92">
<template #default="{ row }">
<ElSwitch
:model-value="row.sell"
:loading="isToggleLoading(row.id, 'sell')"
@change="handleToggle(row, 'sell', $event)"
/>
</template>
</ElTableColumn>
<ElTableColumn label="续费" width="92">
<template #default="{ row }">
<ElSwitch
:model-value="row.renew"
:loading="isToggleLoading(row.id, 'renew')"
@change="handleToggle(row, 'renew', $event)"
/>
</template>
</ElTableColumn>
<ElTableColumn label="名称" min-width="280">
<template #default="{ row }">
<div class="name-cell">
<strong>{{ row.name }}</strong>
<span>
{{ formatPlanTraffic(row) }}
<template v-if="row.tags?.length">
· {{ row.tags.join(' / ') }}
</template>
</span>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="统计" min-width="154">
<template #default="{ row }">
<div class="metric-cell">
<ElTag effect="plain" round>
总用户 {{ row.users_count ?? 0 }}
</ElTag>
<ElTag type="success" effect="plain" round>
活跃 {{ row.active_users_count ?? 0 }}
</ElTag>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="权限组" min-width="120">
<template #default="{ row }">
<ElTag effect="plain" round>
{{ row.group?.name || '未分组' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="价格" min-width="260">
<template #default="{ row }">
<div class="price-cell">
<ElTag
v-for="badge in getPlanPriceBadges(row)"
:key="`${row.id}-${badge.key}`"
effect="plain"
round
>
{{ badge.label }}
</ElTag>
<span v-if="!getPlanPriceBadges(row).length" class="price-empty">未设置价格</span>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="108" fixed="right">
<template #default="{ row }">
<div class="action-group">
<ElButton text class="action-btn" @click="openEditDrawer(row)">
<ElIcon><EditPen /></ElIcon>
</ElButton>
<ElButton text class="action-btn danger-btn" @click="handleDelete(row)">
<ElIcon><Delete /></ElIcon>
</ElButton>
</div>
</template>
</ElTableColumn>
</ElTable>
<footer class="table-footer">
<span>已选择 0 {{ filteredPlans.length }} </span>
<ElPagination
v-model:current-page="current"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]"
layout="sizes, prev, pager, next"
:total="filteredPlans.length"
background
/>
</footer>
</section>
<PlanEditorDrawer
v-model:visible="drawerVisible"
:mode="drawerMode"
:plan="activePlan"
:groups="groups"
@success="() => loadData()"
/>
<ElDialog
v-model="sortDialogVisible"
width="min(640px, calc(100vw - 32px))"
title="编辑排序"
class="sort-dialog"
>
<div class="sort-shell">
<p class="sort-copy">按照当前展示顺序调整套餐排序保存后会同步到后台 `/plan/sort`</p>
<div class="sort-list">
<article
v-for="(item, index) in sortDraft"
:key="item.id"
class="sort-item"
>
<div class="sort-item__main">
<span class="sort-index">{{ index + 1 }}</span>
<div class="sort-meta">
<strong>{{ item.name }}</strong>
<span>{{ formatPlanTraffic(item) }} · {{ item.group?.name || '未分组' }}</span>
</div>
</div>
<div class="sort-actions">
<ElButton :disabled="index === 0" @click="moveDraft(index, -1)">
<ElIcon><ArrowUp /></ElIcon>
上移
</ElButton>
<ElButton :disabled="index === sortDraft.length - 1" @click="moveDraft(index, 1)">
<ElIcon><ArrowDown /></ElIcon>
下移
</ElButton>
</div>
</article>
</div>
</div>
<template #footer>
<div class="sort-footer">
<ElButton @click="sortDialogVisible = false">取消</ElButton>
<ElButton type="primary" :loading="sortSubmitting" @click="submitSort">
保存排序
</ElButton>
</div>
</template>
</ElDialog>
</div>
</template>
<style scoped lang="scss" src="./PlansView.scss"></style>
@@ -0,0 +1,677 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import { ElMessage } from 'element-plus'
import { CircleCheckFilled, Message, RefreshRight, Setting, WarningFilled } from '@element-plus/icons-vue'
import { fetchAdminConfig, getPlans, saveAdminConfig, setTelegramWebhook, testAdminMail } from '@/api/admin'
import type { AdminPlanListItem } from '@/types/api'
import { formatDateTime } from '@/utils/dashboard'
import {
createSystemConfigFormState,
getSystemConfigFieldOptions,
normalizeSystemConfigMappings,
serializeSystemConfigForm,
systemConfigSections,
type SystemConfigFieldSchema,
type SystemConfigFieldValue,
type SystemConfigSectionKey,
} from '@/utils/systemConfig'
const loading = ref(true)
const reloading = ref(false)
const saving = ref(false)
const errorMessage = ref('')
const activeSection = ref<SystemConfigSectionKey>('site')
const auxiliaryAction = ref<'mail' | 'telegram' | null>(null)
const plans = ref<AdminPlanListItem[]>([])
const lastLoadedAt = ref<string | null>(null)
const form = reactive(createSystemConfigFormState())
const sectionRefs = new Map<SystemConfigSectionKey, HTMLElement>()
const originalSnapshot = ref(JSON.stringify(serializeSystemConfigForm(form)))
const resolvedSections = computed(() => systemConfigSections.map((section) => ({
...section,
fields: section.fields.map((field) => ({
...field,
options: getSystemConfigFieldOptions(field, plans.value),
})),
})))
const currentSnapshot = computed(() => JSON.stringify(serializeSystemConfigForm(form)))
const isDirty = computed(() => currentSnapshot.value !== originalSnapshot.value)
const saveStatusText = computed(() => {
if (saving.value) return '配置保存中'
if (isDirty.value) return '存在未保存改动'
return '已与服务端同步'
})
const summaryCards = computed(() => [
{
label: '站点名称',
value: String(form.app_name || '未命名站点'),
},
{
label: '后台路径',
value: form.secure_path ? `/${form.secure_path}` : '未设置',
},
{
label: '注册状态',
value: Boolean(form.stop_register) ? '暂停注册' : '开放注册',
},
])
function applyFormState() {
originalSnapshot.value = JSON.stringify(serializeSystemConfigForm(form))
lastLoadedAt.value = new Date().toISOString()
}
function assignFormState(nextState: Record<string, SystemConfigFieldValue>) {
Object.keys(nextState).forEach((key) => {
form[key] = nextState[key]
})
applyFormState()
}
async function loadPage(mode: 'initial' | 'reload' = 'initial') {
if (mode === 'initial') {
loading.value = true
} else {
reloading.value = true
}
errorMessage.value = ''
try {
const [configResult, plansResult] = await Promise.allSettled([
fetchAdminConfig(),
getPlans(),
])
if (configResult.status === 'rejected') {
throw configResult.reason
}
const nextState = normalizeSystemConfigMappings(configResult.value.data)
assignFormState(nextState)
if (plansResult.status === 'fulfilled') {
plans.value = plansResult.value.data ?? []
} else {
plans.value = []
ElMessage.warning('试用套餐列表加载失败,注册试用下拉选项将暂时不可用')
}
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '系统配置加载失败'
} finally {
loading.value = false
reloading.value = false
}
}
function getFieldValue(key: string): SystemConfigFieldValue {
return form[key]
}
function updateField(key: string, value: SystemConfigFieldValue) {
form[key] = value
}
function resolveNumberValue(field: SystemConfigFieldSchema): number | undefined {
const value = getFieldValue(field.key)
if (typeof value === 'number') return value
if (value === null || value === '') return undefined
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : undefined
}
function resolveTextValue(field: SystemConfigFieldSchema): string {
const value = getFieldValue(field.key)
if (value === null || value === undefined) return ''
if (Array.isArray(value)) return value.join(', ')
return String(value)
}
function registerSection(key: SystemConfigSectionKey) {
return (element: Element | ComponentPublicInstance | null) => {
if (element instanceof HTMLElement) {
sectionRefs.set(key, element)
return
}
if (element && '$el' in element && element.$el instanceof HTMLElement) {
sectionRefs.set(key, element.$el)
}
}
}
function jumpToSection(key: SystemConfigSectionKey) {
activeSection.value = key
sectionRefs.get(key)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
async function handleSave() {
if (saving.value) return
saving.value = true
try {
const payload = serializeSystemConfigForm(form)
await saveAdminConfig(payload)
originalSnapshot.value = JSON.stringify(payload)
ElMessage.success('系统配置已保存')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '系统配置保存失败')
} finally {
saving.value = false
}
}
function ensureSavedBeforeAuxiliaryAction(): boolean {
if (isDirty.value) {
ElMessage.warning('请先保存当前配置,再执行辅助操作')
return false
}
return true
}
async function handleTestMail() {
if (!ensureSavedBeforeAuxiliaryAction()) return
auxiliaryAction.value = 'mail'
try {
await testAdminMail()
ElMessage.success('测试邮件已触发,请检查当前管理员邮箱')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '测试邮件发送失败')
} finally {
auxiliaryAction.value = null
}
}
async function handleSetWebhook() {
if (!ensureSavedBeforeAuxiliaryAction()) return
auxiliaryAction.value = 'telegram'
try {
await setTelegramWebhook({
telegram_bot_token: String(form.telegram_bot_token || ''),
})
ElMessage.success('Telegram Webhook 设置成功')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : 'Telegram Webhook 设置失败')
} finally {
auxiliaryAction.value = null
}
}
onMounted(() => {
void loadPage()
})
</script>
<template>
<div class="config-page">
<section class="config-hero">
<div class="config-copy">
<p class="config-kicker">System Settings</p>
<h1>系统设置</h1>
<p>
管理系统核心配置包括站点安全订阅邀请佣金节点邮件与通知相关设置
</p>
<div class="config-status">
<ElIcon :class="{ danger: isDirty }">
<WarningFilled v-if="isDirty" />
<CircleCheckFilled v-else />
</ElIcon>
<span>{{ saveStatusText }}</span>
<small v-if="lastLoadedAt">最近加载于 {{ formatDateTime(lastLoadedAt) }}</small>
</div>
</div>
<div class="hero-side">
<div class="hero-actions">
<ElButton :loading="reloading" @click="loadPage('reload')">
<ElIcon><RefreshRight /></ElIcon>
重新拉取
</ElButton>
<ElButton type="primary" :loading="saving" :disabled="!isDirty" @click="handleSave">
保存配置
</ElButton>
</div>
<div class="hero-summary">
<article v-for="item in summaryCards" :key="item.label">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</article>
</div>
</div>
</section>
<section v-if="loading" class="loading-shell">
<ElSkeleton :rows="4" animated />
<ElSkeleton :rows="6" animated />
</section>
<section v-else class="config-shell">
<aside class="config-nav">
<button
v-for="section in resolvedSections"
:key="section.key"
type="button"
class="nav-item"
:class="{ active: section.key === activeSection }"
@click="jumpToSection(section.key)"
>
<span>{{ section.navLabel }}</span>
<small>{{ section.fields.length }} </small>
</button>
</aside>
<div class="config-content">
<ElAlert
v-if="errorMessage"
type="error"
show-icon
:closable="false"
class="config-error"
:title="errorMessage"
>
<template #default>
<ElButton size="small" @click="loadPage('reload')">重新加载</ElButton>
</template>
</ElAlert>
<article
v-for="section in resolvedSections"
:key="section.key"
:ref="registerSection(section.key)"
class="config-section"
>
<header class="section-header">
<div class="section-copy">
<p>{{ section.navLabel }}</p>
<h2>{{ section.title }}</h2>
<span>{{ section.description }}</span>
</div>
<div v-if="section.key === 'email' || section.key === 'telegram'" class="section-actions">
<ElButton
v-if="section.key === 'email'"
:loading="auxiliaryAction === 'mail'"
@click="handleTestMail"
>
<ElIcon><Message /></ElIcon>
发送测试邮件
</ElButton>
<ElButton
v-if="section.key === 'telegram'"
:loading="auxiliaryAction === 'telegram'"
@click="handleSetWebhook"
>
<ElIcon><Setting /></ElIcon>
设置 Webhook
</ElButton>
</div>
</header>
<ElForm label-position="top" class="config-form">
<div class="config-grid">
<div
v-for="field in section.fields"
:key="field.key"
class="config-field"
:class="{ 'is-full': field.fullWidth }"
>
<ElFormItem :label="field.label">
<ElSwitch
v-if="field.type === 'switch'"
:model-value="Boolean(getFieldValue(field.key))"
@update:model-value="updateField(field.key, $event)"
/>
<ElInputNumber
v-else-if="field.type === 'number'"
:model-value="resolveNumberValue(field)"
:min="field.min"
:max="field.max"
:step="field.step ?? 1"
controls-position="right"
class="field-number"
@update:model-value="updateField(field.key, $event ?? (field.nullable ? null : field.defaultValue ?? 0))"
/>
<ElSelect
v-else-if="field.type === 'select'"
:model-value="getFieldValue(field.key)"
:multiple="field.multiple"
:allow-create="field.allowCreate"
:filterable="field.multiple || field.allowCreate"
:clearable="!field.multiple"
collapse-tags
collapse-tags-tooltip
class="field-select"
@update:model-value="updateField(field.key, $event as SystemConfigFieldValue)"
>
<ElOption
v-for="option in field.options"
:key="`${field.key}-${option.value}`"
:label="option.label"
:value="option.value"
/>
</ElSelect>
<ElInput
v-else-if="field.type === 'textarea'"
:model-value="resolveTextValue(field)"
type="textarea"
:rows="field.rows ?? 4"
:placeholder="field.placeholder"
:autosize="field.rows ? undefined : { minRows: 4, maxRows: 10 }"
@update:model-value="updateField(field.key, $event)"
/>
<ElInput
v-else
:model-value="resolveTextValue(field)"
:type="field.type === 'password' ? 'password' : 'text'"
:show-password="field.type === 'password'"
:placeholder="field.placeholder"
clearable
@update:model-value="updateField(field.key, $event)"
/>
<p v-if="field.helper" class="field-helper">
{{ field.helper }}
</p>
</ElFormItem>
</div>
</div>
</ElForm>
</article>
</div>
</section>
</div>
</template>
<style scoped>
.config-page {
display: grid;
gap: 24px;
}
.config-hero {
display: flex;
justify-content: space-between;
gap: 24px;
padding: 34px;
border-radius: 28px;
background: #000000;
}
.config-copy {
display: grid;
gap: 12px;
max-width: 620px;
}
.config-kicker {
margin: 0;
color: rgba(255, 255, 255, 0.68);
font-size: 11px;
letter-spacing: 0.24em;
text-transform: uppercase;
}
.config-copy h1 {
margin: 0;
color: #ffffff;
font-size: clamp(34px, 5vw, 52px);
line-height: 1.08;
letter-spacing: -0.28px;
}
.config-copy > p:last-of-type {
margin: 0;
color: rgba(255, 255, 255, 0.72);
line-height: 1.6;
}
.config-status {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin-top: 8px;
color: rgba(255, 255, 255, 0.72);
}
.config-status :deep(.el-icon) {
color: #2997ff;
}
.config-status :deep(.el-icon.danger) {
color: #f59e0b;
}
.config-status small {
color: rgba(255, 255, 255, 0.52);
}
.hero-side {
display: grid;
gap: 16px;
min-width: 360px;
}
.hero-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
}
.hero-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.hero-summary article {
display: grid;
gap: 6px;
padding: 18px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.08);
}
.hero-summary span {
color: rgba(255, 255, 255, 0.64);
font-size: 12px;
}
.hero-summary strong {
color: #ffffff;
font-size: 20px;
line-height: 1.2;
}
.loading-shell {
display: grid;
gap: 16px;
padding: 28px;
border-radius: 24px;
background: #ffffff;
box-shadow: var(--xboard-shadow);
}
.config-shell {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
gap: 20px;
align-items: start;
}
.config-nav,
.config-content {
display: grid;
gap: 18px;
}
.config-nav {
position: sticky;
top: 0;
padding: 18px;
border-radius: 24px;
background: #ffffff;
box-shadow: var(--xboard-shadow);
}
.nav-item {
display: grid;
gap: 4px;
width: 100%;
padding: 14px 16px;
border: 1px solid transparent;
border-radius: 18px;
background: transparent;
color: var(--xboard-text-secondary);
text-align: left;
cursor: pointer;
transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease;
}
.nav-item span {
color: inherit;
font-weight: 600;
}
.nav-item small {
color: var(--xboard-text-muted);
font-size: 12px;
}
.nav-item.active {
border-color: rgba(0, 113, 227, 0.14);
background: rgba(0, 113, 227, 0.08);
color: #0071e3;
}
.config-error {
margin-bottom: 2px;
}
.config-section {
display: grid;
gap: 22px;
padding: 28px;
border-radius: 24px;
background: #ffffff;
box-shadow: var(--xboard-shadow);
}
.section-header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
}
.section-copy {
display: grid;
gap: 8px;
}
.section-copy p {
margin: 0;
color: var(--xboard-text-muted);
font-size: 12px;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.section-copy h2 {
margin: 0;
color: var(--xboard-text-strong);
font-size: 30px;
line-height: 1.1;
letter-spacing: -0.28px;
}
.section-copy span {
color: var(--xboard-text-secondary);
line-height: 1.6;
}
.section-actions {
display: flex;
gap: 12px;
}
.config-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px 16px;
}
.config-field.is-full {
grid-column: 1 / -1;
}
.field-number,
.field-select {
width: 100%;
}
.field-helper {
margin-top: 8px;
color: var(--xboard-text-muted);
font-size: 12px;
line-height: 1.5;
}
@media (max-width: 1180px) {
.config-hero,
.config-shell,
.section-header {
grid-template-columns: 1fr;
flex-direction: column;
}
.hero-side {
min-width: 0;
}
.hero-actions,
.section-actions {
justify-content: flex-start;
}
.config-shell {
display: grid;
}
.config-nav {
position: static;
grid-auto-flow: column;
grid-auto-columns: minmax(180px, 1fr);
overflow-x: auto;
}
}
@media (max-width: 767px) {
.config-grid,
.hero-summary {
grid-template-columns: 1fr;
}
.config-hero {
padding: 28px 24px;
}
.config-section {
padding: 22px;
}
}
</style>
@@ -0,0 +1,323 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { Compass, Connection, Document, Setting } from '@element-plus/icons-vue'
interface PlaceholderState {
title: string
description: string
summary: Array<{ label: string; value: string }>
endpoints: string[]
nextSteps: string[]
}
const route = useRoute()
const placeholderMap: Record<string, PlaceholderState> = {
SystemPlugins: {
title: '插件管理',
description: '本轮先稳定菜单入口与信息架构。下一阶段会接入插件扫描、启停、配置编辑与上传工作流。',
summary: [
{ label: '当前阶段', value: '结构化占位' },
{ label: '下一阶段', value: '插件列表与启停' },
{ label: '重点边界', value: '配置编辑与上传' },
],
endpoints: ['GET /plugin/getPlugins', 'POST /plugin/config', 'POST /plugin/upload'],
nextSteps: ['展示已安装 / 可安装插件列表', '接入启用、禁用、安装、升级动作', '补齐插件配置表单与 README 说明面板'],
},
SystemThemes: {
title: '主题配置',
description: '主题管理本轮仅保留入口。后续会接入主题列表、切换、配置编辑与上传能力。',
summary: [
{ label: '当前阶段', value: '结构化占位' },
{ label: '下一阶段', value: '主题列表与切换' },
{ label: '重点边界', value: '主题配置保存' },
],
endpoints: ['GET /theme/getThemes', 'POST /theme/getThemeConfig', 'POST /theme/saveThemeConfig'],
nextSteps: ['展示主题列表与当前启用主题', '接入主题配置动态表单', '补齐主题上传与删除的安全边界'],
},
SystemNotices: {
title: '公告管理',
description: '公告管理入口已预留。下一阶段会补齐公告列表、显隐切换、排序与编辑工作台。',
summary: [
{ label: '当前阶段', value: '结构化占位' },
{ label: '下一阶段', value: '公告列表' },
{ label: '重点边界', value: '排序与显隐' },
],
endpoints: ['GET /notice/fetch', 'POST /notice/save', 'POST /notice/sort'],
nextSteps: ['接入公告列表和编辑抽屉', '补齐显隐切换与排序反馈', '明确弹窗公告与普通公告的字段边界'],
},
SystemPayments: {
title: '支付配置',
description: '支付配置本轮只保留入口。下一阶段会接入支付方式列表、配置表单、显隐与排序。',
summary: [
{ label: '当前阶段', value: '结构化占位' },
{ label: '下一阶段', value: '支付方式列表' },
{ label: '重点边界', value: '网关配置安全性' },
],
endpoints: ['GET /payment/fetch', 'POST /payment/save', 'POST /payment/show'],
nextSteps: ['展示支付方式列表与状态', '接入网关配置表单', '补齐排序、通知地址与风险提示'],
},
SystemKnowledge: {
title: '知识库管理',
description: '知识库管理入口已预留。下一阶段会补齐分类筛选、文档列表、显隐和编辑工作流。',
summary: [
{ label: '当前阶段', value: '结构化占位' },
{ label: '下一阶段', value: '知识库列表' },
{ label: '重点边界', value: '分类与排序' },
],
endpoints: ['GET /knowledge/fetch', 'GET /knowledge/getCategory', 'POST /knowledge/save'],
nextSteps: ['接入分类与文档列表', '补齐显隐、排序与删除动作', '明确 Markdown / 富文本编辑策略'],
},
}
const pageState = computed(() => {
const fallbackTitle = String(route.meta.title || '系统管理')
return placeholderMap[String(route.name)] ?? {
title: fallbackTitle,
description: '该模块已经预留导航入口,本轮先完成结构化占位,后续继续接入真实管理能力。',
summary: [
{ label: '当前阶段', value: '结构化占位' },
{ label: '下一阶段', value: '真实管理页' },
{ label: '重点边界', value: '接口与权限' },
],
endpoints: [],
nextSteps: ['补齐列表与编辑能力', '补齐保存、排序与删除工作流'],
}
})
</script>
<template>
<div class="placeholder-page">
<section class="placeholder-hero">
<div class="placeholder-copy">
<p class="placeholder-kicker">System Management</p>
<h1>{{ pageState.title }}</h1>
<p>{{ pageState.description }}</p>
</div>
<div class="placeholder-summary">
<article v-for="item in pageState.summary" :key="item.label">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</article>
</div>
</section>
<section class="placeholder-shell">
<article class="info-card">
<div class="card-header">
<ElIcon><Compass /></ElIcon>
<div>
<h2>本轮已就绪</h2>
<p>菜单入口路由结构和页面骨架已稳定下来后续可以在这个基础上继续扩展</p>
</div>
</div>
<ul class="info-list">
<li>
<ElIcon><Setting /></ElIcon>
<span>已接入系统管理分组保持 Apple 风格后台的信息架构一致性</span>
</li>
<li>
<ElIcon><Connection /></ElIcon>
<span>当前页面作为结构化占位页存在保证导航与后续模块边界先稳定</span>
</li>
</ul>
</article>
<article class="info-card">
<div class="card-header">
<ElIcon><Document /></ElIcon>
<div>
<h2>下一阶段接入</h2>
<p>后续优先接入真实列表编辑表单和状态反馈闭环</p>
</div>
</div>
<ol class="next-list">
<li v-for="item in pageState.nextSteps" :key="item">{{ item }}</li>
</ol>
</article>
<article v-if="pageState.endpoints.length" class="info-card">
<div class="card-header">
<ElIcon><Setting /></ElIcon>
<div>
<h2>已确认的后端接口</h2>
<p>后续页面会优先对齐这些现有 Laravel 管理接口不额外猜测后端契约</p>
</div>
</div>
<div class="endpoint-list">
<code v-for="item in pageState.endpoints" :key="item">{{ item }}</code>
</div>
</article>
</section>
</div>
</template>
<style scoped>
.placeholder-page {
display: grid;
gap: 24px;
}
.placeholder-hero {
display: flex;
justify-content: space-between;
gap: 24px;
padding: 32px;
border-radius: 28px;
background: #000000;
}
.placeholder-copy {
display: grid;
gap: 12px;
max-width: 620px;
}
.placeholder-kicker {
margin: 0;
color: rgba(255, 255, 255, 0.68);
font-size: 11px;
letter-spacing: 0.24em;
text-transform: uppercase;
}
.placeholder-copy h1 {
margin: 0;
color: #ffffff;
font-size: clamp(34px, 5vw, 52px);
line-height: 1.08;
letter-spacing: -0.28px;
}
.placeholder-copy p:last-child {
margin: 0;
color: rgba(255, 255, 255, 0.72);
line-height: 1.6;
}
.placeholder-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
min-width: 360px;
}
.placeholder-summary article {
display: grid;
gap: 6px;
padding: 18px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.08);
}
.placeholder-summary span {
color: rgba(255, 255, 255, 0.64);
font-size: 12px;
}
.placeholder-summary strong {
color: #ffffff;
font-size: 20px;
line-height: 1.2;
}
.placeholder-shell {
display: grid;
gap: 18px;
}
.info-card {
display: grid;
gap: 18px;
padding: 26px 28px;
border-radius: 24px;
background: #ffffff;
box-shadow: var(--xboard-shadow);
}
.card-header {
display: flex;
align-items: flex-start;
gap: 14px;
}
.card-header :deep(.el-icon) {
margin-top: 4px;
font-size: 18px;
color: #0071e3;
}
.card-header h2 {
margin: 0;
color: var(--xboard-text-strong);
font-size: 28px;
line-height: 1.12;
letter-spacing: -0.28px;
}
.card-header p {
margin: 8px 0 0;
color: var(--xboard-text-secondary);
line-height: 1.6;
}
.info-list,
.next-list {
display: grid;
gap: 12px;
padding: 0;
margin: 0;
}
.info-list {
list-style: none;
}
.info-list li,
.next-list li {
display: flex;
gap: 12px;
color: var(--xboard-text-secondary);
line-height: 1.6;
}
.info-list li :deep(.el-icon) {
margin-top: 3px;
color: var(--xboard-text-muted);
}
.next-list {
padding-left: 18px;
}
.endpoint-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.endpoint-list code {
padding: 10px 14px;
border-radius: 999px;
background: #f5f5f7;
color: var(--xboard-text-secondary);
font-family: var(--xboard-font-mono);
font-size: 12px;
}
@media (max-width: 1080px) {
.placeholder-hero {
flex-direction: column;
}
.placeholder-summary {
min-width: 0;
grid-template-columns: 1fr;
}
}
</style>