feat(admin-frontend): 完成节点与礼品卡管理工作台

补齐节点管理真实新增、编辑与排序流程,接入权限组与路由组
维护页,并支持 11 种协议的动态配置表单

开放礼品卡管理入口,交付模板、兑换码、使用记录与统计四页签
工作台,接入 gift-card 相关后台接口

将知识库、权限组与路由管理从占位页升级为真实页面,并修复侧边栏
低高度裁切问题

修复仪表盘 24h 流量排行涨跌始终为 0 的问题,改为对比昨天整日统
计并补充单元测试
This commit is contained in:
yinjianm
2026-04-24 21:58:16 +08:00
parent f7cef30b9c
commit e393b11b61
80 changed files with 8811 additions and 278 deletions
+126
View File
@@ -9,12 +9,24 @@ import type {
AdminOrderDetail,
AdminOrderFetchParams,
AdminOrderListItem,
AdminGiftCardCodeGeneratePayload,
AdminGiftCardCodeItem,
AdminGiftCardCodeStatus,
AdminGiftCardCodeUpdatePayload,
AdminGiftCardStatistics,
AdminGiftCardTemplateItem,
AdminGiftCardTemplatePayload,
AdminGiftCardTemplateType,
AdminGiftCardUsageItem,
AdminKnowledgeDetail,
AdminKnowledgeListItem,
AdminKnowledgeSavePayload,
AdminNoticeItem,
AdminNoticeSavePayload,
AdminNodeItem,
AdminNodeSavePayload,
AdminNodeRouteItem,
AdminNodeRouteSavePayload,
AdminNodeUpdatePayload,
AdminPaymentConfigFields,
AdminPaymentListItem,
@@ -28,6 +40,7 @@ import type {
AdminPluginItem,
AdminPluginTypeItem,
AdminServerGroupItem,
AdminServerGroupSavePayload,
AdminTicketDetail,
AdminTicketFetchParams,
AdminTicketListItem,
@@ -209,6 +222,91 @@ export function deleteCoupon(id: number): Promise<ApiResponse<boolean>> {
return unwrapPost<boolean>('/coupon/drop', { id })
}
export function fetchGiftCardTemplates(params: {
page?: number
per_page?: number
type?: AdminGiftCardTemplateType
status?: 0 | 1
} = {}): Promise<AdminPaginationResult<AdminGiftCardTemplateItem>> {
return adminClient
.get<AdminPaginationResult<AdminGiftCardTemplateItem>>('/gift-card/templates', { params })
.then((res) => res.data)
}
export function createGiftCardTemplate(payload: AdminGiftCardTemplatePayload): Promise<ApiResponse<AdminGiftCardTemplateItem>> {
return unwrapPost<AdminGiftCardTemplateItem>('/gift-card/create-template', payload as unknown as Record<string, unknown>)
}
export function updateGiftCardTemplate(payload: AdminGiftCardTemplatePayload): Promise<ApiResponse<AdminGiftCardTemplateItem>> {
return unwrapPost<AdminGiftCardTemplateItem>('/gift-card/update-template', payload as unknown as Record<string, unknown>)
}
export function deleteGiftCardTemplate(id: number): Promise<ApiResponse<boolean>> {
return unwrapPost<boolean>('/gift-card/delete-template', { id })
}
export function fetchGiftCardCodes(params: {
page?: number
per_page?: number
template_id?: number
batch_id?: string
status?: AdminGiftCardCodeStatus
} = {}): Promise<AdminPaginationResult<AdminGiftCardCodeItem>> {
return adminClient
.get<AdminPaginationResult<AdminGiftCardCodeItem>>('/gift-card/codes', { params })
.then((res) => res.data)
}
export function generateGiftCardCodes(payload: AdminGiftCardCodeGeneratePayload): Promise<ApiResponse<{ batch_id: string, count: number, message: string }>> {
return unwrapPost<{ batch_id: string, count: number, message: string }>(
'/gift-card/generate-codes',
payload as unknown as Record<string, unknown>,
)
}
export function toggleGiftCardCode(id: number, action: 'disable' | 'enable'): Promise<ApiResponse<{ message: string }>> {
return unwrapPost<{ message: string }>('/gift-card/toggle-code', { id, action })
}
export function exportGiftCardCodes(batchId: string): Promise<Blob> {
return adminClient
.get<Blob>('/gift-card/export-codes', {
params: { batch_id: batchId },
responseType: 'blob',
})
.then((res) => res.data)
}
export function updateGiftCardCode(payload: AdminGiftCardCodeUpdatePayload): Promise<ApiResponse<AdminGiftCardCodeItem>> {
return unwrapPost<AdminGiftCardCodeItem>('/gift-card/update-code', payload as unknown as Record<string, unknown>)
}
export function deleteGiftCardCode(id: number): Promise<ApiResponse<{ message: string }>> {
return unwrapPost<{ message: string }>('/gift-card/delete-code', { id })
}
export function fetchGiftCardUsages(params: {
page?: number
per_page?: number
template_id?: number
user_id?: number
} = {}): Promise<AdminPaginationResult<AdminGiftCardUsageItem>> {
return adminClient
.get<AdminPaginationResult<AdminGiftCardUsageItem>>('/gift-card/usages', { params })
.then((res) => res.data)
}
export function getGiftCardStatistics(params: {
start_date?: string
end_date?: string
} = {}): Promise<ApiResponse<AdminGiftCardStatistics>> {
return unwrap<AdminGiftCardStatistics>('/gift-card/statistics', params)
}
export function getGiftCardTypes(): Promise<ApiResponse<Record<string, string>>> {
return unwrap<Record<string, string>>('/gift-card/types')
}
export function fetchAdminConfig(key?: AdminConfigGroupKey): Promise<ApiResponse<AdminConfigMappings>> {
return unwrap<AdminConfigMappings>('/config/fetch', key ? { key } : undefined)
}
@@ -380,14 +478,42 @@ export function getServerGroups(): Promise<ApiResponse<AdminServerGroupItem[]>>
return unwrap<AdminServerGroupItem[]>('/server/group/fetch')
}
export function saveServerGroup(payload: AdminServerGroupSavePayload): Promise<ApiResponse<boolean>> {
return unwrapPost<boolean>('/server/group/save', payload as unknown as Record<string, unknown>)
}
export function deleteServerGroup(id: number): Promise<ApiResponse<boolean>> {
return unwrapPost<boolean>('/server/group/drop', { id })
}
export function fetchNodes(): Promise<ApiResponse<AdminNodeItem[]>> {
return unwrap<AdminNodeItem[]>('/server/manage/getNodes')
}
export function fetchNodeRoutes(): Promise<ApiResponse<AdminNodeRouteItem[]>> {
return unwrap<AdminNodeRouteItem[]>('/server/route/fetch')
}
export function saveNodeRoute(payload: AdminNodeRouteSavePayload): Promise<ApiResponse<boolean>> {
return unwrapPost<boolean>('/server/route/save', payload as unknown as Record<string, unknown>)
}
export function deleteNodeRoute(id: number): Promise<ApiResponse<boolean>> {
return unwrapPost<boolean>('/server/route/drop', { id })
}
export function updateNode(payload: AdminNodeUpdatePayload): Promise<ApiResponse<boolean>> {
return unwrapPost<boolean>('/server/manage/update', payload as unknown as Record<string, unknown>)
}
export function saveNode(payload: AdminNodeSavePayload): Promise<ApiResponse<boolean>> {
return unwrapPost<boolean>('/server/manage/save', payload as unknown as Record<string, unknown>)
}
export function sortNodes(payload: Array<{ id: number; order: number }>): Promise<ApiResponse<boolean>> {
return unwrapPost<boolean>('/server/manage/sort', payload as unknown as Record<string, unknown>)
}
export function copyNode(id: number): Promise<ApiResponse<boolean>> {
return unwrapPost<boolean>('/server/manage/copy', { id })
}
+26 -13
View File
@@ -64,7 +64,7 @@ const subscriptionItems: MenuItem[] = [
{ index: '/subscriptions/plans', title: '套餐管理', icon: CollectionTag },
{ index: '/subscriptions/orders', title: '订单管理', icon: Document },
{ index: '/subscriptions/coupons', title: '优惠券管理', icon: Discount },
{ index: '/subscriptions/gift-cards', title: '礼品卡管理', icon: Present, disabled: true, badge: '即将开放' },
{ index: '/subscriptions/gift-cards', title: '礼品卡管理', icon: Present },
]
const systemManagementItems: MenuItem[] = [
@@ -116,19 +116,20 @@ onBeforeUnmount(() => {
</div>
</div>
<ElMenu
:default-active="route.path"
:default-openeds="['node-management', 'management', 'subscription', 'system-management']"
:collapse="app.sidebarCollapsed"
:collapse-transition="false"
router
class="admin-menu"
@select="handleMenuSelect"
<div class="aside-menu-scroll">
<ElMenu
:default-active="route.path"
:default-openeds="['node-management', 'management', 'subscription', 'system-management']"
:collapse="app.sidebarCollapsed"
:collapse-transition="false"
router
class="admin-menu"
@select="handleMenuSelect"
>
<ElMenuItem
v-for="item in menuItems"
:key="item.index"
:index="item.index"
:key="item.index"
:index="item.index"
>
<ElIcon><component :is="item.icon" /></ElIcon>
<template #title>{{ item.title }}</template>
@@ -203,7 +204,8 @@ onBeforeUnmount(() => {
<template #title>{{ item.title }}</template>
</ElMenuItem>
</ElSubMenu>
</ElMenu>
</ElMenu>
</div>
</ElAside>
<ElContainer class="admin-stage">
@@ -251,6 +253,7 @@ onBeforeUnmount(() => {
.admin-aside {
display: flex;
flex-direction: column;
min-height: 0;
background: #ffffff;
border-right: 1px solid rgba(0, 0, 0, 0.06);
overflow: hidden;
@@ -261,11 +264,21 @@ onBeforeUnmount(() => {
.aside-logo {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 12px;
padding: 12px 8px 20px;
}
.aside-menu-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding-right: 4px;
scrollbar-gutter: stable;
}
.aside-mark {
width: 36px;
height: 36px;
@@ -297,9 +310,9 @@ onBeforeUnmount(() => {
}
.admin-menu {
flex: 1;
background: #ffffff;
border-right: 0;
padding-bottom: 12px;
}
.admin-menu :deep(.el-menu-item) {
+6
View File
@@ -71,6 +71,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/subscriptions/CouponsView.vue'),
meta: { title: '优惠券管理', kicker: 'Coupons' },
},
{
path: 'subscriptions/gift-cards',
name: 'SubscriptionGiftCards',
component: () => import('@/views/subscriptions/GiftCardsView.vue'),
meta: { title: '礼品卡管理', kicker: 'Gift Cards' },
},
{
path: 'system/config',
name: 'SystemConfig',
+213 -3
View File
@@ -152,8 +152,13 @@ export interface AdminGroupOption {
}
export interface AdminServerGroupItem extends AdminGroupOption {
users_count?: number
server_count?: number
users_count?: number | string | null
server_count?: number | string | null
}
export interface AdminServerGroupSavePayload {
id?: number
name: string
}
export interface AdminPlanOption {
@@ -420,6 +425,147 @@ export interface AdminCouponGeneratePayload {
code?: string
}
export type AdminGiftCardTemplateType = 1 | 2 | 3
export type AdminGiftCardCodeStatus = 0 | 1 | 2 | 3
export interface AdminGiftCardTemplateConditions {
new_user_only?: boolean
new_user_max_days?: number | null
paid_user_only?: boolean
allowed_plans?: number[]
require_invite?: boolean
}
export interface AdminGiftCardTemplateRewards {
balance?: number | null
transfer_enable?: number | null
expire_days?: number | null
device_limit?: number | null
reset_package?: boolean
plan_id?: number | null
plan_validity_days?: number | null
invite_reward_rate?: number | null
random_rewards?: Array<Record<string, unknown>>
}
export interface AdminGiftCardTemplateLimits {
max_use_per_user?: number | null
cooldown_hours?: number | null
}
export interface AdminGiftCardTemplateSpecialConfig {
start_time?: number | null
end_time?: number | null
festival_bonus?: number | null
}
export interface AdminGiftCardTemplateItem {
id: number
name: string
description?: string | null
type: AdminGiftCardTemplateType
type_name: string
status: boolean | number
conditions?: AdminGiftCardTemplateConditions | null
rewards: AdminGiftCardTemplateRewards
limits?: AdminGiftCardTemplateLimits | null
special_config?: AdminGiftCardTemplateSpecialConfig | null
icon?: string | null
background_image?: string | null
theme_color?: string | null
sort?: number | null
admin_id?: number | null
created_at?: number | string | null
updated_at?: number | string | null
codes_count?: number
used_count?: number
}
export interface AdminGiftCardTemplatePayload {
id?: number
name: string
description?: string | null
type: AdminGiftCardTemplateType
status: boolean
conditions?: AdminGiftCardTemplateConditions
rewards: AdminGiftCardTemplateRewards
limits?: AdminGiftCardTemplateLimits
special_config?: AdminGiftCardTemplateSpecialConfig
icon?: string | null
background_image?: string | null
theme_color?: string | null
sort?: number
}
export interface AdminGiftCardCodeItem {
id: number
template_id: number
template_name: string
code: string
batch_id?: string | null
status: AdminGiftCardCodeStatus
status_name: string
user_id?: number | null
user_email?: string | null
used_at?: number | string | null
expires_at?: number | string | null
usage_count: number
max_usage: number
created_at?: number | string | null
}
export interface AdminGiftCardCodeGeneratePayload {
template_id: number
count: number
prefix?: string
expires_hours?: number | null
max_usage?: number
}
export interface AdminGiftCardCodeUpdatePayload {
id: number
expires_at?: number | null
max_usage?: number
status?: AdminGiftCardCodeStatus
}
export interface AdminGiftCardUsageItem {
id: number
code: string
template_name: string
user_email: string
invite_user_email?: string | null
rewards_given?: Record<string, unknown> | null
invite_rewards?: Record<string, unknown> | null
multiplier_applied?: number | null
created_at?: number | string | null
}
export interface AdminGiftCardStatisticsTotal {
templates_count: number
active_templates_count: number
codes_count: number
used_codes_count: number
usages_count: number
}
export interface AdminGiftCardDailyUsage {
date: string
count: number
}
export interface AdminGiftCardTypeStat {
template_name: string
type_name: string
count: number
}
export interface AdminGiftCardStatistics {
total_stats: AdminGiftCardStatisticsTotal
daily_usages: AdminGiftCardDailyUsage[]
type_stats: AdminGiftCardTypeStat[]
}
export interface AdminUserRef {
id: number
email: string
@@ -638,11 +784,44 @@ export interface AdminTrafficLogResult extends AdminPaginationResult<AdminTraffi
summary: TrafficAmount
}
export type AdminNodeRouteAction = 'block' | 'direct' | 'dns' | 'proxy'
export interface AdminNodeRouteItem {
id: number
remarks: string
match: string[]
action: AdminNodeRouteAction
action_value?: string | null
created_at?: number | string | null
updated_at?: number | string | null
}
export interface AdminNodeRouteSavePayload {
id?: number
remarks: string
match: string[]
action: AdminNodeRouteAction
action_value?: string | null
}
export interface AdminNodeParentRef {
id: number
name: string
}
export type AdminNodeType =
| 'shadowsocks'
| 'vmess'
| 'trojan'
| 'hysteria'
| 'vless'
| 'tuic'
| 'socks'
| 'naive'
| 'http'
| 'mieru'
| 'anytls'
export interface AdminNodeMetrics {
active_connections?: number
active_users?: number
@@ -650,20 +829,31 @@ export interface AdminNodeMetrics {
updated_at?: number
}
export interface AdminNodeRateTimeRange {
start: string
end: string
rate: number
}
export interface AdminNodeItem {
id: number
name: string
type: string
type: AdminNodeType | string
code?: string | null
host: string
port: number | string | null
server_port?: number | null
group_ids?: Array<number | string> | null
route_ids?: Array<number | string> | null
tags?: string[] | null
show: boolean
enabled?: boolean
parent_id?: number | null
rate?: number | null
rate_time_enable?: boolean
rate_time_ranges?: AdminNodeRateTimeRange[] | null
sort?: number | null
protocol_settings?: Record<string, unknown> | null
online: number
online_conn: number
is_online: number
@@ -682,6 +872,26 @@ export interface AdminNodeUpdatePayload {
machine_id?: number | null
}
export interface AdminNodeSavePayload {
id?: number
type: AdminNodeType
code?: string
name: string
group_ids?: number[]
route_ids?: number[]
parent_id?: number | null
enabled?: boolean
host: string
port: number | string
server_port: number | string
tags?: string[]
rate: number
rate_time_enable?: boolean
rate_time_ranges?: AdminNodeRateTimeRange[]
protocol_settings?: Record<string, unknown>
show?: boolean | number
}
declare global {
interface Window {
settings?: {
+2
View File
@@ -15,6 +15,7 @@ declare module 'vue' {
ElAside: typeof import('element-plus/es')['ElAside']
ElButton: typeof import('element-plus/es')['ElButton']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDialog: typeof import('element-plus/es')['ElDialog']
@@ -43,6 +44,7 @@ declare module 'vue' {
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTag: typeof import('element-plus/es')['ElTag']
ElTimePicker: typeof import('element-plus/es')['ElTimePicker']
ElUpload: typeof import('element-plus/es')['ElUpload']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
+434
View File
@@ -0,0 +1,434 @@
import type {
AdminGiftCardCodeItem,
AdminGiftCardCodeStatus,
AdminGiftCardTemplateItem,
AdminGiftCardTemplatePayload,
AdminGiftCardTemplateType,
AdminGiftCardUsageItem,
AdminPlanOption,
} from '@/types/api'
export type GiftCardTemplateStatusFilter = 'all' | 'enabled' | 'disabled'
export type GiftCardCodeStatusFilter = 'all' | AdminGiftCardCodeStatus
export interface GiftCardOption<T extends string | number> {
label: string
value: T
}
export interface GiftCardStatusMeta {
label: string
tone: 'success' | 'warning' | 'danger' | 'info' | 'neutral'
}
export interface GiftCardTemplateFormModel {
id?: number
name: string
description: string
type: AdminGiftCardTemplateType
status: boolean
sort: number
theme_color: string
icon: string
background_image: string
balance_yuan: number | null
transfer_gb: number | null
expire_days: number | null
device_limit: number | null
reset_package: boolean
plan_id: number | null
plan_validity_days: number | null
invite_reward_rate: number | null
new_user_only: boolean
new_user_max_days: number | null
paid_user_only: boolean
require_invite: boolean
allowed_plan_ids: number[]
max_use_per_user: number | null
cooldown_hours: number | null
festival_bonus: number | null
festival_start_at: number | null
festival_end_at: number | null
}
const CURRENCY_FORMATTER = new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
const DATE_TIME_FORMATTER = new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
export const DEFAULT_GIFT_CARD_TYPE_OPTIONS: Array<GiftCardOption<AdminGiftCardTemplateType>> = [
{ label: '通用礼品卡', value: 1 },
{ label: '套餐礼品卡', value: 2 },
{ label: '盲盒礼品卡', value: 3 },
]
export const GIFT_CARD_CODE_STATUS_OPTIONS: Array<GiftCardOption<AdminGiftCardCodeStatus>> = [
{ label: '未使用', value: 0 },
{ label: '已使用', value: 1 },
{ label: '已过期', value: 2 },
{ label: '已禁用', value: 3 },
]
function toNumber(value: unknown): number | null {
if (value === null || value === undefined || value === '') {
return null
}
const numeric = Number(value)
return Number.isFinite(numeric) ? numeric : null
}
function toTimestampMilliseconds(value: number | string | null | undefined): number | null {
if (value === null || value === undefined || value === '') {
return null
}
const numeric = Number(value)
if (Number.isFinite(numeric)) {
return numeric > 1_000_000_000_000 ? numeric : numeric * 1000
}
const parsed = Date.parse(String(value))
return Number.isFinite(parsed) ? parsed : null
}
function cleanObject<T extends Record<string, unknown>>(input: T): T | undefined {
const entries = Object.entries(input).filter(([, value]) => {
if (value === null || value === undefined || value === '') {
return false
}
if (Array.isArray(value)) {
return value.length > 0
}
return true
})
if (entries.length === 0) {
return undefined
}
return Object.fromEntries(entries) as T
}
function normalizeAllowedPlanIds(value: unknown): number[] {
if (!Array.isArray(value)) {
return []
}
return value
.map((item) => Number(item))
.filter((item) => Number.isFinite(item) && item > 0)
}
export function buildGiftCardTypeOptions(typeMap?: Record<string, string> | null): Array<GiftCardOption<AdminGiftCardTemplateType>> {
if (!typeMap || Object.keys(typeMap).length === 0) {
return DEFAULT_GIFT_CARD_TYPE_OPTIONS
}
return Object.entries(typeMap)
.map(([value, label]) => ({
label,
value: Number(value) as AdminGiftCardTemplateType,
}))
.filter((item) => Number.isFinite(item.value))
}
export function createGiftCardTemplateFormModel(): GiftCardTemplateFormModel {
return {
name: '',
description: '',
type: 1,
status: true,
sort: 0,
theme_color: '#0071e3',
icon: '',
background_image: '',
balance_yuan: null,
transfer_gb: null,
expire_days: null,
device_limit: null,
reset_package: false,
plan_id: null,
plan_validity_days: null,
invite_reward_rate: null,
new_user_only: false,
new_user_max_days: 7,
paid_user_only: false,
require_invite: false,
allowed_plan_ids: [],
max_use_per_user: null,
cooldown_hours: null,
festival_bonus: null,
festival_start_at: null,
festival_end_at: null,
}
}
export function centsToYuan(value: unknown): number | null {
const numeric = toNumber(value)
if (numeric === null) {
return null
}
return Number((numeric / 100).toFixed(2))
}
export function yuanToCents(value: unknown): number | null {
const numeric = toNumber(value)
if (numeric === null) {
return null
}
return Math.round(numeric * 100)
}
export function bytesToGb(value: unknown): number | null {
const numeric = toNumber(value)
if (numeric === null) {
return null
}
return Number((numeric / 1024 / 1024 / 1024).toFixed(2))
}
export function gbToBytes(value: unknown): number | null {
const numeric = toNumber(value)
if (numeric === null) {
return null
}
return Math.round(numeric * 1024 * 1024 * 1024)
}
export function formatGiftCardDateTime(value: number | string | null | undefined): string {
const timestamp = toTimestampMilliseconds(value)
if (timestamp === null) {
return '-'
}
return DATE_TIME_FORMATTER.format(new Date(timestamp))
}
export function formatGiftCardCurrency(value: unknown): string {
const yuan = centsToYuan(value)
return yuan === null ? '-' : CURRENCY_FORMATTER.format(yuan)
}
export function formatGiftCardTraffic(value: unknown): string {
const gb = bytesToGb(value)
if (gb === null) {
return '-'
}
return `${gb} GB`
}
export function formatGiftCardMultiplier(value: unknown): string {
const numeric = toNumber(value)
if (numeric === null) {
return '-'
}
return `${numeric.toFixed(2)}x`
}
export function formatGiftCardTypeLabel(value: AdminGiftCardTemplateType, options = DEFAULT_GIFT_CARD_TYPE_OPTIONS): string {
return options.find((item) => item.value === value)?.label ?? '未知类型'
}
export function getGiftCardCodeStatusMeta(status: AdminGiftCardCodeStatus | null | undefined): GiftCardStatusMeta {
switch (status) {
case 0:
return { label: '未使用', tone: 'info' }
case 1:
return { label: '已使用', tone: 'success' }
case 2:
return { label: '已过期', tone: 'warning' }
case 3:
return { label: '已禁用', tone: 'danger' }
default:
return { label: '未知状态', tone: 'neutral' }
}
}
export function getGiftCardTemplateRewardSummary(template: AdminGiftCardTemplateItem, plans: AdminPlanOption[] = []): string[] {
const rewards = template.rewards ?? {}
const summary: string[] = []
if (toNumber(rewards.balance) && toNumber(rewards.balance)! > 0) {
summary.push(`余额: ${formatGiftCardCurrency(rewards.balance)}`)
}
if (toNumber(rewards.transfer_enable) && toNumber(rewards.transfer_enable)! > 0) {
summary.push(`流量: ${formatGiftCardTraffic(rewards.transfer_enable)}`)
}
if (toNumber(rewards.expire_days) && toNumber(rewards.expire_days)! > 0) {
summary.push(`有效期: ${rewards.expire_days}`)
}
if (toNumber(rewards.device_limit) && toNumber(rewards.device_limit)! > 0) {
summary.push(`设备数: ${rewards.device_limit}`)
}
if (rewards.reset_package) {
summary.push('重置当月流量')
}
if (toNumber(rewards.plan_id)) {
const plan = plans.find((item) => item.id === Number(rewards.plan_id))
summary.push(`套餐: ${plan?.name || `#${rewards.plan_id}`}`)
if (toNumber(rewards.plan_validity_days) && toNumber(rewards.plan_validity_days)! > 0) {
summary.push(`套餐有效期: ${rewards.plan_validity_days}`)
}
}
if (toNumber(rewards.invite_reward_rate) && toNumber(rewards.invite_reward_rate)! > 0) {
summary.push(`邀请奖励: ${(Number(rewards.invite_reward_rate) * 100).toFixed(0)}%`)
}
return summary.length > 0 ? summary : ['暂无奖励配置']
}
export function getGiftCardAvailableUsage(code: Pick<AdminGiftCardCodeItem, 'max_usage' | 'usage_count'>): number {
return Math.max(0, (code.max_usage ?? 0) - (code.usage_count ?? 0))
}
export function filterGiftCardTemplates(
templates: AdminGiftCardTemplateItem[],
keyword: string,
type: AdminGiftCardTemplateType | 'all',
status: GiftCardTemplateStatusFilter,
): AdminGiftCardTemplateItem[] {
const normalizedKeyword = keyword.trim().toLowerCase()
return templates.filter((item) => {
const matchesKeyword = !normalizedKeyword
|| [item.name, item.description, item.type_name]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(normalizedKeyword))
const matchesType = type === 'all' || item.type === type
const matchesStatus = status === 'all'
|| (status === 'enabled' && Boolean(item.status))
|| (status === 'disabled' && !Boolean(item.status))
return matchesKeyword && matchesType && matchesStatus
})
}
export function filterGiftCardCodes(
codes: AdminGiftCardCodeItem[],
keyword: string,
templateId: number | 'all',
status: GiftCardCodeStatusFilter,
): AdminGiftCardCodeItem[] {
const normalizedKeyword = keyword.trim().toLowerCase()
return codes.filter((item) => {
const matchesKeyword = !normalizedKeyword
|| [item.code, item.template_name, item.batch_id, item.user_email]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(normalizedKeyword))
const matchesTemplate = templateId === 'all' || item.template_id === templateId
const matchesStatus = status === 'all' || item.status === status
return matchesKeyword && matchesTemplate && matchesStatus
})
}
export function filterGiftCardUsages(usages: AdminGiftCardUsageItem[], keyword: string): AdminGiftCardUsageItem[] {
const normalizedKeyword = keyword.trim().toLowerCase()
return usages.filter((item) => {
if (!normalizedKeyword) {
return true
}
return [item.user_email, item.template_name, item.code, item.invite_user_email]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(normalizedKeyword))
})
}
export function toGiftCardTemplateFormModel(template?: AdminGiftCardTemplateItem | null): GiftCardTemplateFormModel {
const base = createGiftCardTemplateFormModel()
if (!template) {
return base
}
return {
id: template.id,
name: template.name,
description: template.description ?? '',
type: template.type,
status: Boolean(template.status),
sort: Number(template.sort ?? 0),
theme_color: template.theme_color || '#0071e3',
icon: template.icon ?? '',
background_image: template.background_image ?? '',
balance_yuan: centsToYuan(template.rewards?.balance),
transfer_gb: bytesToGb(template.rewards?.transfer_enable),
expire_days: toNumber(template.rewards?.expire_days),
device_limit: toNumber(template.rewards?.device_limit),
reset_package: Boolean(template.rewards?.reset_package),
plan_id: toNumber(template.rewards?.plan_id),
plan_validity_days: toNumber(template.rewards?.plan_validity_days),
invite_reward_rate: toNumber(template.rewards?.invite_reward_rate),
new_user_only: Boolean(template.conditions?.new_user_only),
new_user_max_days: toNumber(template.conditions?.new_user_max_days) ?? 7,
paid_user_only: Boolean(template.conditions?.paid_user_only),
require_invite: Boolean(template.conditions?.require_invite),
allowed_plan_ids: normalizeAllowedPlanIds(template.conditions?.allowed_plans),
max_use_per_user: toNumber(template.limits?.max_use_per_user),
cooldown_hours: toNumber(template.limits?.cooldown_hours),
festival_bonus: toNumber(template.special_config?.festival_bonus),
festival_start_at: toNumber(template.special_config?.start_time),
festival_end_at: toNumber(template.special_config?.end_time),
}
}
export function toGiftCardTemplatePayload(form: GiftCardTemplateFormModel): AdminGiftCardTemplatePayload {
const conditions = cleanObject({
new_user_only: form.new_user_only || undefined,
new_user_max_days: form.new_user_only ? toNumber(form.new_user_max_days) : undefined,
paid_user_only: form.paid_user_only || undefined,
allowed_plans: normalizeAllowedPlanIds(form.allowed_plan_ids),
require_invite: form.require_invite || undefined,
})
const rewards = cleanObject({
balance: yuanToCents(form.balance_yuan),
transfer_enable: gbToBytes(form.transfer_gb),
expire_days: toNumber(form.expire_days),
device_limit: toNumber(form.device_limit),
reset_package: form.reset_package || undefined,
plan_id: form.type === 2 ? toNumber(form.plan_id) : undefined,
plan_validity_days: form.type === 2 ? toNumber(form.plan_validity_days) : undefined,
invite_reward_rate: toNumber(form.invite_reward_rate),
}) ?? {}
const limits = cleanObject({
max_use_per_user: toNumber(form.max_use_per_user),
cooldown_hours: toNumber(form.cooldown_hours),
})
const special_config = cleanObject({
festival_bonus: toNumber(form.festival_bonus),
start_time: toNumber(form.festival_start_at),
end_time: toNumber(form.festival_end_at),
})
return {
id: form.id,
name: form.name.trim(),
description: form.description.trim() || null,
type: form.type,
status: Boolean(form.status),
conditions,
rewards,
limits,
special_config,
icon: form.icon.trim() || null,
background_image: form.background_image.trim() || null,
theme_color: form.theme_color.trim() || '#0071e3',
sort: Math.max(0, Number(form.sort || 0)),
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from './nodeEditorOptions'
export * from './nodeEditorMapper'
@@ -0,0 +1,484 @@
import type {
AdminNodeItem,
AdminNodeRateTimeRange,
AdminNodeSavePayload,
AdminNodeType,
} from '@/types/api'
import { createEmptyNodeForm, type NodeFormModel } from './nodeEditorOptions'
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
function toRecord(value: unknown): Record<string, unknown> {
return isRecord(value) ? value : {}
}
function toStringValue(value: unknown): string {
return typeof value === 'string' ? value : value === null || value === undefined ? '' : String(value)
}
function toNumberValue(value: unknown, fallback = 0): number {
const normalized = Number(value)
return Number.isFinite(normalized) ? normalized : fallback
}
function toNullableNumber(value: unknown): number | null {
const normalized = Number(value)
return Number.isFinite(normalized) ? normalized : null
}
function toBooleanValue(value: unknown, fallback = false): boolean {
if (typeof value === 'boolean') return value
if (typeof value === 'number') return value !== 0
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase()
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true
if (['0', 'false', 'no', 'off', ''].includes(normalized)) return false
}
return fallback
}
function toStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value
.map((item) => toStringValue(item).trim())
.filter(Boolean)
}
function toNumberArray(value: unknown): number[] {
if (!Array.isArray(value)) return []
return [...new Set(
value
.map((item) => Number(item))
.filter((item) => Number.isFinite(item)),
)]
}
function splitMultiline(value: string): string[] {
return [...new Set(
value
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean),
)]
}
function splitInlineList(value: string): string[] {
return [...new Set(
value
.split(/[,\n]/)
.map((item) => item.trim())
.filter(Boolean),
)]
}
function joinInlineList(value: unknown): string {
return toStringArray(value).join(', ')
}
function joinMultilineList(value: unknown): string {
return toStringArray(value).join('\n')
}
function cloneProtocolSettings(value: unknown): Record<string, unknown> {
if (!isRecord(value)) return {}
try {
return JSON.parse(JSON.stringify(value)) as Record<string, unknown>
} catch {
return { ...value }
}
}
export function sortNodesByOrder(nodes: AdminNodeItem[]): AdminNodeItem[] {
return [...nodes].sort((left, right) => {
const leftSort = Number(left.sort ?? Number.MAX_SAFE_INTEGER)
const rightSort = Number(right.sort ?? Number.MAX_SAFE_INTEGER)
if (leftSort !== rightSort) {
return leftSort - rightSort
}
return left.id - right.id
})
}
export function moveNodeOrder<T>(items: T[], fromIndex: number, direction: -1 | 1): T[] {
const targetIndex = fromIndex + direction
if (targetIndex < 0 || targetIndex >= items.length) {
return items
}
const next = [...items]
const [current] = next.splice(fromIndex, 1)
next.splice(targetIndex, 0, current)
return next
}
export function toNodeFormModel(node?: AdminNodeItem | null): NodeFormModel {
const form = createEmptyNodeForm()
if (!node) {
return form
}
const protocolSettings = cloneProtocolSettings(node.protocol_settings)
const tlsSettings = toRecord(protocolSettings.tls_settings)
const tlsObject = toRecord(protocolSettings.tls)
const realitySettings = toRecord(protocolSettings.reality_settings)
const utlsSettings = toRecord(protocolSettings.utls)
const multiplexSettings = toRecord(protocolSettings.multiplex)
const multiplexBrutal = toRecord(multiplexSettings.brutal)
const networkSettings = toRecord(protocolSettings.network_settings)
const tcpHeader = toRecord(networkSettings.header)
const tcpRequest = toRecord(tcpHeader.request)
const tcpHeaders = toRecord(tcpRequest.headers)
const wsHeaders = toRecord(networkSettings.headers)
const echFromTls = toRecord(tlsSettings.ech)
const echFromTlsObject = toRecord(tlsObject.ech)
const hysteriaObfs = toRecord(protocolSettings.obfs)
const hysteriaBandwidth = toRecord(protocolSettings.bandwidth)
const encryption = toRecord(protocolSettings.encryption)
form.id = node.id
form.originalType = (node.type as AdminNodeType) ?? ''
form.type = (node.type as AdminNodeType) ?? ''
form.rawProtocolSettings = protocolSettings
form.name = toStringValue(node.name)
form.code = toStringValue(node.code)
form.rate = toNumberValue(node.rate, 1)
form.rateTimeEnable = toBooleanValue(node.rate_time_enable)
form.rateTimeRanges = Array.isArray(node.rate_time_ranges) && node.rate_time_ranges.length > 0
? node.rate_time_ranges.map((item, index) => ({
key: `range-${node.id}-${index}`,
start: toStringValue(item.start),
end: toStringValue(item.end),
rate: toNumberValue(item.rate, 1),
}))
: createEmptyNodeForm().rateTimeRanges
form.tags = toStringArray(node.tags)
form.groupIds = toNumberArray(node.group_ids)
form.routeIds = toNumberArray(node.route_ids)
form.host = toStringValue(node.host)
form.port = toStringValue(node.port)
form.serverPort = toStringValue(node.server_port)
form.parentId = node.parent_id ?? null
form.show = toBooleanValue(node.show, true)
form.enabled = toBooleanValue(node.enabled, true)
form.tlsMode = Number(protocolSettings.tls ?? 0)
form.tlsServerName = toStringValue(tlsSettings.server_name || tlsObject.server_name)
form.tlsAllowInsecure = toBooleanValue(tlsSettings.allow_insecure ?? tlsObject.allow_insecure)
form.echEnabled = toBooleanValue(echFromTls.enabled ?? echFromTlsObject.enabled)
form.echConfig = toStringValue(echFromTls.config ?? echFromTlsObject.config)
form.echQueryServerName = toStringValue(echFromTls.query_server_name ?? echFromTlsObject.query_server_name)
form.echKey = toStringValue(echFromTls.key ?? echFromTlsObject.key)
form.utlsEnabled = toBooleanValue(utlsSettings.enabled)
form.utlsFingerprint = toStringValue(utlsSettings.fingerprint || 'chrome')
form.realityServerName = toStringValue(realitySettings.server_name)
form.realityServerPort = toStringValue(realitySettings.server_port)
form.realityPublicKey = toStringValue(realitySettings.public_key)
form.realityPrivateKey = toStringValue(realitySettings.private_key)
form.realityShortId = toStringValue(realitySettings.short_id)
form.network = toStringValue(protocolSettings.network)
form.tcpHeaderType = toStringValue(tcpHeader.type || 'none')
form.tcpRequestPath = joinMultilineList(tcpRequest.path)
form.tcpRequestHost = joinInlineList(tcpHeaders.Host)
form.wsPath = toStringValue(networkSettings.path)
form.wsHost = toStringValue(wsHeaders.Host)
form.grpcServiceName = toStringValue(networkSettings.serviceName)
form.h2Path = toStringValue(networkSettings.path)
form.h2Host = joinInlineList(networkSettings.host)
form.httpupgradePath = toStringValue(networkSettings.path)
form.httpupgradeHost = toStringValue(networkSettings.host)
form.xhttpPath = toStringValue(networkSettings.path)
form.xhttpHost = toStringValue(networkSettings.host)
form.xhttpMode = toStringValue(networkSettings.mode || 'auto')
form.xhttpExtra = networkSettings.extra ? JSON.stringify(networkSettings.extra, null, 2) : ''
form.kcpSeed = toStringValue(networkSettings.seed)
form.kcpHeaderType = toStringValue(toRecord(networkSettings.header).type || 'none')
form.shadowsocksCipher = toStringValue(protocolSettings.cipher || '2022-blake3-aes-128-gcm')
form.shadowsocksObfs = toStringValue(protocolSettings.obfs)
form.shadowsocksObfsHost = toStringValue(toRecord(protocolSettings.obfs_settings).host)
form.shadowsocksObfsPath = toStringValue(toRecord(protocolSettings.obfs_settings).path)
form.shadowsocksPlugin = toStringValue(protocolSettings.plugin)
form.shadowsocksPluginOpts = toStringValue(protocolSettings.plugin_opts)
form.vlessFlow = toStringValue(protocolSettings.flow)
form.vlessEncryptionEnabled = toBooleanValue(encryption.enabled)
form.vlessEncryption = toStringValue(encryption.encryption)
form.vlessDecryption = toStringValue(encryption.decryption)
form.hysteriaVersion = toNumberValue(protocolSettings.version, 2)
form.hysteriaUpMbps = toNullableNumber(hysteriaBandwidth.up)
form.hysteriaDownMbps = toNullableNumber(hysteriaBandwidth.down)
form.hysteriaObfsEnabled = toBooleanValue(hysteriaObfs.open)
form.hysteriaObfsType = toStringValue(hysteriaObfs.type || 'salamander')
form.hysteriaObfsPassword = toStringValue(hysteriaObfs.password)
form.hysteriaHopInterval = toNullableNumber(protocolSettings.hop_interval)
form.tuicVersion = toNullableNumber(protocolSettings.version) ?? 5
form.tuicCongestionControl = toStringValue(protocolSettings.congestion_control || 'cubic')
form.tuicAlpn = toStringArray(protocolSettings.alpn).length ? toStringArray(protocolSettings.alpn) : ['h3']
form.tuicUdpRelayMode = toStringValue(protocolSettings.udp_relay_mode || 'native')
form.mieruTransport = toStringValue(protocolSettings.transport || 'TCP')
form.mieruTrafficPattern = toStringValue(protocolSettings.traffic_pattern)
form.anytlsPaddingSchemeText = joinMultilineList(protocolSettings.padding_scheme) || form.anytlsPaddingSchemeText
form.multiplexEnabled = toBooleanValue(multiplexSettings.enabled)
form.multiplexProtocol = toStringValue(multiplexSettings.protocol || 'yamux')
form.multiplexMaxConnections = toNullableNumber(multiplexSettings.max_connections)
form.multiplexPadding = toBooleanValue(multiplexSettings.padding)
form.multiplexBrutalEnabled = toBooleanValue(multiplexBrutal.enabled)
form.multiplexBrutalUpMbps = toNullableNumber(multiplexBrutal.up_mbps)
form.multiplexBrutalDownMbps = toNullableNumber(multiplexBrutal.down_mbps)
return form
}
function buildTlsEchPayload(form: NodeFormModel): Record<string, unknown> | undefined {
if (!form.echEnabled) return undefined
return {
enabled: true,
config: form.echConfig.trim() || undefined,
query_server_name: form.echQueryServerName.trim() || undefined,
key: form.echKey.trim() || undefined,
}
}
function buildTlsSettingsPayload(form: NodeFormModel): Record<string, unknown> {
return {
server_name: form.tlsServerName.trim() || undefined,
allow_insecure: form.tlsAllowInsecure,
ech: buildTlsEchPayload(form),
}
}
function buildTlsObjectPayload(form: NodeFormModel): Record<string, unknown> {
return {
server_name: form.tlsServerName.trim() || undefined,
allow_insecure: form.tlsAllowInsecure,
ech: buildTlsEchPayload(form),
}
}
function buildRealityPayload(form: NodeFormModel): Record<string, unknown> {
return {
server_name: form.realityServerName.trim() || undefined,
server_port: form.realityServerPort.trim() ? Number(form.realityServerPort) : undefined,
public_key: form.realityPublicKey.trim() || undefined,
private_key: form.realityPrivateKey.trim() || undefined,
short_id: form.realityShortId.trim() || undefined,
allow_insecure: form.tlsAllowInsecure,
}
}
function buildUtlsPayload(form: NodeFormModel): Record<string, unknown> {
return {
enabled: form.utlsEnabled,
fingerprint: form.utlsEnabled ? form.utlsFingerprint : undefined,
}
}
function buildMultiplexPayload(form: NodeFormModel): Record<string, unknown> {
return {
enabled: form.multiplexEnabled,
protocol: form.multiplexProtocol,
max_connections: form.multiplexEnabled ? form.multiplexMaxConnections ?? undefined : undefined,
padding: form.multiplexPadding,
brutal: {
enabled: form.multiplexBrutalEnabled,
up_mbps: form.multiplexBrutalEnabled ? form.multiplexBrutalUpMbps ?? undefined : undefined,
down_mbps: form.multiplexBrutalDownMbps ? form.multiplexBrutalDownMbps : undefined,
},
}
}
function buildNetworkSettingsPayload(form: NodeFormModel): Record<string, unknown> | undefined {
switch (form.network) {
case 'tcp':
return form.tcpHeaderType === 'http'
? {
header: {
type: 'http',
request: {
path: splitMultiline(form.tcpRequestPath),
headers: { Host: splitInlineList(form.tcpRequestHost) },
},
},
}
: { header: { type: 'none' } }
case 'ws':
return {
path: form.wsPath.trim() || undefined,
headers: form.wsHost.trim() ? { Host: form.wsHost.trim() } : undefined,
}
case 'grpc':
return { serviceName: form.grpcServiceName.trim() || undefined }
case 'h2':
return {
path: form.h2Path.trim() || undefined,
host: splitInlineList(form.h2Host),
}
case 'httpupgrade':
return {
path: form.httpupgradePath.trim() || undefined,
host: form.httpupgradeHost.trim() || undefined,
}
case 'xhttp':
return {
path: form.xhttpPath.trim() || undefined,
host: form.xhttpHost.trim() || undefined,
mode: form.xhttpMode.trim() || 'auto',
extra: form.xhttpExtra.trim() ? JSON.parse(form.xhttpExtra) : undefined,
}
case 'kcp':
return {
seed: form.kcpSeed.trim() || undefined,
header: { type: form.kcpHeaderType },
}
case 'quic':
return { security: 'none' }
default:
return undefined
}
}
function buildRateRanges(form: NodeFormModel): AdminNodeRateTimeRange[] {
return form.rateTimeRanges
.map((item) => ({
start: item.start.trim(),
end: item.end.trim(),
rate: toNumberValue(item.rate, 1),
}))
.filter((item) => item.start && item.end && Number.isFinite(item.rate) && item.rate > 0)
}
function buildProtocolSettings(form: NodeFormModel): Record<string, unknown> {
const preserved = form.type === form.originalType ? cloneProtocolSettings(form.rawProtocolSettings) : {}
const networkSettings = buildNetworkSettingsPayload(form)
const tlsSettings = buildTlsSettingsPayload(form)
const tlsObject = buildTlsObjectPayload(form)
const realitySettings = buildRealityPayload(form)
const utls = buildUtlsPayload(form)
const multiplex = buildMultiplexPayload(form)
switch (form.type) {
case 'shadowsocks':
return {
...preserved,
cipher: form.shadowsocksCipher,
obfs: form.shadowsocksObfs || undefined,
obfs_settings: form.shadowsocksObfs
? {
host: form.shadowsocksObfsHost.trim() || undefined,
path: form.shadowsocksObfsPath.trim() || undefined,
}
: undefined,
plugin: form.shadowsocksPlugin.trim() || undefined,
plugin_opts: form.shadowsocksPluginOpts.trim() || undefined,
}
case 'vmess':
return {
...preserved,
tls: form.tlsMode,
network: form.network,
network_settings: networkSettings,
tls_settings: tlsSettings,
rules: Array.isArray(preserved.rules) ? preserved.rules : [],
utls,
multiplex,
}
case 'trojan':
return {
...preserved,
tls: form.tlsMode,
network: form.network,
network_settings: networkSettings,
server_name: form.tlsMode === 1 ? form.tlsServerName.trim() || undefined : undefined,
allow_insecure: form.tlsMode === 1 ? form.tlsAllowInsecure : undefined,
tls_settings: tlsSettings,
reality_settings: form.tlsMode === 2 ? realitySettings : undefined,
utls,
multiplex,
}
case 'hysteria':
return {
...preserved,
version: form.hysteriaVersion,
bandwidth: {
up: form.hysteriaUpMbps ?? undefined,
down: form.hysteriaDownMbps ?? undefined,
},
obfs: {
open: form.hysteriaObfsEnabled,
type: form.hysteriaObfsType.trim() || 'salamander',
password: form.hysteriaObfsEnabled ? form.hysteriaObfsPassword.trim() || undefined : undefined,
},
tls: tlsObject,
hop_interval: form.hysteriaHopInterval ?? undefined,
}
case 'vless':
return {
...preserved,
tls: form.tlsMode,
tls_settings: tlsSettings,
flow: form.vlessFlow.trim() || undefined,
encryption: {
enabled: form.vlessEncryptionEnabled,
encryption: form.vlessEncryptionEnabled ? form.vlessEncryption.trim() || undefined : undefined,
decryption: form.vlessEncryptionEnabled ? form.vlessDecryption.trim() || undefined : undefined,
},
network: form.network,
network_settings: networkSettings,
reality_settings: form.tlsMode === 2 ? realitySettings : undefined,
utls,
multiplex,
}
case 'socks':
case 'naive':
case 'http':
return {
...preserved,
tls: form.tlsMode,
tls_settings: tlsSettings,
}
case 'tuic':
return {
...preserved,
version: form.tuicVersion ?? undefined,
congestion_control: form.tuicCongestionControl.trim() || 'cubic',
alpn: form.tuicAlpn.length ? form.tuicAlpn : ['h3'],
udp_relay_mode: form.tuicUdpRelayMode.trim() || 'native',
tls: tlsObject,
}
case 'mieru':
return {
...preserved,
transport: form.mieruTransport,
traffic_pattern: form.mieruTrafficPattern.trim(),
multiplex,
}
case 'anytls':
return {
...preserved,
padding_scheme: splitMultiline(form.anytlsPaddingSchemeText),
tls: tlsObject,
}
default:
return preserved
}
}
export function toNodeSavePayload(form: NodeFormModel): AdminNodeSavePayload {
return {
id: form.id,
type: form.type as AdminNodeType,
code: form.code.trim() || undefined,
name: form.name.trim(),
group_ids: [...new Set(form.groupIds)],
route_ids: [...new Set(form.routeIds)],
parent_id: form.parentId ?? undefined,
enabled: form.enabled,
host: form.host.trim(),
port: form.port.trim(),
server_port: form.serverPort.trim(),
tags: [...new Set(form.tags.map((item) => item.trim()).filter(Boolean))],
rate: Math.max(0.01, Number(form.rate) || 1),
rate_time_enable: form.rateTimeEnable,
rate_time_ranges: form.rateTimeEnable ? buildRateRanges(form) : [],
protocol_settings: buildProtocolSettings(form),
show: form.show ? 1 : 0,
}
}
@@ -0,0 +1,410 @@
import type { AdminNodeType } from '@/types/api'
export interface NodeOption<T extends string | number = string> {
value: T
label: string
dotColor?: string
}
export interface NodeRateRangeForm {
key: string
start: string
end: string
rate: number
}
export interface NodeFormModel {
id?: number
originalType: AdminNodeType | ''
type: AdminNodeType | ''
rawProtocolSettings: Record<string, unknown>
name: string
code: string
rate: number
rateTimeEnable: boolean
rateTimeRanges: NodeRateRangeForm[]
tags: string[]
groupIds: number[]
routeIds: number[]
host: string
port: string
serverPort: string
parentId: number | null
show: boolean
enabled: boolean
tlsMode: number
tlsServerName: string
tlsAllowInsecure: boolean
echEnabled: boolean
echConfig: string
echQueryServerName: string
echKey: string
utlsEnabled: boolean
utlsFingerprint: string
realityServerName: string
realityServerPort: string
realityPublicKey: string
realityPrivateKey: string
realityShortId: string
network: string
tcpHeaderType: string
tcpRequestPath: string
tcpRequestHost: string
wsPath: string
wsHost: string
grpcServiceName: string
h2Path: string
h2Host: string
httpupgradePath: string
httpupgradeHost: string
xhttpPath: string
xhttpHost: string
xhttpMode: string
xhttpExtra: string
kcpSeed: string
kcpHeaderType: string
shadowsocksCipher: string
shadowsocksObfs: string
shadowsocksObfsHost: string
shadowsocksObfsPath: string
shadowsocksPlugin: string
shadowsocksPluginOpts: string
vlessFlow: string
vlessEncryptionEnabled: boolean
vlessEncryption: string
vlessDecryption: string
hysteriaVersion: number
hysteriaUpMbps: number | null
hysteriaDownMbps: number | null
hysteriaObfsEnabled: boolean
hysteriaObfsType: string
hysteriaObfsPassword: string
hysteriaHopInterval: number | null
tuicVersion: number | null
tuicCongestionControl: string
tuicAlpn: string[]
tuicUdpRelayMode: string
mieruTransport: string
mieruTrafficPattern: string
anytlsPaddingSchemeText: string
multiplexEnabled: boolean
multiplexProtocol: string
multiplexMaxConnections: number | null
multiplexPadding: boolean
multiplexBrutalEnabled: boolean
multiplexBrutalUpMbps: number | null
multiplexBrutalDownMbps: number | null
}
export const NODE_PROTOCOL_OPTIONS: Array<NodeOption<AdminNodeType>> = [
{ value: 'shadowsocks', label: 'Shadowsocks', dotColor: '#44a35f' },
{ value: 'vmess', label: 'VMess', dotColor: '#d94696' },
{ value: 'trojan', label: 'Trojan', dotColor: '#f3b74f' },
{ value: 'hysteria', label: 'Hysteria', dotColor: '#5d84ff' },
{ value: 'vless', label: 'VLess', dotColor: '#111111' },
{ value: 'tuic', label: 'TUIC', dotColor: '#22c55e' },
{ value: 'socks', label: 'SOCKS', dotColor: '#3b82f6' },
{ value: 'naive', label: 'Naive', dotColor: '#8b3dff' },
{ value: 'http', label: 'HTTP', dotColor: '#ff5c2b' },
{ value: 'mieru', label: 'Mieru', dotColor: '#4caf50' },
{ value: 'anytls', label: 'AnyTLS', dotColor: '#8e59d1' },
]
export const NODE_TLS_MODE_OPTIONS: Array<NodeOption<number>> = [
{ value: 0, label: '无' },
{ value: 1, label: 'TLS' },
{ value: 2, label: 'Reality' },
]
export const NODE_SIMPLE_TLS_OPTIONS: Array<NodeOption<number>> = [
{ value: 0, label: '无' },
{ value: 1, label: 'TLS' },
]
export const NODE_TRANSPORT_OPTIONS: Record<string, Array<NodeOption>> = {
vmess: [
{ value: 'tcp', label: 'TCP' },
{ value: 'ws', label: 'WebSocket' },
{ value: 'grpc', label: 'gRPC' },
{ value: 'h2', label: 'HTTP/2' },
{ value: 'httpupgrade', label: 'HTTPUpgrade' },
{ value: 'xhttp', label: 'XHTTP' },
],
vless: [
{ value: 'tcp', label: 'TCP' },
{ value: 'ws', label: 'WebSocket' },
{ value: 'grpc', label: 'gRPC' },
{ value: 'h2', label: 'HTTP/2' },
{ value: 'httpupgrade', label: 'HTTPUpgrade' },
{ value: 'xhttp', label: 'XHTTP' },
{ value: 'kcp', label: 'mKCP' },
{ value: 'quic', label: 'QUIC' },
],
trojan: [
{ value: 'tcp', label: 'TCP' },
{ value: 'ws', label: 'WebSocket' },
{ value: 'grpc', label: 'gRPC' },
{ value: 'h2', label: 'HTTP/2' },
{ value: 'httpupgrade', label: 'HTTPUpgrade' },
{ value: 'xhttp', label: 'XHTTP' },
],
}
export const NODE_TCP_HEADER_OPTIONS: Array<NodeOption> = [
{ value: 'none', label: '无头部' },
{ value: 'http', label: 'HTTP 伪装' },
]
export const NODE_TLS_FINGERPRINT_OPTIONS: Array<NodeOption> = [
{ value: 'chrome', label: 'Chrome' },
{ value: 'firefox', label: 'Firefox' },
{ value: 'safari', label: 'Safari' },
{ value: 'ios', label: 'iOS' },
{ value: 'edge', label: 'Edge' },
{ value: 'qq', label: 'QQ' },
{ value: 'random', label: '随机' },
]
export const NODE_SHADOWSOCKS_CIPHER_OPTIONS: Array<NodeOption> = [
{ value: 'aes-128-gcm', label: 'aes-128-gcm' },
{ value: 'aes-256-gcm', label: 'aes-256-gcm' },
{ value: 'chacha20-ietf-poly1305', label: 'chacha20-ietf-poly1305' },
{ value: '2022-blake3-aes-128-gcm', label: '2022-blake3-aes-128-gcm' },
{ value: '2022-blake3-aes-256-gcm', label: '2022-blake3-aes-256-gcm' },
{ value: '2022-blake3-chacha20-poly1305', label: '2022-blake3-chacha20-poly1305' },
]
export const NODE_SHADOWSOCKS_OBFS_OPTIONS: Array<NodeOption> = [
{ value: '', label: '无' },
{ value: 'http', label: 'HTTP' },
{ value: 'tls', label: 'TLS' },
]
export const NODE_VLESS_FLOW_OPTIONS: Array<NodeOption> = [
{ value: '', label: '无' },
{ value: 'xtls-rprx-vision', label: 'xtls-rprx-vision' },
{ value: 'xtls-rprx-vision-udp443', label: 'xtls-rprx-vision-udp443' },
]
export const NODE_CONGESTION_CONTROL_OPTIONS: Array<NodeOption> = [
{ value: 'cubic', label: 'cubic' },
{ value: 'bbr', label: 'bbr' },
{ value: 'new_reno', label: 'new_reno' },
]
export const NODE_UDP_RELAY_MODE_OPTIONS: Array<NodeOption> = [
{ value: 'native', label: 'native' },
{ value: 'quic', label: 'quic' },
]
export const NODE_MUX_PROTOCOL_OPTIONS: Array<NodeOption> = [
{ value: 'yamux', label: 'yamux' },
{ value: 'smux', label: 'smux' },
{ value: 'h2mux', label: 'h2mux' },
]
function createRateRange(index = 0): NodeRateRangeForm {
return {
key: `range-${Date.now()}-${index}`,
start: '',
end: '',
rate: 1,
}
}
export function createEmptyNodeForm(): NodeFormModel {
return {
originalType: '',
type: '',
rawProtocolSettings: {},
name: '',
code: '',
rate: 1,
rateTimeEnable: false,
rateTimeRanges: [createRateRange()],
tags: [],
groupIds: [],
routeIds: [],
host: '',
port: '',
serverPort: '',
parentId: null,
show: true,
enabled: true,
tlsMode: 0,
tlsServerName: '',
tlsAllowInsecure: false,
echEnabled: false,
echConfig: '',
echQueryServerName: '',
echKey: '',
utlsEnabled: false,
utlsFingerprint: 'chrome',
realityServerName: '',
realityServerPort: '',
realityPublicKey: '',
realityPrivateKey: '',
realityShortId: '',
network: '',
tcpHeaderType: 'none',
tcpRequestPath: '',
tcpRequestHost: '',
wsPath: '',
wsHost: '',
grpcServiceName: '',
h2Path: '',
h2Host: '',
httpupgradePath: '',
httpupgradeHost: '',
xhttpPath: '',
xhttpHost: '',
xhttpMode: 'auto',
xhttpExtra: '',
kcpSeed: '',
kcpHeaderType: 'none',
shadowsocksCipher: '2022-blake3-aes-128-gcm',
shadowsocksObfs: '',
shadowsocksObfsHost: '',
shadowsocksObfsPath: '',
shadowsocksPlugin: '',
shadowsocksPluginOpts: '',
vlessFlow: '',
vlessEncryptionEnabled: false,
vlessEncryption: '',
vlessDecryption: '',
hysteriaVersion: 2,
hysteriaUpMbps: null,
hysteriaDownMbps: null,
hysteriaObfsEnabled: false,
hysteriaObfsType: 'salamander',
hysteriaObfsPassword: '',
hysteriaHopInterval: null,
tuicVersion: 5,
tuicCongestionControl: 'cubic',
tuicAlpn: ['h3'],
tuicUdpRelayMode: 'native',
mieruTransport: 'TCP',
mieruTrafficPattern: '',
anytlsPaddingSchemeText: [
'stop=8',
'0=30-30',
'1=100-400',
'2=400-500,c,500-1000,c,500-1000,c,500-1000,c,500-1000',
].join('\n'),
multiplexEnabled: false,
multiplexProtocol: 'yamux',
multiplexMaxConnections: null,
multiplexPadding: false,
multiplexBrutalEnabled: false,
multiplexBrutalUpMbps: null,
multiplexBrutalDownMbps: null,
}
}
export function createNodeRateRange(): NodeRateRangeForm {
return createRateRange()
}
export function getNodeProtocolLabel(type: AdminNodeType | '' | string): string {
return NODE_PROTOCOL_OPTIONS.find((item) => item.value === type)?.label ?? String(type ?? '')
}
export function getNodeProtocolOptions(): Array<NodeOption<AdminNodeType>> {
return NODE_PROTOCOL_OPTIONS
}
export function getNodeTlsOptions(type: AdminNodeType | '' | string): Array<NodeOption<number>> {
return type === 'vless' || type === 'trojan'
? NODE_TLS_MODE_OPTIONS
: NODE_SIMPLE_TLS_OPTIONS
}
export function getNodeTransportOptions(type: AdminNodeType | '' | string): Array<NodeOption> {
return NODE_TRANSPORT_OPTIONS[type] ?? []
}
export function supportsNodeSecurity(type: AdminNodeType | '' | string): boolean {
return ['vmess', 'vless', 'trojan', 'hysteria', 'tuic', 'anytls', 'socks', 'naive', 'http'].includes(type)
}
export function supportsNodeTransport(type: AdminNodeType | '' | string): boolean {
return ['vmess', 'vless', 'trojan'].includes(type)
}
export function supportsNodeMultiplex(type: AdminNodeType | '' | string): boolean {
return ['vmess', 'vless', 'trojan', 'mieru'].includes(type)
}
export function shouldShowTlsSettings(type: AdminNodeType | '' | string, tlsMode: number): boolean {
if (['hysteria', 'tuic', 'anytls'].includes(type)) {
return true
}
if (['vmess', 'socks', 'naive', 'http'].includes(type)) {
return tlsMode === 1
}
if (['vless', 'trojan'].includes(type)) {
return tlsMode === 1
}
return false
}
export function shouldShowRealitySettings(type: AdminNodeType | '' | string, tlsMode: number): boolean {
return ['vless', 'trojan'].includes(type) && tlsMode === 2
}
export function getNodeProtocolHint(type: AdminNodeType | '' | string): string {
const hints: Record<string, string> = {
shadowsocks: '配置 cipher、混淆与 plugin,适合传统 SS 节点维护。',
vmess: '配置 TLS 与传输层参数,适合 VMess 客户端场景。',
trojan: '配置 TLS / Reality 与传输层,适合 Trojan 高兼容场景。',
hysteria: '配置版本、带宽、混淆与 TLS 信息。',
vless: '配置安全性、传输协议、Flow、Reality 与加密模式。',
tuic: '配置版本、拥塞控制、ALPN 与 UDP relay。',
socks: '配置基础 SOCKS 节点,支持可选 TLS。',
naive: '配置 NaiveProxy 基础 TLS 信息。',
http: '配置 HTTP 节点与可选 TLS。',
mieru: '配置传输方式、流量模式与多路复用。',
anytls: '配置 AnyTLS 的 TLS 信息与 Padding Scheme。',
}
return hints[type] ?? '请选择协议后继续配置。'
}
export function validateNodeForm(form: NodeFormModel): string | null {
if (!form.type) {
return '请选择协议类型'
}
if (form.rateTimeEnable) {
const validRanges = form.rateTimeRanges.filter((item) => item.start.trim() && item.end.trim() && Number(item.rate) > 0)
if (validRanges.length === 0) {
return '请至少填写一条有效的动态倍率规则'
}
}
if (form.type === 'shadowsocks' && !form.shadowsocksCipher.trim()) {
return '请选择 Shadowsocks 加密方式'
}
if (['vmess', 'trojan', 'vless'].includes(form.type) && !form.network.trim()) {
return '请选择传输协议'
}
if (['vmess', 'socks', 'naive', 'http'].includes(form.type) && form.tlsMode === 1 && !form.tlsServerName.trim()) {
return '启用 TLS 时请输入服务器名称(SNI)'
}
if (['vless', 'trojan'].includes(form.type) && form.tlsMode === 2) {
if (!form.realityServerName.trim()) return 'Reality 模式下请输入服务器名称'
if (!form.realityPublicKey.trim()) return 'Reality 模式下请输入公钥'
if (!form.realityShortId.trim()) return 'Reality 模式下请输入 Short ID'
}
if (form.network === 'xhttp' && form.xhttpExtra.trim()) {
try {
JSON.parse(form.xhttpExtra)
} catch {
return 'XHTTP 额外参数必须是合法 JSON'
}
}
if (form.type === 'hysteria' && form.hysteriaObfsEnabled && !form.hysteriaObfsPassword.trim()) {
return '启用 Hysteria 混淆时请输入混淆密码'
}
if (form.type === 'tuic' && form.tuicAlpn.length === 0) {
return '请至少保留一个 TUIC ALPN'
}
return null
}
+50
View File
@@ -0,0 +1,50 @@
import type { AdminServerGroupItem } from '@/types/api'
function normalizeText(value: unknown): string {
return String(value ?? '').trim().toLowerCase()
}
function toSafeCount(value: unknown): number {
const count = Number(value)
return Number.isFinite(count) ? count : 0
}
export function normalizeNodeGroup(group: AdminServerGroupItem): AdminServerGroupItem {
return {
...group,
users_count: toSafeCount(group.users_count),
server_count: toSafeCount(group.server_count),
}
}
export function filterNodeGroups(groups: AdminServerGroupItem[], keyword: string): AdminServerGroupItem[] {
const normalizedKeyword = normalizeText(keyword)
if (!normalizedKeyword) {
return groups
}
return groups.filter((group) => {
const searchText = [group.id, group.name, group.users_count, group.server_count]
.map((item) => String(item ?? '').trim().toLowerCase())
.filter(Boolean)
.join(' ')
return searchText.includes(normalizedKeyword)
})
}
export function summarizeNodeGroups(groups: AdminServerGroupItem[]): {
totalUsers: number
totalServers: number
} {
return groups.reduce(
(summary, group) => ({
totalUsers: summary.totalUsers + toSafeCount(group.users_count),
totalServers: summary.totalServers + toSafeCount(group.server_count),
}),
{
totalUsers: 0,
totalServers: 0,
},
)
}
+2 -2
View File
@@ -10,8 +10,8 @@ const NODE_TYPE_LABELS: Record<string, string> = {
shadowsocks: 'Shadowsocks',
trojan: 'Trojan',
vmess: 'VMess',
vless: 'VLESS',
hysteria: 'Hysteria 2',
vless: 'VLess',
hysteria: 'Hysteria',
tuic: 'TUIC',
anytls: 'AnyTLS',
socks: 'SOCKS',
+300
View File
@@ -0,0 +1,300 @@
import type {
AdminNodeItem,
AdminNodeRouteAction,
AdminNodeRouteItem,
AdminNodeRouteSavePayload,
} from '@/types/api'
export interface NodeRouteActionMeta {
label: string
tagType: 'danger' | 'success' | 'warning' | 'info'
}
export interface NodeRouteReferenceSummary {
count: number
names: string[]
preview: string
}
export interface NodeRouteFormModel {
id?: number
remarks: string
matchText: string
action: AdminNodeRouteAction
actionValue: string
}
export const NODE_ROUTE_ACTION_OPTIONS: Array<{
label: string
value: AdminNodeRouteAction
}> = [
{ label: '禁止访问', value: 'block' },
{ label: '指定DNS服务器进行解析', value: 'dns' },
{ label: '直连', value: 'direct' },
{ label: '转发', value: 'proxy' },
]
const ROUTE_ACTION_META: Record<AdminNodeRouteAction, NodeRouteActionMeta> = {
block: { label: '禁止访问', tagType: 'danger' },
dns: { label: '指定DNS服务器进行解析', tagType: 'info' },
direct: { label: '直连', tagType: 'success' },
proxy: { label: '转发', tagType: 'warning' },
}
function normalizeText(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function normalizeMatchList(value: unknown): string[] {
if (!Array.isArray(value)) {
return []
}
return [...new Set(value
.map((item) => normalizeText(item))
.filter(Boolean))]
}
/**
* 将多行文本转换为路由匹配规则数组。
*
* @param value 多行输入文本。
* @returns 去重并去空后的规则数组。
*/
export function parseRouteMatchLines(value: string): string[] {
return [...new Set(value
.split(/\r?\n/g)
.map((line) => line.trim())
.filter(Boolean))]
}
/**
* 归一化后端返回的路由实体,确保表格与表单层只消费稳定结构。
*
* @param route 后端返回的原始路由对象。
* @returns 归一化后的路由实体。
*/
export function normalizeNodeRoute(route: AdminNodeRouteItem): AdminNodeRouteItem {
return {
...route,
remarks: normalizeText(route.remarks),
match: normalizeMatchList(route.match),
action_value: normalizeText(route.action_value),
}
}
/**
* 创建默认的路由表单模型。
*
* @returns 空表单模型。
*/
export function createEmptyNodeRouteForm(): NodeRouteFormModel {
return {
remarks: '',
matchText: '',
action: 'block',
actionValue: '',
}
}
/**
* 将路由实体转换为编辑表单模型。
*
* @param route 当前编辑的路由;为空时返回默认模型。
* @returns 可直接绑定到表单的模型。
*/
export function toNodeRouteFormModel(route?: AdminNodeRouteItem | null): NodeRouteFormModel {
if (!route) {
return createEmptyNodeRouteForm()
}
const normalized = normalizeNodeRoute(route)
return {
id: normalized.id,
remarks: normalized.remarks,
matchText: normalized.match.join('\n'),
action: normalized.action,
actionValue: normalizeText(normalized.action_value),
}
}
/**
* 将表单模型序列化为保存接口需要的载荷。
*
* @param form 当前表单模型。
* @returns 可直接提交给后端的保存载荷。
*/
export function toNodeRouteSavePayload(form: NodeRouteFormModel): AdminNodeRouteSavePayload {
return {
id: form.id,
remarks: form.remarks.trim(),
match: parseRouteMatchLines(form.matchText),
action: form.action,
action_value: requiresNodeRouteActionValue(form.action)
? form.actionValue.trim()
: null,
}
}
/**
* 判断某个动作是否要求额外填写动作值。
*
* @param action 路由动作。
* @returns 当前动作是否需要动作值。
*/
export function requiresNodeRouteActionValue(action: AdminNodeRouteAction): boolean {
return action === 'dns' || action === 'proxy'
}
/**
* 获取动作标签与颜色元信息。
*
* @param action 路由动作。
* @returns 动作标签展示元信息。
*/
export function getNodeRouteActionMeta(action: AdminNodeRouteAction): NodeRouteActionMeta {
return ROUTE_ACTION_META[action] ?? ROUTE_ACTION_META.block
}
/**
* 格式化列表中的动作值摘要。
*
* @param route 路由实体。
* @returns 面向列表展示的动作值文本。
*/
export function formatNodeRouteActionValue(route: AdminNodeRouteItem): string {
const actionValue = normalizeText(route.action_value)
switch (route.action) {
case 'dns':
return actionValue ? `DNS: ${actionValue}` : 'DNS 服务器未配置'
case 'direct':
return '直接连接'
case 'proxy':
return actionValue ? `转发: ${actionValue}` : '转发目标未配置'
case 'block':
default:
return '阻止访问'
}
}
/**
* 获取动作值输入框标签。
*
* @param action 路由动作。
* @returns 动作值输入标签。
*/
export function getNodeRouteActionValueLabel(action: AdminNodeRouteAction): string {
return action === 'dns' ? 'DNS服务器' : '转发目标'
}
/**
* 获取动作值输入框占位文案。
*
* @param action 路由动作。
* @returns 输入占位文案。
*/
export function getNodeRouteActionValuePlaceholder(action: AdminNodeRouteAction): string {
return action === 'dns'
? '例如 8.8.8.8 或 https://dns.google/dns-query'
: '例如 auto、proxy 或 香港出口'
}
/**
* 为所有路由生成节点引用摘要。
*
* @param nodes 当前节点列表。
* @returns 以路由 ID 为 key 的引用摘要映射。
*/
export function buildNodeRouteReferenceMap(nodes: AdminNodeItem[]): Record<number, NodeRouteReferenceSummary> {
const map: Record<number, NodeRouteReferenceSummary> = {}
nodes.forEach((node) => {
;(node.route_ids ?? []).forEach((routeId) => {
const normalizedId = Number(routeId)
if (!Number.isFinite(normalizedId)) {
return
}
if (!map[normalizedId]) {
map[normalizedId] = {
count: 0,
names: [],
preview: '未被节点引用',
}
}
map[normalizedId].count += 1
if (node.name && !map[normalizedId].names.includes(node.name)) {
map[normalizedId].names.push(node.name)
}
})
})
Object.values(map).forEach((summary) => {
if (summary.count === 0) {
summary.preview = '未被节点引用'
return
}
const previewNames = summary.names.slice(0, 2)
const more = summary.count - previewNames.length
summary.preview = more > 0
? `${previewNames.join('、')} +${more}`
: previewNames.join('、')
})
return map
}
function buildRouteSearchText(
route: AdminNodeRouteItem,
reference?: NodeRouteReferenceSummary,
): string {
return [
route.id,
route.remarks,
route.match.join(' '),
formatNodeRouteActionValue(route),
getNodeRouteActionMeta(route.action).label,
reference?.names.join(' '),
]
.map((item) => String(item ?? '').trim())
.filter(Boolean)
.join(' ')
.toLowerCase()
}
/**
* 按关键字过滤路由列表。
*
* @param routes 当前路由列表。
* @param keyword 搜索关键字。
* @param references 节点引用摘要映射。
* @returns 过滤后的路由列表。
*/
export function filterNodeRoutes(
routes: AdminNodeRouteItem[],
keyword: string,
references: Record<number, NodeRouteReferenceSummary>,
): AdminNodeRouteItem[] {
const normalizedKeyword = normalizeText(keyword).toLowerCase()
if (!normalizedKeyword) {
return routes
}
return routes.filter((route) => buildRouteSearchText(route, references[route.id]).includes(normalizedKeyword))
}
/**
* 统计当前已被节点引用的路由数量。
*
* @param routes 路由列表。
* @param references 节点引用摘要映射。
* @returns 被引用路由数量。
*/
export function countReferencedNodeRoutes(
routes: AdminNodeRouteItem[],
references: Record<number, NodeRouteReferenceSummary>,
): number {
return routes.filter((route) => (references[route.id]?.count ?? 0) > 0).length
}
+189
View File
@@ -0,0 +1,189 @@
.node-editor-dialog {
.node-editor-shell {
display: grid;
gap: 20px;
max-height: min(78vh, 980px);
overflow: auto;
padding-right: 6px;
}
.node-editor-hero,
.hero-copy__title,
.switch-row,
.switch-panel,
.node-editor-footer,
.footer-actions,
.protocol-option,
.rate-item__footer {
display: flex;
align-items: center;
gap: 12px;
}
.node-editor-hero,
.node-editor-footer,
.rate-item__footer {
justify-content: space-between;
}
.hero-copy,
.node-editor-form,
.form-section,
.section-head,
.form-placeholder,
.rate-list {
display: grid;
gap: 16px;
}
.hero-copy {
gap: 8px;
}
.hero-copy h2 {
margin: 0;
font-size: clamp(28px, 4vw, 34px);
line-height: 1.08;
letter-spacing: -0.24px;
color: var(--xboard-text-strong);
}
.hero-copy p,
.section-head p,
.switch-row span,
.placeholder-copy,
.footer-hint,
.rate-item__footer span {
color: var(--xboard-text-muted);
line-height: 1.55;
}
.hero-protocol {
display: grid;
gap: 8px;
min-width: min(260px, 100%);
}
.hero-protocol__label {
color: var(--xboard-text-secondary);
font-size: 13px;
}
.protocol-badge {
background: #111111;
border-color: #111111;
}
.protocol-option__dot {
width: 8px;
height: 8px;
border-radius: 999px;
flex-shrink: 0;
}
.form-section {
padding: 20px;
border-radius: 22px;
background: #fbfbfd;
border: 1px solid rgba(0, 0, 0, 0.05);
}
.section-head h3 {
margin: 0;
color: var(--xboard-text-strong);
font-size: 18px;
}
.form-grid,
.rate-item__grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.form-grid--full {
grid-column: 1 / -1;
}
.full-width,
.full-width .el-input__wrapper {
width: 100%;
}
.switch-row,
.switch-card {
justify-content: space-between;
}
.switch-row strong,
.switch-card strong {
display: block;
color: var(--xboard-text-strong);
margin-bottom: 4px;
}
.switch-panel {
align-items: stretch;
flex-wrap: wrap;
}
.switch-card {
flex: 1 1 280px;
padding: 14px 16px;
border-radius: 16px;
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.05);
}
.rate-item {
display: grid;
gap: 12px;
padding: 16px;
border-radius: 18px;
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.05);
}
.form-placeholder {
padding: 24px;
border-radius: 22px;
background: #fbfbfd;
}
.node-editor-footer {
width: 100%;
}
@media (max-width: 960px) {
.node-editor-hero,
.node-editor-footer {
flex-direction: column;
align-items: stretch;
}
.hero-copy__title {
align-items: flex-start;
flex-wrap: wrap;
}
.hero-protocol {
min-width: 0;
}
}
@media (max-width: 767px) {
.form-grid,
.rate-item__grid {
grid-template-columns: 1fr;
}
.switch-row,
.switch-card,
.node-editor-footer,
.footer-actions,
.rate-item__footer {
flex-direction: column;
align-items: stretch;
}
}
}
@@ -0,0 +1,415 @@
<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 { Plus } from '@element-plus/icons-vue'
import { saveNode } from '@/api/admin'
import type { AdminNodeItem, AdminNodeRouteItem, AdminNodeType, AdminServerGroupItem } from '@/types/api'
import NodeEditorProtocolSection from './NodeEditorProtocolSection.vue'
import {
createEmptyNodeForm,
createNodeRateRange,
getNodeProtocolLabel,
getNodeProtocolOptions,
toNodeFormModel,
toNodeSavePayload,
type NodeFormModel,
validateNodeForm,
} from '@/utils/nodeEditor'
const props = defineProps<{
visible: boolean
mode: 'create' | 'edit'
node?: AdminNodeItem | null
groups: AdminServerGroupItem[]
routes: AdminNodeRouteItem[]
nodes: AdminNodeItem[]
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
success: [message: string]
}>()
const formRef = ref<FormInstance>()
const submitting = ref(false)
const form = reactive<NodeFormModel>(createEmptyNodeForm())
const protocolOptions = computed(() => getNodeProtocolOptions())
const dialogTitle = computed(() => props.mode === 'create' ? '新建节点' : '编辑节点')
const dialogDescription = computed(() => props.mode === 'create'
? '管理所有节点,包括添加、删除、编辑等操作。'
: '调整节点基础配置、协议细节与排序前置参数。')
const currentProtocolLabel = computed(() => getNodeProtocolLabel(form.type))
const parentNodeOptions = computed(() => props.nodes.filter((item) => item.id !== props.node?.id))
const rules = computed<FormRules<NodeFormModel>>(() => ({
type: [{ required: true, message: '请选择协议类型', trigger: 'change' }],
name: [{ required: true, message: '请输入节点名称', trigger: 'blur' }],
host: [{ required: true, message: '请输入节点地址', trigger: 'blur' }],
port: [{ required: true, message: '请输入连接端口', trigger: 'blur' }],
serverPort: [{ required: true, message: '请输入服务端口', trigger: 'blur' }],
rate: [
{
validator: (_rule, value, callback) => {
if (!Number.isFinite(Number(value)) || Number(value) <= 0) {
callback(new Error('请输入大于 0 的倍率'))
return
}
callback()
},
trigger: 'blur',
},
],
}))
function closeDialog() {
emit('update:visible', false)
}
function syncForm() {
Object.assign(form, toNodeFormModel(props.node))
}
function applyProtocolDefaults(type: AdminNodeType | '') {
if (!type) {
form.network = ''
form.tlsMode = 0
return
}
if (['vmess', 'vless', 'trojan'].includes(type) && !form.network) {
form.network = 'tcp'
}
if (!['vmess', 'vless', 'trojan'].includes(type)) {
form.network = ''
}
if (!['vmess', 'vless', 'trojan', 'hysteria', 'tuic', 'anytls', 'socks', 'naive', 'http'].includes(type)) {
form.tlsMode = 0
}
if (type === 'trojan' && form.tlsMode === 0) {
form.tlsMode = 1
}
}
function addRateRange() {
form.rateTimeRanges.push(createNodeRateRange())
}
function removeRateRange(index: number) {
if (form.rateTimeRanges.length === 1) {
form.rateTimeRanges.splice(0, 1, createNodeRateRange())
return
}
form.rateTimeRanges.splice(index, 1)
}
async function handleSubmit() {
const instance = formRef.value
if (!instance) {
return
}
const valid = await instance.validate().catch(() => false)
if (!valid) {
return
}
const validationMessage = validateNodeForm(form)
if (validationMessage) {
ElMessage.warning(validationMessage)
return
}
submitting.value = true
try {
await saveNode(toNodeSavePayload(form))
const message = props.mode === 'create' ? '节点已创建' : '节点已更新'
ElMessage.success(message)
emit('success', message)
closeDialog()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '节点保存失败')
} finally {
submitting.value = false
}
}
watch(
() => [props.visible, props.node, props.mode],
([visible]) => {
if (!visible) {
return
}
syncForm()
applyProtocolDefaults(form.type)
nextTick(() => {
formRef.value?.clearValidate()
})
},
{ immediate: true },
)
watch(
() => form.type,
(value) => {
applyProtocolDefaults(value)
},
)
watch(
() => form.tlsMode,
(value) => {
if (value !== 2) {
form.realityServerName = ''
form.realityServerPort = ''
form.realityPublicKey = ''
form.realityPrivateKey = ''
form.realityShortId = ''
}
if (value === 0) {
form.tlsServerName = ''
form.tlsAllowInsecure = false
form.echEnabled = false
form.echConfig = ''
form.echQueryServerName = ''
form.echKey = ''
form.utlsEnabled = false
}
},
)
</script>
<template>
<ElDialog
:model-value="props.visible"
width="min(960px, calc(100vw - 24px))"
top="4vh"
destroy-on-close
class="node-editor-dialog"
@close="closeDialog"
@update:model-value="emit('update:visible', $event)"
>
<div class="node-editor-shell">
<header class="node-editor-hero">
<div class="hero-copy">
<div class="hero-copy__title">
<h2>{{ dialogTitle }}</h2>
<ElTag v-if="form.type" round effect="dark" class="protocol-badge">
{{ currentProtocolLabel }}
</ElTag>
</div>
<p>{{ dialogDescription }}</p>
</div>
<div class="hero-protocol">
<span class="hero-protocol__label">选择协议类型</span>
<ElSelect v-model="form.type" placeholder="选择协议类型">
<ElOption
v-for="option in protocolOptions"
:key="option.value"
:label="option.label"
:value="option.value"
>
<div class="protocol-option">
<span class="protocol-option__dot" :style="{ background: option.dotColor }" />
<span>{{ option.label }}</span>
</div>
</ElOption>
</ElSelect>
</div>
</header>
<ElForm
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
class="node-editor-form"
>
<section class="form-section">
<div class="section-head">
<div>
<h3>基础信息</h3>
<p>先完成节点标识地址权限组与展示状态等通用配置</p>
</div>
</div>
<div class="form-grid">
<ElFormItem label="节点名称" prop="name">
<ElInput v-model="form.name" placeholder="请输入节点名称" />
</ElFormItem>
<ElFormItem label="基础倍率" prop="rate">
<ElInputNumber
v-model="form.rate"
:min="0.01"
:step="0.01"
:precision="2"
:controls="false"
class="full-width"
/>
</ElFormItem>
<ElFormItem label="启用动态倍率" class="form-grid--full">
<div class="switch-row">
<div>
<strong>根据时间段设置不同的倍率乘数</strong>
<span>关闭后仅使用基础倍率开启后可配置多个倍率区间</span>
</div>
<ElSwitch v-model="form.rateTimeEnable" />
</div>
</ElFormItem>
<ElFormItem label="自定义节点 ID(选填)">
<ElInput v-model="form.code" placeholder="请输入自定义节点 ID" />
</ElFormItem>
<ElFormItem label="节点标签">
<ElSelect
v-model="form.tags"
multiple
filterable
allow-create
default-first-option
collapse-tags
collapse-tags-tooltip
placeholder="输入后回车添加标签"
/>
</ElFormItem>
<ElFormItem label="权限组">
<ElSelect v-model="form.groupIds" multiple collapse-tags collapse-tags-tooltip placeholder="请选择权限组">
<ElOption
v-for="group in props.groups"
:key="group.id"
:label="group.name"
:value="group.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="父级节点">
<ElSelect v-model="form.parentId" clearable placeholder="无">
<ElOption
v-for="node in parentNodeOptions"
:key="node.id"
:label="`${node.name} (#${node.id})`"
:value="node.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="节点地址" prop="host" class="form-grid--full">
<ElInput v-model="form.host" placeholder="请输入节点域名或者 IP" />
</ElFormItem>
<ElFormItem label="连接端口" prop="port">
<ElInput v-model="form.port" placeholder="用户连接端口" />
</ElFormItem>
<ElFormItem label="服务端口" prop="serverPort">
<ElInput v-model="form.serverPort" placeholder="请输入服务端口" />
</ElFormItem>
<ElFormItem label="路由组" class="form-grid--full">
<ElSelect v-model="form.routeIds" multiple collapse-tags collapse-tags-tooltip placeholder="选择路由组">
<ElOption
v-for="route in props.routes"
:key="route.id"
:label="route.remarks"
:value="route.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="节点状态" class="form-grid--full">
<div class="switch-panel">
<label class="switch-card">
<div>
<strong>前台显示</strong>
<span>开启后节点会出现在可展示列表中</span>
</div>
<ElSwitch v-model="form.show" />
</label>
<label class="switch-card">
<div>
<strong>启用节点</strong>
<span>关闭后节点仍保留配置但视为停用状态</span>
</div>
<ElSwitch v-model="form.enabled" />
</label>
</div>
</ElFormItem>
</div>
</section>
<section v-if="form.rateTimeEnable" class="form-section">
<div class="section-head">
<div>
<h3>动态倍率</h3>
<p>按时间段定义倍率规则保存时会序列化为 `rate_time_ranges`</p>
</div>
<ElButton @click="addRateRange">
<ElIcon><Plus /></ElIcon>
添加时间段
</ElButton>
</div>
<div class="rate-list">
<article
v-for="(item, index) in form.rateTimeRanges"
:key="item.key"
class="rate-item"
>
<div class="rate-item__grid">
<ElFormItem label="开始时间">
<ElTimePicker v-model="item.start" value-format="HH:mm" format="HH:mm" placeholder="09:00" />
</ElFormItem>
<ElFormItem label="结束时间">
<ElTimePicker v-model="item.end" value-format="HH:mm" format="HH:mm" placeholder="18:00" />
</ElFormItem>
<ElFormItem label="倍率">
<ElInputNumber
v-model="item.rate"
:min="0.01"
:step="0.01"
:precision="2"
:controls="false"
class="full-width"
/>
</ElFormItem>
</div>
<div class="rate-item__footer">
<span>规则 {{ index + 1 }}</span>
<ElButton text type="danger" @click="removeRateRange(index)">删除</ElButton>
</div>
</article>
</div>
</section>
<section v-if="!form.type" class="form-placeholder">
<ElEmpty description="请选择协议类型后继续配置协议参数。">
<p class="placeholder-copy">不同协议会自动切换不同的安全层传输层与专属配置项</p>
</ElEmpty>
</section>
<NodeEditorProtocolSection v-else :form="form" />
</ElForm>
</div>
<template #footer>
<div class="node-editor-footer">
<span class="footer-hint">当前协议{{ form.type ? currentProtocolLabel : '未选择' }}</span>
<div class="footer-actions">
<ElButton @click="closeDialog">取消</ElButton>
<ElButton type="primary" :loading="submitting" @click="handleSubmit">
{{ props.mode === 'create' ? '提交' : '保存修改' }}
</ElButton>
</div>
</div>
</template>
</ElDialog>
</template>
<style lang="scss" src="./NodeEditorDialog.scss"></style>
@@ -0,0 +1,494 @@
<script setup lang="ts">
import { computed } from 'vue'
import {
getNodeProtocolHint,
NODE_CONGESTION_CONTROL_OPTIONS,
NODE_MUX_PROTOCOL_OPTIONS,
NODE_SHADOWSOCKS_CIPHER_OPTIONS,
NODE_SHADOWSOCKS_OBFS_OPTIONS,
NODE_TCP_HEADER_OPTIONS,
NODE_TLS_FINGERPRINT_OPTIONS,
NODE_UDP_RELAY_MODE_OPTIONS,
NODE_VLESS_FLOW_OPTIONS,
shouldShowRealitySettings,
shouldShowTlsSettings,
supportsNodeMultiplex,
supportsNodeSecurity,
supportsNodeTransport,
getNodeTlsOptions,
getNodeTransportOptions,
type NodeFormModel,
} from '@/utils/nodeEditor'
const props = defineProps<{
form: NodeFormModel
}>()
const transportOptions = computed(() => getNodeTransportOptions(props.form.type))
const tlsOptions = computed(() => getNodeTlsOptions(props.form.type))
const showSecuritySection = computed(() => supportsNodeSecurity(props.form.type))
const showTransportSection = computed(() => supportsNodeTransport(props.form.type))
const showMultiplexSection = computed(() => supportsNodeMultiplex(props.form.type))
const showTlsSection = computed(() => shouldShowTlsSettings(props.form.type, props.form.tlsMode))
const showRealitySection = computed(() => shouldShowRealitySettings(props.form.type, props.form.tlsMode))
const currentProtocolHint = computed(() => getNodeProtocolHint(props.form.type))
</script>
<template>
<section v-if="showSecuritySection" class="form-section">
<div class="section-head">
<div>
<h3>安全层</h3>
<p>根据协议切换 TLS / Reality / ECH / uTLS 等安全配置</p>
</div>
</div>
<div class="form-grid">
<ElFormItem
v-if="['vmess', 'vless', 'trojan', 'socks', 'naive', 'http'].includes(props.form.type)"
label="安全性"
>
<ElSelect v-model="props.form.tlsMode" placeholder="请选择安全性">
<ElOption
v-for="option in tlsOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
v-if="['hysteria', 'tuic', 'anytls'].includes(props.form.type)"
label="服务器名称(SNI"
>
<ElInput v-model="props.form.tlsServerName" placeholder="example.com" />
</ElFormItem>
<ElFormItem v-if="showTlsSection" label="服务器名称(SNI">
<ElInput v-model="props.form.tlsServerName" placeholder="example.com" />
</ElFormItem>
<ElFormItem v-if="showTlsSection || ['hysteria', 'tuic', 'anytls'].includes(props.form.type)" label="允许不安全连接">
<ElSwitch v-model="props.form.tlsAllowInsecure" />
</ElFormItem>
<ElFormItem v-if="showTlsSection || ['hysteria', 'tuic', 'anytls'].includes(props.form.type)" label="启用 ECH" class="form-grid--full">
<div class="switch-row">
<div>
<strong>Encrypted Client Hello</strong>
<span>用于支持 ECH TLS 场景关闭时不会写入 ECH 配置</span>
</div>
<ElSwitch v-model="props.form.echEnabled" />
</div>
</ElFormItem>
<template v-if="props.form.echEnabled">
<ElFormItem label="ECH Config" class="form-grid--full">
<ElInput
v-model="props.form.echConfig"
type="textarea"
:autosize="{ minRows: 3, maxRows: 5 }"
placeholder="粘贴 ECH Config"
/>
</ElFormItem>
<ElFormItem label="ECH 查询域名">
<ElInput v-model="props.form.echQueryServerName" placeholder="ech.example.com" />
</ElFormItem>
<ElFormItem label="ECH Key(可选)">
<ElInput v-model="props.form.echKey" placeholder="仅服务端维护时填写" />
</ElFormItem>
</template>
<ElFormItem
v-if="showTlsSection || showRealitySection"
label="启用 uTLS"
class="form-grid--full"
>
<div class="switch-row">
<div>
<strong>uTLS 指纹伪装</strong>
<span>适用于需要模拟客户端指纹的连接场景</span>
</div>
<ElSwitch v-model="props.form.utlsEnabled" />
</div>
</ElFormItem>
<ElFormItem v-if="props.form.utlsEnabled" label="uTLS 指纹">
<ElSelect v-model="props.form.utlsFingerprint" placeholder="请选择指纹">
<ElOption
v-for="option in NODE_TLS_FINGERPRINT_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</ElFormItem>
<template v-if="showRealitySection">
<ElFormItem label="Reality 服务器名称">
<ElInput v-model="props.form.realityServerName" placeholder="www.cloudflare.com" />
</ElFormItem>
<ElFormItem label="Reality 服务器端口">
<ElInput v-model="props.form.realityServerPort" placeholder="443" />
</ElFormItem>
<ElFormItem label="Reality 公钥" class="form-grid--full">
<ElInput v-model="props.form.realityPublicKey" placeholder="请输入公钥" />
</ElFormItem>
<ElFormItem label="Reality 私钥" class="form-grid--full">
<ElInput v-model="props.form.realityPrivateKey" placeholder="仅服务端维护时填写" />
</ElFormItem>
<ElFormItem label="Reality Short ID">
<ElInput v-model="props.form.realityShortId" placeholder="请输入 Short ID" />
</ElFormItem>
</template>
</div>
</section>
<section v-if="showTransportSection" class="form-section">
<div class="section-head">
<div>
<h3>传输层</h3>
<p>按不同传输协议切换对应字段避免把所有参数堆到同一层</p>
</div>
</div>
<div class="form-grid">
<ElFormItem label="传输协议">
<ElSelect v-model="props.form.network" placeholder="请选择传输协议">
<ElOption
v-for="option in transportOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem v-if="props.form.network === 'tcp'" label="TCP 头部类型">
<ElSelect v-model="props.form.tcpHeaderType" placeholder="请选择头部类型">
<ElOption
v-for="option in NODE_TCP_HEADER_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</ElFormItem>
<template v-if="props.form.network === 'tcp' && props.form.tcpHeaderType === 'http'">
<ElFormItem label="请求路径" class="form-grid--full">
<ElInput
v-model="props.form.tcpRequestPath"
type="textarea"
:autosize="{ minRows: 3, maxRows: 4 }"
placeholder="每行一个 path,例如:&#10;/api&#10;/ws"
/>
</ElFormItem>
<ElFormItem label="Host 列表" class="form-grid--full">
<ElInput v-model="props.form.tcpRequestHost" placeholder="多个 Host 用逗号分隔" />
</ElFormItem>
</template>
<template v-if="props.form.network === 'ws'">
<ElFormItem label="路径">
<ElInput v-model="props.form.wsPath" placeholder="/ws" />
</ElFormItem>
<ElFormItem label="Host">
<ElInput v-model="props.form.wsHost" placeholder="ws.example.com" />
</ElFormItem>
</template>
<ElFormItem v-if="props.form.network === 'grpc'" label="Service Name">
<ElInput v-model="props.form.grpcServiceName" placeholder="grpc-service" />
</ElFormItem>
<template v-if="props.form.network === 'h2'">
<ElFormItem label="路径">
<ElInput v-model="props.form.h2Path" placeholder="/" />
</ElFormItem>
<ElFormItem label="Host 列表">
<ElInput v-model="props.form.h2Host" placeholder="多个 Host 用逗号分隔" />
</ElFormItem>
</template>
<template v-if="props.form.network === 'httpupgrade'">
<ElFormItem label="路径">
<ElInput v-model="props.form.httpupgradePath" placeholder="/upgrade" />
</ElFormItem>
<ElFormItem label="Host">
<ElInput v-model="props.form.httpupgradeHost" placeholder="upgrade.example.com" />
</ElFormItem>
</template>
<template v-if="props.form.network === 'xhttp'">
<ElFormItem label="路径">
<ElInput v-model="props.form.xhttpPath" placeholder="/connect" />
</ElFormItem>
<ElFormItem label="Host">
<ElInput v-model="props.form.xhttpHost" placeholder="xhttp.example.com" />
</ElFormItem>
<ElFormItem label="模式">
<ElInput v-model="props.form.xhttpMode" placeholder="auto" />
</ElFormItem>
<ElFormItem label="额外参数 JSON" class="form-grid--full">
<ElInput
v-model="props.form.xhttpExtra"
type="textarea"
:autosize="{ minRows: 3, maxRows: 5 }"
placeholder='例如:{ "mode": "stream-one" }'
/>
</ElFormItem>
</template>
<template v-if="props.form.network === 'kcp'">
<ElFormItem label="Seed">
<ElInput v-model="props.form.kcpSeed" placeholder="请输入 mKCP seed" />
</ElFormItem>
<ElFormItem label="KCP 头部类型">
<ElSelect v-model="props.form.kcpHeaderType" placeholder="请选择头部类型">
<ElOption
v-for="option in NODE_TCP_HEADER_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</ElFormItem>
</template>
</div>
</section>
<section class="form-section">
<div class="section-head">
<div>
<h3>协议配置</h3>
<p>{{ currentProtocolHint }}</p>
</div>
</div>
<div class="form-grid">
<template v-if="props.form.type === 'shadowsocks'">
<ElFormItem label="加密方式">
<ElSelect v-model="props.form.shadowsocksCipher" placeholder="请选择加密方式">
<ElOption
v-for="option in NODE_SHADOWSOCKS_CIPHER_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="混淆方式">
<ElSelect v-model="props.form.shadowsocksObfs" placeholder="请选择混淆">
<ElOption
v-for="option in NODE_SHADOWSOCKS_OBFS_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem v-if="props.form.shadowsocksObfs" label="混淆 Host">
<ElInput v-model="props.form.shadowsocksObfsHost" placeholder="obfs host" />
</ElFormItem>
<ElFormItem v-if="props.form.shadowsocksObfs" label="混淆路径">
<ElInput v-model="props.form.shadowsocksObfsPath" placeholder="/path" />
</ElFormItem>
<ElFormItem label="Plugin">
<ElInput v-model="props.form.shadowsocksPlugin" placeholder="v2ray-plugin" />
</ElFormItem>
<ElFormItem label="Plugin 参数">
<ElInput v-model="props.form.shadowsocksPluginOpts" placeholder="server;tls;host=example.com" />
</ElFormItem>
</template>
<template v-else-if="props.form.type === 'vless'">
<ElFormItem label="Flow">
<ElSelect v-model="props.form.vlessFlow" placeholder="请选择 Flow">
<ElOption
v-for="option in NODE_VLESS_FLOW_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="启用自定义加密" class="form-grid--full">
<div class="switch-row">
<div>
<strong>VLess Encryption</strong>
<span>适用于需要额外加解密配置的场景</span>
</div>
<ElSwitch v-model="props.form.vlessEncryptionEnabled" />
</div>
</ElFormItem>
<ElFormItem v-if="props.form.vlessEncryptionEnabled" label="客户端公钥">
<ElInput v-model="props.form.vlessEncryption" placeholder="encryption key" />
</ElFormItem>
<ElFormItem v-if="props.form.vlessEncryptionEnabled" label="服务端私钥">
<ElInput v-model="props.form.vlessDecryption" placeholder="decryption key" />
</ElFormItem>
</template>
<template v-else-if="props.form.type === 'hysteria'">
<ElFormItem label="协议版本">
<ElSelect v-model="props.form.hysteriaVersion" placeholder="请选择版本">
<ElOption :value="1" label="Hysteria 1" />
<ElOption :value="2" label="Hysteria 2" />
</ElSelect>
</ElFormItem>
<ElFormItem label="上行带宽 Mbps">
<ElInputNumber v-model="props.form.hysteriaUpMbps" :min="0" :controls="false" class="full-width" />
</ElFormItem>
<ElFormItem label="下行带宽 Mbps">
<ElInputNumber v-model="props.form.hysteriaDownMbps" :min="0" :controls="false" class="full-width" />
</ElFormItem>
<ElFormItem label="端口跳跃间隔(秒)">
<ElInputNumber v-model="props.form.hysteriaHopInterval" :min="0" :controls="false" class="full-width" />
</ElFormItem>
<ElFormItem label="启用混淆" class="form-grid--full">
<div class="switch-row">
<div>
<strong>Obfs</strong>
<span>Hysteria 2 默认推荐 Salamander开启后需提供密码</span>
</div>
<ElSwitch v-model="props.form.hysteriaObfsEnabled" />
</div>
</ElFormItem>
<ElFormItem v-if="props.form.hysteriaObfsEnabled" label="混淆类型">
<ElInput v-model="props.form.hysteriaObfsType" placeholder="salamander" />
</ElFormItem>
<ElFormItem v-if="props.form.hysteriaObfsEnabled" label="混淆密码">
<ElInput v-model="props.form.hysteriaObfsPassword" placeholder="请输入混淆密码" />
</ElFormItem>
</template>
<template v-else-if="props.form.type === 'tuic'">
<ElFormItem label="协议版本">
<ElInputNumber v-model="props.form.tuicVersion" :min="1" :controls="false" class="full-width" />
</ElFormItem>
<ElFormItem label="拥塞控制">
<ElSelect v-model="props.form.tuicCongestionControl" placeholder="请选择拥塞控制">
<ElOption
v-for="option in NODE_CONGESTION_CONTROL_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="UDP Relay Mode">
<ElSelect v-model="props.form.tuicUdpRelayMode" placeholder="请选择模式">
<ElOption
v-for="option in NODE_UDP_RELAY_MODE_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="ALPN">
<ElSelect
v-model="props.form.tuicAlpn"
multiple
filterable
allow-create
default-first-option
placeholder="输入后回车添加 ALPN"
/>
</ElFormItem>
</template>
<template v-else-if="props.form.type === 'mieru'">
<ElFormItem label="传输方式">
<ElSelect v-model="props.form.mieruTransport" placeholder="请选择传输方式">
<ElOption value="TCP" label="TCP" />
<ElOption value="UDP" label="UDP" />
</ElSelect>
</ElFormItem>
<ElFormItem label="Traffic Pattern">
<ElInput v-model="props.form.mieruTrafficPattern" placeholder="例如:steady" />
</ElFormItem>
</template>
<template v-else-if="props.form.type === 'anytls'">
<ElFormItem label="Padding Scheme" class="form-grid--full">
<ElInput
v-model="props.form.anytlsPaddingSchemeText"
type="textarea"
:autosize="{ minRows: 4, maxRows: 8 }"
placeholder="每行一条 padding scheme"
/>
</ElFormItem>
</template>
</div>
</section>
<section v-if="showMultiplexSection" class="form-section">
<div class="section-head">
<div>
<h3>多路复用</h3>
<p>对支持的协议开放多路复用与 Brutal 加速配置</p>
</div>
</div>
<div class="form-grid">
<ElFormItem label="启用多路复用" class="form-grid--full">
<div class="switch-row">
<div>
<strong>Multiplex</strong>
<span>适用于 VLess / VMess / Trojan / Mieru 的复用场景</span>
</div>
<ElSwitch v-model="props.form.multiplexEnabled" />
</div>
</ElFormItem>
<template v-if="props.form.multiplexEnabled">
<ElFormItem label="复用协议">
<ElSelect v-model="props.form.multiplexProtocol" placeholder="请选择协议">
<ElOption
v-for="option in NODE_MUX_PROTOCOL_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="最大连接数">
<ElInputNumber
v-model="props.form.multiplexMaxConnections"
:min="1"
:controls="false"
class="full-width"
/>
</ElFormItem>
<ElFormItem label="填充">
<ElSwitch v-model="props.form.multiplexPadding" />
</ElFormItem>
<ElFormItem label="启用 Brutal">
<ElSwitch v-model="props.form.multiplexBrutalEnabled" />
</ElFormItem>
<template v-if="props.form.multiplexBrutalEnabled">
<ElFormItem label="Brutal 上行 Mbps">
<ElInputNumber
v-model="props.form.multiplexBrutalUpMbps"
:min="1"
:controls="false"
class="full-width"
/>
</ElFormItem>
<ElFormItem label="Brutal 下行 Mbps">
<ElInputNumber
v-model="props.form.multiplexBrutalDownMbps"
:min="1"
:controls="false"
class="full-width"
/>
</ElFormItem>
</template>
</template>
</div>
</section>
</template>
@@ -0,0 +1,149 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { saveServerGroup } from '@/api/admin'
import type { AdminServerGroupItem } from '@/types/api'
type DialogMode = 'create' | 'edit'
interface NodeGroupFormModel {
name: string
}
const props = defineProps<{
visible: boolean
mode: DialogMode
group: AdminServerGroupItem | null
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
success: []
}>()
const formRef = ref<FormInstance>()
const submitting = ref(false)
const form = reactive<NodeGroupFormModel>({
name: '',
})
const dialogTitle = computed(() => props.mode === 'create' ? '添加权限组' : '编辑权限组')
const dialogDescription = computed(() => props.mode === 'create'
? '创建新的权限组,供节点、套餐与用户权限分配使用。'
: '修改权限组信息,更新后会立即影响后台显示。')
const rules = computed<FormRules<NodeGroupFormModel>>(() => ({
name: [{ required: true, message: '请输入权限组名称', trigger: 'blur' }],
}))
function resetForm() {
form.name = ''
}
function closeDialog() {
emit('update:visible', false)
}
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 saveServerGroup({
id: props.mode === 'edit' ? props.group?.id : undefined,
name: form.name.trim(),
})
ElMessage.success(props.mode === 'create' ? '权限组已创建' : '权限组已更新')
emit('success')
closeDialog()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '权限组保存失败')
} finally {
submitting.value = false
}
}
watch(
() => props.visible,
(visible) => {
if (!visible) {
return
}
resetForm()
form.name = props.group?.name ?? ''
formRef.value?.clearValidate()
},
{ immediate: true },
)
</script>
<template>
<ElDialog
:model-value="props.visible"
:title="dialogTitle"
width="min(480px, calc(100vw - 32px))"
class="node-group-dialog"
destroy-on-close
@close="closeDialog"
@update:model-value="emit('update:visible', $event)"
>
<div class="dialog-shell">
<p class="dialog-description">{{ dialogDescription }}</p>
<ElForm
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
>
<ElFormItem label="组名称" prop="name">
<ElInput
v-model="form.name"
maxlength="30"
show-word-limit
placeholder="请输入有意义的权限组名称"
/>
</ElFormItem>
</ElForm>
</div>
<template #footer>
<div class="dialog-footer">
<ElButton @click="closeDialog">取消</ElButton>
<ElButton type="primary" :loading="submitting" @click="handleSubmit">
{{ props.mode === 'create' ? '创建' : '更新' }}
</ElButton>
</div>
</template>
</ElDialog>
</template>
<style scoped>
.dialog-shell {
display: grid;
gap: 18px;
}
.dialog-description {
color: var(--xboard-text-muted);
line-height: 1.6;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
width: 100%;
}
</style>
+136
View File
@@ -0,0 +1,136 @@
.node-groups-page {
display: grid;
gap: 20px;
}
.page-header {
display: grid;
gap: 10px;
}
.page-copy {
display: grid;
gap: 10px;
}
.page-copy h1 {
margin: 0;
font-size: clamp(34px, 4.8vw, 52px);
line-height: 1.08;
letter-spacing: -0.28px;
color: var(--xboard-text-strong);
}
.page-copy p {
margin: 0;
color: var(--xboard-text-secondary);
line-height: 1.6;
}
.page-summary,
.table-toolbar,
.toolbar-left,
.table-footer,
.action-group,
.metric-chip,
.metric-link {
display: flex;
align-items: center;
gap: 10px;
}
.page-summary {
flex-wrap: wrap;
}
.page-summary span {
display: inline-flex;
align-items: center;
height: 30px;
padding: 0 12px;
border-radius: 999px;
background: rgba(0, 113, 227, 0.06);
color: var(--xboard-text-secondary);
font-size: 13px;
}
.table-shell {
display: grid;
gap: 18px;
padding: 24px;
border-radius: 26px;
background: #ffffff;
box-shadow: var(--xboard-shadow);
}
.table-toolbar,
.table-footer {
justify-content: space-between;
}
.toolbar-left {
flex-wrap: wrap;
}
.toolbar-search {
width: min(320px, 100%);
}
.node-groups-table :deep(th.el-table__cell) {
color: var(--xboard-text-secondary);
background: #fbfbfd;
}
.node-groups-table :deep(.el-table__row td.el-table__cell) {
padding-top: 16px;
padding-bottom: 16px;
}
.name-cell {
display: grid;
gap: 6px;
}
.name-cell strong {
color: var(--xboard-text-strong);
}
.name-cell span,
.table-footer span,
.metric-chip.is-muted {
color: var(--xboard-text-muted);
}
.metric-chip,
.metric-link {
font-variant-numeric: tabular-nums;
}
.metric-chip {
color: var(--xboard-text-secondary);
}
.metric-link {
padding: 0;
height: auto;
}
.action-btn {
font-size: 18px;
}
.danger-btn {
color: var(--xboard-danger);
}
.table-empty {
padding: 24px 0;
}
@media (max-width: 1080px) {
.table-toolbar,
.table-footer {
flex-direction: column;
align-items: stretch;
}
}
+242 -86
View File
@@ -1,100 +1,256 @@
<script setup lang="ts">
const milestones = [
'接入权限组列表与用户 / 节点引用统计',
'补齐新增、编辑、删除与使用冲突提示',
'联动节点页的权限组筛选与维护闭环',
]
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Connection,
Delete,
EditPen,
Plus,
Search,
User,
} from '@element-plus/icons-vue'
import {
deleteServerGroup,
getServerGroups,
} from '@/api/admin'
import type { AdminServerGroupItem } from '@/types/api'
import {
filterNodeGroups,
normalizeNodeGroup,
summarizeNodeGroups,
} from '@/utils/nodeGroups'
import NodeGroupEditorDialog from './NodeGroupEditorDialog.vue'
type DialogMode = 'create' | 'edit'
const router = useRouter()
const loading = ref(true)
const errorMessage = ref('')
const keyword = ref('')
const current = ref(1)
const pageSize = ref(10)
const groups = ref<AdminServerGroupItem[]>([])
const dialogVisible = ref(false)
const dialogMode = ref<DialogMode>('create')
const activeGroup = ref<AdminServerGroupItem | null>(null)
const filteredGroups = computed(() => filterNodeGroups(groups.value, keyword.value))
const visibleGroups = computed(() => {
const start = (current.value - 1) * pageSize.value
return filteredGroups.value.slice(start, start + pageSize.value)
})
const summary = computed(() => summarizeNodeGroups(groups.value))
async function loadPage() {
loading.value = true
errorMessage.value = ''
try {
const response = await getServerGroups()
groups.value = (response.data ?? []).map((item) => normalizeNodeGroup(item))
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '权限组数据加载失败'
} finally {
loading.value = false
}
}
function openCreateDialog() {
dialogMode.value = 'create'
activeGroup.value = null
dialogVisible.value = true
}
function openEditDialog(group: AdminServerGroupItem) {
dialogMode.value = 'edit'
activeGroup.value = group
dialogVisible.value = true
}
function handleDialogSuccess() {
void loadPage()
}
async function handleDelete(group: AdminServerGroupItem) {
try {
await ElMessageBox.confirm(
`删除权限组「${group.name}」后无法恢复,确认继续吗?`,
'删除权限组',
{ type: 'warning' },
)
await deleteServerGroup(group.id)
ElMessage.success('权限组已删除')
await loadPage()
} catch (error) {
if (error === 'cancel' || error === 'close') {
return
}
ElMessage.error(error instanceof Error ? error.message : '权限组删除失败')
}
}
function openNodeFilter(group: AdminServerGroupItem) {
void router.push({
path: '/nodes',
query: { group: String(group.id) },
})
}
watch(keyword, () => {
current.value = 1
})
watch(filteredGroups, (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 loadPage()
})
</script>
<template>
<div class="placeholder-page">
<section class="placeholder-hero">
<div class="placeholder-copy">
<p class="placeholder-kicker">Node Groups</p>
<div class="node-groups-page">
<section class="page-header">
<div class="page-copy">
<h1>权限组管理</h1>
<span>入口已预留本轮先完成节点列表主链路下一阶段继续接入权限组的真实维护能力</span>
<p>管理所有权限组包括添加删除编辑等操作</p>
<div class="page-summary">
<span> {{ groups.length }} </span>
<span>关联用户 {{ summary.totalUsers }}</span>
<span>关联节点 {{ summary.totalServers }}</span>
</div>
</div>
</section>
<section class="placeholder-card">
<header>
<h2>下一阶段计划</h2>
<p>这一页不会空着结束而是明确告诉你后续要接什么</p>
<section class="table-shell">
<ElAlert
v-if="errorMessage"
type="error"
show-icon
:closable="false"
:title="errorMessage"
>
<template #default>
<ElButton size="small" @click="loadPage">重新加载</ElButton>
</template>
</ElAlert>
<header class="table-toolbar">
<div class="toolbar-left">
<ElButton type="primary" @click="openCreateDialog">
<ElIcon><Plus /></ElIcon>
添加权限组
</ElButton>
<ElInput
v-model="keyword"
clearable
placeholder="搜索权限组..."
class="toolbar-search"
>
<template #prefix>
<ElIcon><Search /></ElIcon>
</template>
</ElInput>
</div>
</header>
<ol>
<li v-for="item in milestones" :key="item">{{ item }}</li>
</ol>
<ElTable
:data="visibleGroups"
v-loading="loading"
row-key="id"
class="node-groups-table"
empty-text="当前筛选条件下暂无权限组"
>
<ElTableColumn prop="id" label="组ID" width="104" />
<ElTableColumn label="组名称" min-width="280">
<template #default="{ row }">
<div class="name-cell">
<strong>{{ row.name }}</strong>
<span>用于节点套餐与用户的权限范围归属</span>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="用户数量" width="160">
<template #default="{ row }">
<span class="metric-chip">
<ElIcon><User /></ElIcon>
{{ row.users_count ?? 0 }}
</span>
</template>
</ElTableColumn>
<ElTableColumn label="节点数量" width="180">
<template #default="{ row }">
<ElButton
v-if="Number(row.server_count ?? 0) > 0"
link
type="primary"
class="metric-link"
@click="openNodeFilter(row)"
>
<ElIcon><Connection /></ElIcon>
{{ row.server_count }}
</ElButton>
<span v-else class="metric-chip is-muted">
<ElIcon><Connection /></ElIcon>
0
</span>
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="120" fixed="right">
<template #default="{ row }">
<div class="action-group">
<ElButton text class="action-btn" @click="openEditDialog(row)">
<ElIcon><EditPen /></ElIcon>
</ElButton>
<ElButton text class="action-btn danger-btn" @click="handleDelete(row)">
<ElIcon><Delete /></ElIcon>
</ElButton>
</div>
</template>
</ElTableColumn>
<template #empty>
<div class="table-empty">
<ElEmpty :description="keyword ? '当前搜索条件下暂无权限组。' : '暂无权限组数据。'">
<ElButton v-if="keyword" @click="keyword = ''">清空搜索</ElButton>
<ElButton v-else @click="loadPage">重新加载</ElButton>
</ElEmpty>
</div>
</template>
</ElTable>
<footer class="table-footer">
<span>已选择 0 {{ filteredGroups.length }} </span>
<ElPagination
v-model:current-page="current"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]"
layout="sizes, prev, pager, next"
:total="filteredGroups.length"
background
/>
</footer>
</section>
<NodeGroupEditorDialog
v-model:visible="dialogVisible"
:mode="dialogMode"
:group="activeGroup"
@success="handleDialogSuccess"
/>
</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>
<style scoped lang="scss" src="./NodeGroupsView.scss"></style>
@@ -0,0 +1,77 @@
.dialog-shell,
.dialog-form {
display: grid;
gap: 20px;
}
.dialog-copy {
display: grid;
gap: 4px;
}
.dialog-copy p {
margin: 0;
font-size: 12px;
color: var(--xboard-text-muted);
letter-spacing: 0.18em;
text-transform: uppercase;
}
.dialog-copy h2 {
margin: 0;
font-size: 30px;
line-height: 1.08;
color: var(--xboard-text-strong);
}
.dialog-copy span,
.field-help span {
color: var(--xboard-text-secondary);
line-height: 1.47;
}
.dialog-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.field-help {
margin-top: 8px;
}
.action-panel {
display: grid;
gap: 8px;
padding: 18px;
border-radius: 18px;
background: #fbfbfd;
border: 1px solid rgba(0, 0, 0, 0.06);
}
.action-panel__main,
.dialog-footer {
display: flex;
align-items: center;
gap: 10px;
}
.action-panel strong {
color: var(--xboard-text-strong);
}
.action-panel span {
color: var(--xboard-text-muted);
line-height: 1.5;
}
.dialog-footer {
justify-content: flex-end;
width: 100%;
}
@media (max-width: 767px) {
.dialog-grid {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,222 @@
<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 { saveNodeRoute } from '@/api/admin'
import type { AdminNodeRouteItem } from '@/types/api'
import {
createEmptyNodeRouteForm,
getNodeRouteActionMeta,
getNodeRouteActionValueLabel,
getNodeRouteActionValuePlaceholder,
NODE_ROUTE_ACTION_OPTIONS,
parseRouteMatchLines,
requiresNodeRouteActionValue,
toNodeRouteFormModel,
toNodeRouteSavePayload,
type NodeRouteFormModel,
} from '@/utils/routes'
const props = defineProps<{
visible: boolean
mode: 'create' | 'edit'
route?: AdminNodeRouteItem | null
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
success: [message: string]
}>()
const formRef = ref<FormInstance>()
const submitting = ref(false)
const form = reactive<NodeRouteFormModel>(createEmptyNodeRouteForm())
const dialogTitle = computed(() => props.mode === 'create' ? '添加路由' : '编辑路由')
const needsActionValue = computed(() => requiresNodeRouteActionValue(form.action))
const actionMeta = computed(() => getNodeRouteActionMeta(form.action))
function closeDialog() {
emit('update:visible', false)
}
function syncForm() {
Object.assign(form, toNodeRouteFormModel(props.route))
if (!needsActionValue.value) {
form.actionValue = ''
}
}
function validateMatchText(_rule: unknown, value: string, callback: (error?: Error) => void) {
if (parseRouteMatchLines(value).length === 0) {
callback(new Error('请至少输入一条匹配规则'))
return
}
callback()
}
function validateActionValue(_rule: unknown, value: string, callback: (error?: Error) => void) {
if (needsActionValue.value && !value.trim()) {
callback(new Error(`请输入${getNodeRouteActionValueLabel(form.action)}`))
return
}
callback()
}
const rules = computed<FormRules<NodeRouteFormModel>>(() => ({
remarks: [{ required: true, message: '请输入备注', trigger: 'blur' }],
matchText: [{ validator: validateMatchText, trigger: 'blur' }],
action: [{ required: true, message: '请选择动作', trigger: 'change' }],
actionValue: [{ validator: validateActionValue, trigger: 'blur' }],
}))
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 saveNodeRoute(toNodeRouteSavePayload(form))
const message = props.mode === 'create' ? '路由已创建' : '路由已更新'
ElMessage.success(message)
emit('success', message)
closeDialog()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '路由保存失败')
} finally {
submitting.value = false
}
}
watch(
() => [props.visible, props.route, props.mode],
([visible]) => {
if (!visible) {
return
}
syncForm()
nextTick(() => {
formRef.value?.clearValidate()
})
},
{ immediate: true },
)
watch(
() => form.action,
() => {
if (!needsActionValue.value) {
form.actionValue = ''
}
},
)
</script>
<template>
<ElDialog
:model-value="props.visible"
:title="dialogTitle"
width="min(640px, calc(100vw - 32px))"
destroy-on-close
class="node-route-editor-dialog"
@close="closeDialog"
@update:model-value="emit('update:visible', $event)"
>
<div class="dialog-shell">
<div class="dialog-copy">
<p>Node Routes</p>
<h2>{{ dialogTitle }}</h2>
<span>维护路由备注匹配规则与动作配置保存后会同步到节点侧使用的路由规则</span>
</div>
<ElForm
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
class="dialog-form"
>
<ElFormItem label="备注" prop="remarks">
<ElInput
v-model="form.remarks"
placeholder="例如:屏蔽广告、走指定 DNS"
maxlength="80"
show-word-limit
/>
</ElFormItem>
<ElFormItem label="匹配规则" prop="matchText">
<ElInput
v-model="form.matchText"
type="textarea"
:autosize="{ minRows: 5, maxRows: 8 }"
placeholder="每行一条规则,例如:&#10;test.com&#10;*.apple.com"
/>
<div class="field-help">
<span>每行一条规则保存时会自动去空与去重</span>
</div>
</ElFormItem>
<div class="dialog-grid">
<ElFormItem label="动作" prop="action">
<ElSelect v-model="form.action" placeholder="请选择动作">
<ElOption
v-for="option in NODE_ROUTE_ACTION_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
v-if="needsActionValue"
:label="getNodeRouteActionValueLabel(form.action)"
prop="actionValue"
>
<ElInput
v-model="form.actionValue"
:placeholder="getNodeRouteActionValuePlaceholder(form.action)"
/>
</ElFormItem>
</div>
<section class="action-panel">
<div class="action-panel__main">
<strong>当前动作</strong>
<ElTag round effect="plain" :type="actionMeta.tagType">
{{ actionMeta.label }}
</ElTag>
</div>
<span v-if="needsActionValue">
{{ getNodeRouteActionValueLabel(form.action) }} 会随当前路由一起下发到节点端
</span>
<span v-else>
当前动作不需要额外动作值保存后会直接按策略执行
</span>
</section>
</ElForm>
</div>
<template #footer>
<div class="dialog-footer">
<ElButton @click="closeDialog">取消</ElButton>
<ElButton type="primary" :loading="submitting" @click="handleSubmit">
{{ props.mode === 'create' ? '提交' : '保存修改' }}
</ElButton>
</div>
</template>
</ElDialog>
</template>
<style scoped lang="scss" src="./NodeRouteEditorDialog.scss"></style>
+208
View File
@@ -0,0 +1,208 @@
.node-routes-page {
display: grid;
gap: 24px;
}
.node-routes-hero {
display: flex;
justify-content: space-between;
gap: 24px;
padding: 34px;
border-radius: 28px;
background: #000000;
}
.hero-copy {
display: grid;
gap: 12px;
max-width: 640px;
}
.hero-kicker {
margin: 0;
color: rgba(255, 255, 255, 0.68);
font-size: 11px;
letter-spacing: 0.24em;
text-transform: uppercase;
}
.hero-copy h1 {
margin: 0;
color: #ffffff;
font-size: clamp(34px, 5vw, 52px);
line-height: 1.08;
letter-spacing: -0.28px;
}
.hero-copy span {
color: rgba(255, 255, 255, 0.72);
line-height: 1.6;
}
.hero-stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
min-width: 360px;
}
.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: 20px;
line-height: 1.2;
}
.table-shell {
display: grid;
gap: 18px;
padding: 22px 22px 18px;
border-radius: 24px;
background: #ffffff;
box-shadow: var(--xboard-shadow);
}
.table-toolbar,
.toolbar-left,
.table-footer,
.footer-right,
.footer-hint,
.action-group {
display: flex;
align-items: center;
gap: 12px;
}
.table-toolbar,
.table-footer {
justify-content: space-between;
}
.toolbar-search {
width: min(280px, 100%);
}
.table-alert {
border-radius: 16px;
}
.routes-table :deep(.el-table__cell) {
vertical-align: top;
}
.routes-table :deep(th.el-table__cell) {
color: var(--xboard-text-secondary);
background: #fbfbfd;
}
.remark-cell,
.value-cell {
display: grid;
gap: 8px;
padding: 2px 0;
}
.remark-cell strong,
.value-cell strong {
color: var(--xboard-text-strong);
line-height: 1.35;
}
.remark-cell span,
.value-cell span,
.table-footer > span,
.footer-hint span {
color: var(--xboard-text-muted);
line-height: 1.5;
}
.id-tag,
.action-tag {
font-variant-numeric: tabular-nums;
}
.action-tag--dns {
color: #0071e3;
border-color: rgba(0, 113, 227, 0.18);
background: rgba(0, 113, 227, 0.08);
}
.action-btn {
width: 36px;
height: 36px;
padding: 0;
border-radius: 10px;
color: var(--xboard-text-secondary);
}
.action-btn:hover {
color: #0071e3;
background: rgba(0, 113, 227, 0.08);
}
.danger-btn:hover {
color: #d92d20;
background: rgba(217, 45, 32, 0.08);
}
.table-empty {
padding: 24px 0;
}
.footer-right {
justify-content: flex-end;
flex-wrap: wrap;
}
.footer-hint {
color: var(--xboard-text-muted);
}
@media (max-width: 1180px) {
.node-routes-hero,
.table-toolbar,
.table-footer {
flex-direction: column;
align-items: stretch;
}
.hero-stats {
min-width: 0;
}
.footer-right {
justify-content: space-between;
}
}
@media (max-width: 767px) {
.hero-stats {
grid-template-columns: 1fr;
}
.toolbar-left,
.footer-right {
flex-direction: column;
align-items: stretch;
}
.action-group {
justify-content: flex-start;
}
.footer-hint {
align-items: flex-start;
}
}
+285 -86
View File
@@ -1,100 +1,299 @@
<script setup lang="ts">
const milestones = [
'接入路由规则列表、动作类型与备注字段',
'补齐新增 / 编辑 / 删除路由的操作台',
'与节点页建立路由引用可视化关系,方便运营判断影响面',
]
import { computed, onMounted, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Connection,
Delete,
EditPen,
Plus,
RefreshRight,
Search,
} from '@element-plus/icons-vue'
import {
deleteNodeRoute,
fetchNodeRoutes,
fetchNodes,
} from '@/api/admin'
import type { AdminNodeItem, AdminNodeRouteItem } from '@/types/api'
import NodeRouteEditorDialog from './NodeRouteEditorDialog.vue'
import {
buildNodeRouteReferenceMap,
countReferencedNodeRoutes,
filterNodeRoutes,
formatNodeRouteActionValue,
getNodeRouteActionMeta,
normalizeNodeRoute,
} from '@/utils/routes'
type DialogMode = 'create' | 'edit'
const loading = ref(false)
const errorMessage = ref('')
const editorVisible = ref(false)
const editorMode = ref<DialogMode>('create')
const activeRoute = ref<AdminNodeRouteItem | null>(null)
const deletingId = ref<number | null>(null)
const keyword = ref('')
const current = ref(1)
const pageSize = ref(10)
const routes = ref<AdminNodeRouteItem[]>([])
const nodes = ref<AdminNodeItem[]>([])
const referenceMap = computed(() => buildNodeRouteReferenceMap(nodes.value))
const filteredRoutes = computed(() => filterNodeRoutes(routes.value, keyword.value, referenceMap.value))
const visibleRoutes = computed(() => {
const start = (current.value - 1) * pageSize.value
return filteredRoutes.value.slice(start, start + pageSize.value)
})
const heroStats = computed(() => [
{ label: '路由总数', value: String(routes.value.length) },
{ label: '禁止访问', value: String(routes.value.filter((item) => item.action === 'block').length) },
{ label: 'DNS 解析', value: String(routes.value.filter((item) => item.action === 'dns').length) },
{ label: '已被引用', value: String(countReferencedNodeRoutes(routes.value, referenceMap.value)) },
])
const hasActiveFilters = computed(() => keyword.value.trim() !== '')
function isDeleting(id: number): boolean {
return deletingId.value === id
}
async function loadData() {
loading.value = true
errorMessage.value = ''
try {
const [routeResult, nodeResult] = await Promise.all([
fetchNodeRoutes(),
fetchNodes(),
])
routes.value = (routeResult.data ?? [])
.map((route) => normalizeNodeRoute(route))
.sort((a, b) => a.id - b.id)
nodes.value = nodeResult.data ?? []
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '路由数据加载失败'
} finally {
loading.value = false
}
}
function openCreateDialog() {
editorMode.value = 'create'
activeRoute.value = null
editorVisible.value = true
}
function openEditDialog(route: AdminNodeRouteItem) {
editorMode.value = 'edit'
activeRoute.value = route
editorVisible.value = true
}
function handleReset() {
keyword.value = ''
}
async function handleDelete(route: AdminNodeRouteItem) {
deletingId.value = route.id
try {
await ElMessageBox.confirm(`删除路由「${route.remarks}」后无法恢复,确认继续吗?`, '删除路由', {
type: 'warning',
})
await deleteNodeRoute(route.id)
ElMessage.success('路由已删除')
await loadData()
} catch (error) {
if (error === 'cancel' || error === 'close') {
return
}
ElMessage.error(error instanceof Error ? error.message : '路由删除失败')
} finally {
deletingId.value = null
}
}
watch([keyword, pageSize], () => {
current.value = 1
})
watch(filteredRoutes, (list) => {
const maxPage = Math.max(1, Math.ceil(list.length / pageSize.value))
if (current.value > maxPage) {
current.value = maxPage
}
})
onMounted(() => {
void loadData().catch((error) => {
ElMessage.error(error instanceof Error ? error.message : '路由管理页面初始化失败')
})
})
</script>
<template>
<div class="placeholder-page">
<section class="placeholder-hero">
<div class="placeholder-copy">
<p class="placeholder-kicker">Node Routes</p>
<div class="node-routes-page">
<section class="node-routes-hero">
<div class="hero-copy">
<p class="hero-kicker">Node Routes</p>
<h1>路由管理</h1>
<span>侧边栏入口已对齐下一阶段将继续补齐路由规则列表与节点引用关系</span>
<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="placeholder-card">
<header>
<h2>接下来会补什么</h2>
<p>本轮先把节点管理主链路落稳路由管理不留空白先把后续接入方向固定下来</p>
<section class="table-shell">
<header class="table-toolbar">
<div class="toolbar-left">
<ElButton type="primary" @click="openCreateDialog">
<ElIcon><Plus /></ElIcon>
添加路由
</ElButton>
<ElInput
v-model="keyword"
clearable
placeholder="搜索路由..."
class="toolbar-search"
>
<template #prefix>
<ElIcon><Search /></ElIcon>
</template>
</ElInput>
</div>
</header>
<ol>
<li v-for="item in milestones" :key="item">{{ item }}</li>
</ol>
<ElAlert
v-if="errorMessage"
type="error"
show-icon
:closable="false"
class="table-alert"
:title="errorMessage"
>
<template #default>
<ElButton text @click="loadData">重新加载</ElButton>
</template>
</ElAlert>
<ElTable
:data="visibleRoutes"
v-loading="loading"
class="routes-table"
row-key="id"
empty-text="当前筛选条件下暂无路由"
>
<ElTableColumn label="组ID" width="108">
<template #default="{ row }">
<ElTag round effect="plain" class="id-tag">
{{ row.id }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="备注" min-width="320">
<template #default="{ row }">
<div class="remark-cell">
<strong>{{ row.remarks }}</strong>
<span v-if="referenceMap[row.id]?.count">
引用 {{ referenceMap[row.id].count }} 个节点 · {{ referenceMap[row.id].preview }}
</span>
<span v-else>未被节点引用</span>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="动作值" min-width="260">
<template #default="{ row }">
<div class="value-cell">
<strong>{{ formatNodeRouteActionValue(row) }}</strong>
<span>匹配 {{ row.match.length }} 条规则</span>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="动作" width="190">
<template #default="{ row }">
<ElTag
round
effect="plain"
:type="getNodeRouteActionMeta(row.action).tagType"
class="action-tag"
:class="`action-tag--${row.action}`"
>
{{ getNodeRouteActionMeta(row.action).label }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="110" fixed="right">
<template #default="{ row }">
<div class="action-group">
<ElButton text class="action-btn" @click="openEditDialog(row)">
<ElIcon><EditPen /></ElIcon>
</ElButton>
<ElButton
text
class="action-btn danger-btn"
:loading="isDeleting(row.id)"
@click="handleDelete(row)"
>
<ElIcon><Delete /></ElIcon>
</ElButton>
</div>
</template>
</ElTableColumn>
<template #empty>
<div class="table-empty">
<ElEmpty
:description="hasActiveFilters ? '当前筛选条件下暂无路由。' : '暂无路由数据。'"
>
<ElButton v-if="hasActiveFilters" @click="handleReset">清空筛选</ElButton>
<ElButton v-else @click="loadData">
<ElIcon><RefreshRight /></ElIcon>
重新加载
</ElButton>
</ElEmpty>
</div>
</template>
</ElTable>
<footer class="table-footer">
<span>已选择 0 {{ filteredRoutes.length }} </span>
<div class="footer-right">
<div class="footer-hint">
<ElIcon><Connection /></ElIcon>
<span>节点引用摘要基于当前节点 `route_ids` 实时推导</span>
</div>
<ElPagination
v-model:current-page="current"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]"
layout="sizes, prev, pager, next"
:total="filteredRoutes.length"
background
/>
</div>
</footer>
</section>
<NodeRouteEditorDialog
v-model:visible="editorVisible"
:mode="editorMode"
:route="activeRoute"
@success="() => loadData()"
/>
</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>
<style scoped lang="scss" src="./NodeRoutesView.scss"></style>
+65
View File
@@ -0,0 +1,65 @@
.sort-shell,
.sort-list {
display: grid;
gap: 16px;
}
.sort-copy,
.sort-meta span {
color: var(--xboard-text-muted);
line-height: 1.55;
}
.sort-item,
.sort-item__main,
.sort-actions,
.sort-footer {
display: flex;
align-items: center;
gap: 12px;
}
.sort-item,
.sort-footer {
justify-content: space-between;
}
.sort-item {
padding: 16px;
border-radius: 18px;
background: #fbfbfd;
}
.sort-index {
width: 34px;
height: 34px;
display: grid;
place-items: center;
border-radius: 999px;
background: rgba(0, 113, 227, 0.08);
color: #0071e3;
font-weight: 600;
}
.sort-meta {
display: grid;
gap: 6px;
}
.sort-meta strong {
color: var(--xboard-text-strong);
}
@media (max-width: 767px) {
.sort-item,
.sort-item__main,
.sort-actions,
.sort-footer {
flex-direction: column;
align-items: stretch;
}
.sort-index {
align-self: flex-start;
}
}
@@ -0,0 +1,119 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { ArrowDown, ArrowUp } from '@element-plus/icons-vue'
import { sortNodes } from '@/api/admin'
import type { AdminNodeItem } from '@/types/api'
import { getNodeProtocolLabel, moveNodeOrder, sortNodesByOrder } from '@/utils/nodeEditor'
const props = defineProps<{
visible: boolean
nodes: AdminNodeItem[]
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
success: [message: string]
}>()
const submitting = ref(false)
const draft = ref<AdminNodeItem[]>([])
const sortedDraft = computed(() => draft.value)
function closeDialog() {
emit('update:visible', false)
}
function moveDraft(index: number, direction: -1 | 1) {
draft.value = moveNodeOrder(draft.value, index, direction)
}
async function handleSubmit() {
submitting.value = true
try {
await sortNodes(
draft.value.map((item, index) => ({
id: item.id,
order: index + 1,
})),
)
const message = '节点排序已保存'
ElMessage.success(message)
emit('success', message)
closeDialog()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '节点排序保存失败')
} finally {
submitting.value = false
}
}
watch(
() => [props.visible, props.nodes],
([visible]) => {
if (!visible) {
return
}
draft.value = sortNodesByOrder(props.nodes).map((item) => ({ ...item }))
},
{ immediate: true },
)
</script>
<template>
<ElDialog
:model-value="props.visible"
width="min(720px, calc(100vw - 32px))"
class="node-sort-dialog"
title="编辑排序"
@close="closeDialog"
@update:model-value="emit('update:visible', $event)"
>
<div class="sort-shell">
<p class="sort-copy">按照当前展示顺序调整节点排序保存后会同步到后台 `/server/manage/sort`</p>
<div class="sort-list">
<article
v-for="(item, index) in sortedDraft"
: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>
{{ getNodeProtocolLabel(item.type) }}
· {{ item.host }}:{{ item.server_port || item.port }}
</span>
</div>
</div>
<div class="sort-actions">
<ElButton :disabled="index === 0" @click="moveDraft(index, -1)">
<ElIcon><ArrowUp /></ElIcon>
上移
</ElButton>
<ElButton :disabled="index === sortedDraft.length - 1" @click="moveDraft(index, 1)">
<ElIcon><ArrowDown /></ElIcon>
下移
</ElButton>
</div>
</article>
</div>
</div>
<template #footer>
<div class="sort-footer">
<ElButton @click="closeDialog">取消</ElButton>
<ElButton type="primary" :loading="submitting" @click="handleSubmit">
保存排序
</ElButton>
</div>
</template>
</ElDialog>
</template>
<style scoped lang="scss" src="./NodeSortDialog.scss"></style>
+88 -14
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Connection,
@@ -13,10 +14,13 @@ import {
copyNode,
deleteNode,
fetchNodes,
fetchNodeRoutes,
getServerGroups,
updateNode,
} from '@/api/admin'
import type { AdminNodeItem, AdminServerGroupItem } from '@/types/api'
import type { AdminNodeItem, AdminNodeRouteItem, AdminServerGroupItem } from '@/types/api'
import NodeEditorDialog from './NodeEditorDialog.vue'
import NodeSortDialog from './NodeSortDialog.vue'
import {
buildNodeTypeOptions,
countOnlineNodes,
@@ -29,25 +33,34 @@ import {
getNodeStatusMeta,
getNodeTypeLabel,
} from '@/utils/nodes'
import { sortNodesByOrder } from '@/utils/nodeEditor'
type NodeAction = 'edit' | 'copy' | 'delete'
type NodeDialogMode = 'create' | 'edit'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const errorMessage = ref('')
const nodes = ref<AdminNodeItem[]>([])
const groups = ref<AdminServerGroupItem[]>([])
const routes = ref<AdminNodeRouteItem[]>([])
const keyword = ref('')
const typeFilter = ref('all')
const groupFilter = ref('all')
const switchingIds = ref<number[]>([])
const workingIds = ref<number[]>([])
const editorVisible = ref(false)
const editorMode = ref<NodeDialogMode>('create')
const activeNode = ref<AdminNodeItem | null>(null)
const sortDialogVisible = ref(false)
const filteredNodes = computed(() => filterNodes(
const filteredNodes = computed(() => sortNodesByOrder(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')
@@ -59,6 +72,24 @@ const summaryCards = computed(() => [
{ label: '当前结果', value: String(filteredNodes.value.length) },
])
function getRouteGroupQuery(): string {
const rawValue = route.query.group
if (Array.isArray(rawValue)) {
return String(rawValue[0] ?? '')
}
return String(rawValue ?? '')
}
function applyRouteGroupFilter() {
const groupValue = getRouteGroupQuery().trim()
if (!groupValue) {
return
}
const exists = groups.value.some((group) => String(group.id) === groupValue)
groupFilter.value = exists ? groupValue : 'all'
}
function markPending(list: typeof switchingIds, id: number, pending: boolean) {
if (pending) {
if (!list.value.includes(id)) {
@@ -78,8 +109,20 @@ function isWorking(id: number): boolean {
return workingIds.value.includes(id)
}
function notifyPending(scope: string) {
ElMessage.info(`${scope} 会在下一阶段接入,本轮已先打通节点列表主链路。`)
function openCreateEditor() {
editorMode.value = 'create'
activeNode.value = null
editorVisible.value = true
}
function openEditEditor(node: AdminNodeItem) {
editorMode.value = 'edit'
activeNode.value = node
editorVisible.value = true
}
function openSortEditor() {
sortDialogVisible.value = true
}
async function loadNodeBoard() {
@@ -87,13 +130,16 @@ async function loadNodeBoard() {
errorMessage.value = ''
try {
const [nodesResponse, groupsResponse] = await Promise.all([
const [nodesResponse, groupsResponse, routesResponse] = await Promise.all([
fetchNodes(),
getServerGroups(),
fetchNodeRoutes(),
])
nodes.value = nodesResponse.data ?? []
nodes.value = sortNodesByOrder(nodesResponse.data ?? [])
groups.value = groupsResponse.data ?? []
routes.value = routesResponse.data ?? []
applyRouteGroupFilter()
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '节点数据加载失败'
} finally {
@@ -107,6 +153,10 @@ function handleReset() {
groupFilter.value = 'all'
}
function openNodeGroupManagement() {
void router.push('/node-groups')
}
async function handleToggleShow(node: AdminNodeItem, nextValue: boolean) {
const previous = Boolean(node.show)
if (previous === nextValue) {
@@ -132,7 +182,7 @@ async function handleToggleShow(node: AdminNodeItem, nextValue: boolean) {
async function handleAction(action: NodeAction, node: AdminNodeItem) {
if (action === 'edit') {
notifyPending(`编辑节点 #${node.id}`)
openEditEditor(node)
return
}
@@ -169,6 +219,13 @@ async function handleAction(action: NodeAction, node: AdminNodeItem) {
onMounted(() => {
void loadNodeBoard()
})
watch(
() => route.query.group,
() => {
applyRouteGroupFilter()
},
)
</script>
<template>
@@ -193,7 +250,7 @@ onMounted(() => {
<section class="nodes-board">
<header class="board-toolbar">
<div class="toolbar-fields">
<ElButton type="primary" @click="notifyPending('添加节点')">
<ElButton type="primary" @click="openCreateEditor">
<ElIcon><Plus /></ElIcon>
添加节点
</ElButton>
@@ -231,11 +288,12 @@ onMounted(() => {
</div>
<div class="toolbar-actions">
<ElButton @click="openNodeGroupManagement">管理权限组</ElButton>
<ElButton @click="handleReset" :disabled="!hasActiveFilters">
<ElIcon><RefreshRight /></ElIcon>
重置筛选
</ElButton>
<ElButton @click="notifyPending('编辑排序')">编辑排序</ElButton>
<ElButton @click="openSortEditor">编辑排序</ElButton>
</div>
</header>
@@ -362,8 +420,8 @@ onMounted(() => {
<ElIcon><MoreFilled /></ElIcon>
</ElButton>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem command="edit">编辑节点下一阶段</ElDropdownItem>
<ElDropdownMenu>
<ElDropdownItem command="edit">编辑节点</ElDropdownItem>
<ElDropdownItem command="copy">复制节点</ElDropdownItem>
<ElDropdownItem command="delete" divided>删除节点</ElDropdownItem>
</ElDropdownMenu>
@@ -391,10 +449,26 @@ onMounted(() => {
<span>已显示 {{ filteredNodes.length }} / {{ nodes.length }} 个节点</span>
<div class="footer-hint">
<ElIcon><Connection /></ElIcon>
<span>完整的节点创建编辑与排序流程将在下一阶段补齐</span>
<span>节点新增编辑与排序已在当前工作台内接入真实流程</span>
</div>
</footer>
</section>
<NodeEditorDialog
v-model:visible="editorVisible"
:mode="editorMode"
:node="activeNode"
:groups="groups"
:routes="routes"
:nodes="nodes"
@success="() => loadNodeBoard()"
/>
<NodeSortDialog
v-model:visible="sortDialogVisible"
:nodes="nodes"
@success="() => loadNodeBoard()"
/>
</div>
</template>
@@ -0,0 +1,161 @@
<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { generateGiftCardCodes } from '@/api/admin'
import type { AdminGiftCardTemplateItem } from '@/types/api'
const props = defineProps<{
visible: boolean
templates: AdminGiftCardTemplateItem[]
}>()
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void
(e: 'success', payload: { batchId: string }): void
}>()
const formRef = ref<FormInstance>()
const saving = ref(false)
const form = reactive({
template_id: undefined as number | undefined,
count: 10,
prefix: 'GC',
expires_hours: undefined as number | undefined,
max_usage: 1,
})
const rules: FormRules<typeof form> = {
template_id: [{ required: true, message: '请选择模板', trigger: 'change' }],
count: [{ required: true, message: '请输入生成数量', trigger: 'blur' }],
}
function resetForm() {
form.template_id = undefined
form.count = 10
form.prefix = 'GC'
form.expires_hours = undefined
form.max_usage = 1
}
function closeDialog() {
emit('update:visible', false)
}
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid || !form.template_id) {
return
}
saving.value = true
try {
const response = await generateGiftCardCodes({
template_id: form.template_id,
count: form.count,
prefix: form.prefix.trim() || 'GC',
expires_hours: form.expires_hours,
max_usage: form.max_usage,
})
ElMessage.success(`兑换码已生成,本次批次:${response.data.batch_id}`)
emit('success', { batchId: response.data.batch_id })
closeDialog()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '兑换码生成失败')
} finally {
saving.value = false
}
}
watch(
() => props.visible,
(visible) => {
if (visible) {
resetForm()
}
},
)
</script>
<template>
<ElDialog
:model-value="visible"
title="生成兑换码"
width="520px"
@update:model-value="emit('update:visible', $event)"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-position="top" class="batch-form">
<ElFormItem label="模板" prop="template_id">
<ElSelect v-model="form.template_id" placeholder="请选择一个礼品卡模板">
<ElOption
v-for="item in templates"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</ElSelect>
</ElFormItem>
<div class="form-grid">
<ElFormItem label="生成数量" prop="count">
<ElInputNumber v-model="form.count" :min="1" :max="10000" class="number-input" />
</ElFormItem>
<ElFormItem label="最大使用次数">
<ElInputNumber v-model="form.max_usage" :min="1" :max="1000" class="number-input" />
</ElFormItem>
<ElFormItem label="前缀">
<ElInput v-model="form.prefix" maxlength="10" placeholder="例如 GC" />
</ElFormItem>
<ElFormItem label="有效期(小时)">
<ElInputNumber v-model="form.expires_hours" :min="1" class="number-input" />
</ElFormItem>
</div>
<ElAlert
type="info"
:closable="false"
show-icon
title="生成后可在兑换码管理中按批次导出文本文件,并继续做启停、编辑或删除操作。"
/>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="closeDialog">取消</ElButton>
<ElButton type="primary" :loading="saving" @click="handleSubmit">开始生成</ElButton>
</div>
</template>
</ElDialog>
</template>
<style scoped lang="scss">
.batch-form {
display: grid;
gap: 18px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.number-input {
width: 100%;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
@media (max-width: 640px) {
.form-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,206 @@
<script setup lang="ts">
import { computed } from 'vue'
import {
CopyDocument,
Delete,
Download,
EditPen,
Plus,
Search,
} from '@element-plus/icons-vue'
import type {
AdminGiftCardCodeItem,
AdminGiftCardTemplateItem,
} from '@/types/api'
import type { GiftCardCodeStatusFilter } from '@/utils/giftCards'
import {
formatGiftCardDateTime,
getGiftCardAvailableUsage,
getGiftCardCodeStatusMeta,
} from '@/utils/giftCards'
const props = defineProps<{
loading: boolean
error: string
codes: AdminGiftCardCodeItem[]
keyword: string
templateFilter: number | 'all'
statusFilter: GiftCardCodeStatusFilter
current: number
pageSize: number
total: number
templates: AdminGiftCardTemplateItem[]
resolvedBatchId: string
}>()
const emit = defineEmits<{
(e: 'update:keyword', value: string): void
(e: 'update:template-filter', value: number | 'all'): void
(e: 'update:status-filter', value: GiftCardCodeStatusFilter): void
(e: 'update:current', value: number): void
(e: 'update:page-size', value: number): void
(e: 'create'): void
(e: 'export'): void
(e: 'reset'): void
(e: 'copy', code: string): void
(e: 'select-batch', batchId: string): void
(e: 'edit', code: AdminGiftCardCodeItem): void
(e: 'delete', code: AdminGiftCardCodeItem): void
(e: 'toggle', code: AdminGiftCardCodeItem, nextValue: string | number | boolean): void
}>()
const keywordModel = computed({
get: () => props.keyword,
set: (value: string) => emit('update:keyword', value),
})
const templateFilterModel = computed({
get: () => props.templateFilter,
set: (value: number | 'all') => emit('update:template-filter', value),
})
const statusFilterModel = computed({
get: () => props.statusFilter,
set: (value: GiftCardCodeStatusFilter) => emit('update:status-filter', value),
})
const currentModel = computed({
get: () => props.current,
set: (value: number) => emit('update:current', value),
})
const pageSizeModel = computed({
get: () => props.pageSize,
set: (value: number) => emit('update:page-size', value),
})
function isCodeEnabled(code: AdminGiftCardCodeItem): boolean {
return code.status !== 3
}
function isCodeToggleDisabled(code: AdminGiftCardCodeItem): boolean {
return code.status === 1 || code.status === 2
}
</script>
<template>
<div class="tab-panel">
<div class="panel-copy">
<h2>兑换码管理</h2>
<p>管理礼品卡兑换码包括生成查看和导出兑换码</p>
</div>
<div class="toolbar">
<div class="toolbar-left">
<ElInput v-model="keywordModel" clearable placeholder="搜索礼品卡..." class="toolbar-search">
<template #prefix><ElIcon><Search /></ElIcon></template>
</ElInput>
<ElSelect v-model="templateFilterModel" class="toolbar-filter">
<ElOption label="模板" value="all" />
<ElOption v-for="item in templates" :key="item.id" :label="item.name" :value="item.id" />
</ElSelect>
<ElSelect v-model="statusFilterModel" class="toolbar-filter">
<ElOption label="状态" value="all" />
<ElOption
v-for="item in [
{ label: '未使用', value: 0 },
{ label: '已使用', value: 1 },
{ label: '已过期', value: 2 },
{ label: '已禁用', value: 3 },
]"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</div>
<div class="toolbar-right">
<ElButton type="primary" @click="emit('create')">
<ElIcon><Plus /></ElIcon>
生成兑换码
</ElButton>
<ElButton :disabled="!resolvedBatchId" @click="emit('export')">
<ElIcon><Download /></ElIcon>
导出
</ElButton>
<ElButton @click="emit('reset')">重置</ElButton>
</div>
</div>
<div v-if="resolvedBatchId" class="batch-tip">
当前批次
<button type="button" class="batch-pill batch-pill--active" @click="emit('select-batch', resolvedBatchId)">
{{ resolvedBatchId }}
</button>
</div>
<ElAlert v-if="error" type="error" :closable="false" show-icon :title="error" />
<ElTable :data="codes" v-loading="loading" class="data-table" row-key="id" empty-text="当前筛选条件下暂无兑换码">
<ElTableColumn prop="id" label="ID" width="88" />
<ElTableColumn label="兑换码" min-width="260">
<template #default="{ row }">
<div class="code-cell">
<div class="code-line">
<strong class="mono">{{ row.code }}</strong>
<ElButton text @click="emit('copy', row.code)"><ElIcon><CopyDocument /></ElIcon></ElButton>
</div>
<button type="button" class="batch-pill" @click="emit('select-batch', row.batch_id || '')">
{{ row.batch_id || '无批次' }}
</button>
</div>
</template>
</ElTableColumn>
<ElTableColumn prop="template_name" label="模板名称" min-width="160" />
<ElTableColumn label="状态" min-width="150">
<template #default="{ row }">
<div class="status-with-switch">
<span class="pill" :class="`pill--${getGiftCardCodeStatusMeta(row.status).tone}`">
{{ getGiftCardCodeStatusMeta(row.status).label }}
</span>
<ElSwitch
:model-value="isCodeEnabled(row)"
:disabled="isCodeToggleDisabled(row)"
@change="emit('toggle', row, $event)"
/>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="过期时间" min-width="180">
<template #default="{ row }">{{ formatGiftCardDateTime(row.expires_at) }}</template>
</ElTableColumn>
<ElTableColumn label="已用/总次数" width="140">
<template #default="{ row }">{{ row.usage_count }} / {{ row.max_usage }}</template>
</ElTableColumn>
<ElTableColumn label="可用次数" width="110">
<template #default="{ row }">{{ getGiftCardAvailableUsage(row) }}</template>
</ElTableColumn>
<ElTableColumn label="创建时间" min-width="180">
<template #default="{ row }">{{ formatGiftCardDateTime(row.created_at) }}</template>
</ElTableColumn>
<ElTableColumn label="操作" width="130" fixed="right">
<template #default="{ row }">
<div class="action-group">
<ElButton text @click="emit('edit', row)"><ElIcon><EditPen /></ElIcon></ElButton>
<ElButton text class="danger-btn" @click="emit('delete', row)"><ElIcon><Delete /></ElIcon></ElButton>
</div>
</template>
</ElTableColumn>
</ElTable>
<footer class="table-footer">
<span>已选择 0 {{ total }} </span>
<ElPagination
v-model:current-page="currentModel"
v-model:page-size="pageSizeModel"
:page-sizes="[20, 50, 100]"
layout="sizes, prev, pager, next"
:total="total"
background
/>
</footer>
</div>
</template>
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { AdminGiftCardStatistics } from '@/types/api'
const props = defineProps<{
loading: boolean
error: string
statistics: AdminGiftCardStatistics | null
}>()
const statsCards = computed(() => {
const total = props.statistics?.total_stats
return [
{ label: '模板总数', value: total?.templates_count ?? 0 },
{ label: '活跃模板数', value: total?.active_templates_count ?? 0 },
{ label: '兑换码总数', value: total?.codes_count ?? 0 },
{ label: '已使用兑换码', value: total?.used_codes_count ?? 0 },
]
})
</script>
<template>
<div class="tab-panel">
<div class="panel-copy">
<h2>统计数据</h2>
<p>查看礼品卡的统计数据和使用情况分析</p>
</div>
<ElAlert v-if="error" type="error" :closable="false" show-icon :title="error" />
<div class="stats-grid" v-loading="loading">
<article v-for="item in statsCards" :key="item.label" class="stats-card">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</article>
</div>
<div class="stats-secondary" v-if="statistics">
<article class="stats-list-card">
<header>
<strong>最近 30 天使用走势</strong>
<span>按天汇总兑换使用次数</span>
</header>
<ul v-if="statistics.daily_usages.length > 0">
<li v-for="item in statistics.daily_usages.slice(-7)" :key="item.date">
<span>{{ item.date }}</span>
<strong>{{ item.count }}</strong>
</li>
</ul>
<p v-else class="empty-copy">最近 30 天暂无使用记录</p>
</article>
<article class="stats-list-card">
<header>
<strong>模板消耗排行</strong>
<span>按模板汇总兑换次数</span>
</header>
<ul v-if="statistics.type_stats.length > 0">
<li v-for="item in statistics.type_stats.slice(0, 6)" :key="`${item.template_name}-${item.type_name}`">
<div>
<strong>{{ item.template_name || '未命名模板' }}</strong>
<span>{{ item.type_name }}</span>
</div>
<b>{{ item.count }}</b>
</li>
</ul>
<p v-else class="empty-copy">暂无模板使用数据</p>
</article>
</div>
</div>
</template>
@@ -0,0 +1,97 @@
.template-form {
display: grid;
gap: 18px;
padding-right: 8px;
}
.section-card {
display: grid;
gap: 16px;
padding: 18px 20px;
border-radius: 20px;
border: 1px solid var(--xboard-border);
background: #fff;
}
.section-card header {
display: flex;
align-items: flex-start;
gap: 12px;
}
.section-card header .el-icon {
margin-top: 2px;
color: var(--xboard-primary);
}
.section-card header div {
display: grid;
gap: 4px;
}
.section-card header strong {
color: var(--xboard-text-strong);
}
.section-card header span,
.switch-box span {
color: var(--xboard-text-muted);
line-height: 1.45;
font-size: 13px;
}
.form-grid {
display: grid;
gap: 16px;
}
.two-columns {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.full-row {
grid-column: 1 / -1;
}
.number-input,
.date-input {
width: 100%;
}
.switch-box {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 48px;
}
.switch-box strong {
display: block;
margin-bottom: 4px;
color: var(--xboard-text-strong);
}
.switch-box--soft {
padding: 14px 16px;
border-radius: 16px;
background: var(--xboard-surface-soft);
}
.color-row {
display: grid;
grid-template-columns: auto 1fr;
gap: 12px;
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
@media (max-width: 720px) {
.two-columns {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,380 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import {
Aim,
CircleCheck,
Clock,
Picture,
Present,
Setting,
} from '@element-plus/icons-vue'
import {
createGiftCardTemplate,
updateGiftCardTemplate,
} from '@/api/admin'
import type {
AdminGiftCardTemplateItem,
AdminPlanOption,
} from '@/types/api'
import {
buildGiftCardTypeOptions,
createGiftCardTemplateFormModel,
toGiftCardTemplateFormModel,
toGiftCardTemplatePayload,
type GiftCardOption,
type GiftCardTemplateFormModel,
} from '@/utils/giftCards'
type DrawerMode = 'create' | 'edit'
const props = defineProps<{
visible: boolean
mode: DrawerMode
template?: AdminGiftCardTemplateItem | null
plans: AdminPlanOption[]
typeMap?: Record<string, string> | null
}>()
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void
(e: 'success'): void
}>()
const formRef = ref<FormInstance>()
const saving = ref(false)
const form = reactive<GiftCardTemplateFormModel>(createGiftCardTemplateFormModel())
const title = computed(() => props.mode === 'edit' ? '编辑模板' : '添加模板')
const typeOptions = computed<GiftCardOption<1 | 2 | 3>[]>(() => buildGiftCardTypeOptions(props.typeMap))
const rules: FormRules<GiftCardTemplateFormModel> = {
name: [{ required: true, message: '请输入模板名称', trigger: 'blur' }],
type: [{ required: true, message: '请选择模板类型', trigger: 'change' }],
}
function patchForm(nextForm: GiftCardTemplateFormModel) {
Object.assign(form, createGiftCardTemplateFormModel(), nextForm)
}
function closeDrawer() {
emit('update:visible', false)
}
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) {
return
}
if (form.type === 2 && !form.plan_id) {
ElMessage.warning('套餐礼品卡需要先选择目标套餐')
return
}
saving.value = true
try {
const payload = toGiftCardTemplatePayload(form)
if (props.mode === 'edit' && form.id) {
await updateGiftCardTemplate({ ...payload, id: form.id })
ElMessage.success('礼品卡模板已更新')
} else {
await createGiftCardTemplate(payload)
ElMessage.success('礼品卡模板已创建')
}
emit('success')
closeDrawer()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '礼品卡模板保存失败')
} finally {
saving.value = false
}
}
watch(
() => props.visible,
(visible) => {
if (!visible) {
return
}
patchForm(toGiftCardTemplateFormModel(props.template))
},
{ immediate: true },
)
</script>
<template>
<ElDrawer
:model-value="visible"
:title="title"
size="620px"
direction="rtl"
class="gift-card-template-drawer"
@update:model-value="emit('update:visible', $event)"
>
<ElForm
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
class="template-form"
>
<section class="section-card">
<header>
<ElIcon><Setting /></ElIcon>
<div>
<strong>基础配置</strong>
<span>管理模板名称类型与基础开关</span>
</div>
</header>
<div class="form-grid two-columns">
<ElFormItem label="模板名称" prop="name">
<ElInput v-model="form.name" placeholder="请输入模板名称" />
</ElFormItem>
<ElFormItem label="类型" prop="type">
<ElSelect v-model="form.type" placeholder="请选择模板类型">
<ElOption
v-for="item in typeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="描述" class="full-row">
<ElInput
v-model="form.description"
type="textarea"
:rows="4"
placeholder="请输入礼品卡描述"
/>
</ElFormItem>
<ElFormItem label="排序">
<ElInputNumber v-model="form.sort" :min="0" class="number-input" />
</ElFormItem>
<ElFormItem label="状态">
<div class="switch-box">
<div>
<strong>{{ form.status ? '启用中' : '已停用' }}</strong>
<span>禁用后此模板将无法生成或兑换新的礼品卡</span>
</div>
<ElSwitch v-model="form.status" />
</div>
</ElFormItem>
</div>
</section>
<section class="section-card">
<header>
<ElIcon><Present /></ElIcon>
<div>
<strong>奖励内容</strong>
<span>配置余额流量设备数与套餐奖励</span>
</div>
</header>
<div class="form-grid two-columns">
<ElFormItem label="奖励余额(元)">
<ElInputNumber v-model="form.balance_yuan" :min="0" :precision="2" class="number-input" />
</ElFormItem>
<ElFormItem label="奖励流量(GB">
<ElInputNumber v-model="form.transfer_gb" :min="0" :precision="2" class="number-input" />
</ElFormItem>
<ElFormItem label="延长有效期(天)">
<ElInputNumber v-model="form.expire_days" :min="0" class="number-input" />
</ElFormItem>
<ElFormItem label="增加设备数">
<ElInputNumber v-model="form.device_limit" :min="0" class="number-input" />
</ElFormItem>
<ElFormItem label="邀请人奖励比例" class="full-row">
<ElInputNumber
v-model="form.invite_reward_rate"
:min="0"
:max="1"
:step="0.05"
:precision="2"
class="number-input"
/>
</ElFormItem>
<ElFormItem label="重置当月流量" class="full-row">
<div class="switch-box switch-box--soft">
<div>
<strong>开启后将给符合条件的用户直接重置当月流量</strong>
<span>适合流量补偿活动回馈和节假日赠送场景</span>
</div>
<ElSwitch v-model="form.reset_package" />
</div>
</ElFormItem>
<template v-if="form.type === 2">
<ElFormItem label="目标套餐">
<ElSelect v-model="form.plan_id" placeholder="请选择礼包对应套餐">
<ElOption
v-for="plan in plans"
:key="plan.id"
:label="plan.name"
:value="plan.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="套餐有效期(天)">
<ElInputNumber v-model="form.plan_validity_days" :min="0" class="number-input" />
</ElFormItem>
</template>
</div>
</section>
<section class="section-card">
<header>
<ElIcon><Aim /></ElIcon>
<div>
<strong>使用条件</strong>
<span>限制哪些用户可以兑换该模板</span>
</div>
</header>
<div class="form-grid two-columns">
<ElFormItem label="新用户注册天数限制">
<ElInputNumber v-model="form.new_user_max_days" :min="0" class="number-input" />
</ElFormItem>
<ElFormItem label="允许的套餐">
<ElSelect
v-model="form.allowed_plan_ids"
multiple
filterable
collapse-tags
collapse-tags-tooltip
placeholder="选择允许兑换的套餐(留空则不限)"
>
<ElOption
v-for="plan in plans"
:key="plan.id"
:label="plan.name"
:value="plan.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="仅限新用户">
<ElSwitch v-model="form.new_user_only" />
</ElFormItem>
<ElFormItem label="仅限付费用户">
<ElSwitch v-model="form.paid_user_only" />
</ElFormItem>
<ElFormItem label="需要邀请关系">
<ElSwitch v-model="form.require_invite" />
</ElFormItem>
</div>
</section>
<section class="section-card">
<header>
<ElIcon><CircleCheck /></ElIcon>
<div>
<strong>使用限制</strong>
<span>限制单用户可使用次数和冷却时间</span>
</div>
</header>
<div class="form-grid two-columns">
<ElFormItem label="单用户最大使用次数">
<ElInputNumber v-model="form.max_use_per_user" :min="0" class="number-input" />
</ElFormItem>
<ElFormItem label="同类卡冷却时间(小时)">
<ElInputNumber v-model="form.cooldown_hours" :min="0" class="number-input" />
</ElFormItem>
</div>
</section>
<section class="section-card">
<header>
<ElIcon><Clock /></ElIcon>
<div>
<strong>特殊配置</strong>
<span>用于配置节日活动与倍率加成</span>
</div>
</header>
<div class="form-grid two-columns">
<ElFormItem label="活动开始时间">
<ElDatePicker
v-model="form.festival_start_at"
type="datetime"
value-format="X"
class="date-input"
placeholder="请选择开始时间"
/>
</ElFormItem>
<ElFormItem label="活动结束时间">
<ElDatePicker
v-model="form.festival_end_at"
type="datetime"
value-format="X"
class="date-input"
placeholder="请选择结束时间"
/>
</ElFormItem>
<ElFormItem label="节日奖励系数" class="full-row">
<ElInputNumber v-model="form.festival_bonus" :min="0" :precision="2" :step="0.1" class="number-input" />
</ElFormItem>
</div>
</section>
<section class="section-card">
<header>
<ElIcon><Picture /></ElIcon>
<div>
<strong>显示效果</strong>
<span>管理卡片 icon背景图与主题色</span>
</div>
</header>
<div class="form-grid two-columns">
<ElFormItem label="图标 URL">
<ElInput v-model="form.icon" placeholder="请输入图标的 URL" />
</ElFormItem>
<ElFormItem label="背景图片 URL">
<ElInput v-model="form.background_image" placeholder="请输入背景图片的 URL" />
</ElFormItem>
<ElFormItem label="主题色" class="full-row">
<div class="color-row">
<ElColorPicker v-model="form.theme_color" />
<ElInput v-model="form.theme_color" placeholder="#0071e3" />
</div>
</ElFormItem>
</div>
</section>
</ElForm>
<template #footer>
<div class="drawer-footer">
<ElButton @click="closeDrawer">取消</ElButton>
<ElButton type="primary" :loading="saving" @click="handleSubmit">
{{ mode === 'edit' ? '保存修改' : '创建模板' }}
</ElButton>
</div>
</template>
</ElDrawer>
</template>
<style lang="scss" src="./GiftCardTemplateDrawer.scss"></style>
@@ -0,0 +1,157 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Delete, EditPen, Plus, Search } from '@element-plus/icons-vue'
import type {
AdminGiftCardTemplateItem,
AdminGiftCardTemplateType,
AdminPlanOption,
} from '@/types/api'
import type { GiftCardOption, GiftCardTemplateStatusFilter } from '@/utils/giftCards'
import {
formatGiftCardDateTime,
getGiftCardTemplateRewardSummary,
} from '@/utils/giftCards'
const props = defineProps<{
loading: boolean
error: string
templates: AdminGiftCardTemplateItem[]
keyword: string
typeFilter: AdminGiftCardTemplateType | 'all'
statusFilter: GiftCardTemplateStatusFilter
current: number
pageSize: number
total: number
typeOptions: Array<GiftCardOption<AdminGiftCardTemplateType>>
plans: AdminPlanOption[]
}>()
const emit = defineEmits<{
(e: 'update:keyword', value: string): void
(e: 'update:type-filter', value: AdminGiftCardTemplateType | 'all'): void
(e: 'update:status-filter', value: GiftCardTemplateStatusFilter): void
(e: 'update:current', value: number): void
(e: 'update:page-size', value: number): void
(e: 'create'): void
(e: 'reset'): void
(e: 'edit', template: AdminGiftCardTemplateItem): void
(e: 'delete', template: AdminGiftCardTemplateItem): void
(e: 'toggle', template: AdminGiftCardTemplateItem, nextValue: string | number | boolean): void
}>()
const keywordModel = computed({
get: () => props.keyword,
set: (value: string) => emit('update:keyword', value),
})
const typeFilterModel = computed({
get: () => props.typeFilter,
set: (value: AdminGiftCardTemplateType | 'all') => emit('update:type-filter', value),
})
const statusFilterModel = computed({
get: () => props.statusFilter,
set: (value: GiftCardTemplateStatusFilter) => emit('update:status-filter', value),
})
const currentModel = computed({
get: () => props.current,
set: (value: number) => emit('update:current', value),
})
const pageSizeModel = computed({
get: () => props.pageSize,
set: (value: number) => emit('update:page-size', value),
})
</script>
<template>
<div class="tab-panel">
<div class="panel-copy">
<h2>模板管理</h2>
<p>管理礼品卡模板包括创建编辑和删除模板</p>
</div>
<div class="toolbar">
<div class="toolbar-left">
<ElInput v-model="keywordModel" clearable placeholder="搜索礼品卡..." class="toolbar-search">
<template #prefix><ElIcon><Search /></ElIcon></template>
</ElInput>
<ElSelect v-model="typeFilterModel" class="toolbar-filter">
<ElOption label="类型" value="all" />
<ElOption v-for="item in typeOptions" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
<ElSelect v-model="statusFilterModel" class="toolbar-filter">
<ElOption label="状态" value="all" />
<ElOption label="启用中" value="enabled" />
<ElOption label="已停用" value="disabled" />
</ElSelect>
</div>
<div class="toolbar-right">
<ElButton type="primary" @click="emit('create')">
<ElIcon><Plus /></ElIcon>
添加模板
</ElButton>
<ElButton @click="emit('reset')">重置</ElButton>
</div>
</div>
<ElAlert v-if="error" type="error" :closable="false" show-icon :title="error" />
<ElTable :data="templates" v-loading="loading" class="data-table" row-key="id" empty-text="当前筛选条件下暂无模板">
<ElTableColumn prop="id" label="ID" width="88" />
<ElTableColumn label="状态" width="110">
<template #default="{ row }">
<ElSwitch :model-value="Boolean(row.status)" @change="emit('toggle', row, $event)" />
</template>
</ElTableColumn>
<ElTableColumn label="名称" min-width="220">
<template #default="{ row }">
<div class="name-cell">
<strong>{{ row.name }}</strong>
<span>{{ row.description || '暂无描述' }}</span>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="类型" width="150">
<template #default="{ row }">
<span class="pill pill--soft">{{ row.type_name }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="奖励内容" min-width="260">
<template #default="{ row }">
<div class="reward-stack">
<span v-for="item in getGiftCardTemplateRewardSummary(row, plans)" :key="item" class="reward-chip">{{ item }}</span>
</div>
</template>
</ElTableColumn>
<ElTableColumn prop="sort" label="排序" width="90" />
<ElTableColumn label="创建时间" min-width="180">
<template #default="{ row }">{{ formatGiftCardDateTime(row.created_at) }}</template>
</ElTableColumn>
<ElTableColumn label="操作" width="130" fixed="right">
<template #default="{ row }">
<div class="action-group">
<ElButton text @click="emit('edit', row)"><ElIcon><EditPen /></ElIcon></ElButton>
<ElButton text class="danger-btn" @click="emit('delete', row)"><ElIcon><Delete /></ElIcon></ElButton>
</div>
</template>
</ElTableColumn>
</ElTable>
<footer class="table-footer">
<span>已选择 0 {{ total }} </span>
<ElPagination
v-model:current-page="currentModel"
v-model:page-size="pageSizeModel"
:page-sizes="[20, 50, 100]"
layout="sizes, prev, pager, next"
:total="total"
background
/>
</footer>
</div>
</template>
@@ -0,0 +1,92 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RefreshRight, Search } from '@element-plus/icons-vue'
import type { AdminGiftCardUsageItem } from '@/types/api'
import { formatGiftCardDateTime, formatGiftCardMultiplier } from '@/utils/giftCards'
const props = defineProps<{
loading: boolean
error: string
usages: AdminGiftCardUsageItem[]
keyword: string
current: number
pageSize: number
total: number
}>()
const emit = defineEmits<{
(e: 'update:keyword', value: string): void
(e: 'update:current', value: number): void
(e: 'update:page-size', value: number): void
(e: 'reset'): void
(e: 'refresh'): void
}>()
const keywordModel = computed({
get: () => props.keyword,
set: (value: string) => emit('update:keyword', value),
})
const currentModel = computed({
get: () => props.current,
set: (value: number) => emit('update:current', value),
})
const pageSizeModel = computed({
get: () => props.pageSize,
set: (value: number) => emit('update:page-size', value),
})
</script>
<template>
<div class="tab-panel">
<div class="panel-copy">
<h2>使用记录</h2>
<p>查看礼品卡的使用记录和详细信息</p>
</div>
<div class="toolbar">
<div class="toolbar-left">
<ElInput v-model="keywordModel" clearable placeholder="搜索用户邮箱..." class="toolbar-search">
<template #prefix><ElIcon><Search /></ElIcon></template>
</ElInput>
</div>
<div class="toolbar-right">
<ElButton @click="emit('reset')">重置</ElButton>
<ElButton @click="emit('refresh')">
<ElIcon><RefreshRight /></ElIcon>
刷新
</ElButton>
</div>
</div>
<ElAlert v-if="error" type="error" :closable="false" show-icon :title="error" />
<ElTable :data="usages" v-loading="loading" class="data-table" row-key="id" empty-text="暂无数据">
<ElTableColumn prop="id" label="ID" width="88" />
<ElTableColumn prop="code" label="兑换码" min-width="240">
<template #default="{ row }"><span class="mono">{{ row.code }}</span></template>
</ElTableColumn>
<ElTableColumn prop="user_email" label="用户邮箱" min-width="220" />
<ElTableColumn prop="template_name" label="模板名称" min-width="180" />
<ElTableColumn label="使用倍率" width="120">
<template #default="{ row }">{{ formatGiftCardMultiplier(row.multiplier_applied) }}</template>
</ElTableColumn>
<ElTableColumn label="使用时间" min-width="180">
<template #default="{ row }">{{ formatGiftCardDateTime(row.created_at) }}</template>
</ElTableColumn>
</ElTable>
<footer class="table-footer">
<span>已选择 0 {{ total }} </span>
<ElPagination
v-model:current-page="currentModel"
v-model:page-size="pageSizeModel"
:page-sizes="[20, 50, 100]"
layout="sizes, prev, pager, next"
:total="total"
background
/>
</footer>
</div>
</template>
@@ -0,0 +1,325 @@
.gift-cards-page {
display: grid;
gap: 18px;
}
.page-intro {
display: grid;
gap: 10px;
}
.intro-copy {
display: grid;
gap: 8px;
}
.intro-copy h1 {
font-size: clamp(34px, 5vw, 48px);
line-height: 1.08;
letter-spacing: -0.28px;
color: var(--xboard-text-strong);
}
.intro-copy p,
.panel-copy p,
.table-footer span,
.empty-copy,
.stats-list-card header span,
.name-cell span {
color: var(--xboard-text-muted);
line-height: 1.47;
}
.segment-shell,
.tab-panel {
display: grid;
gap: 18px;
}
.segment-tabs {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
padding: 4px;
border-radius: 18px;
background: #e9edf4;
}
.segment-tab {
height: 44px;
border: 0;
border-radius: 14px;
background: transparent;
color: var(--xboard-text-secondary);
cursor: pointer;
transition: 0.2s ease;
}
.segment-tab.is-active {
background: #ffffff;
color: var(--xboard-text-strong);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
}
.panel-copy {
display: grid;
gap: 6px;
}
.panel-copy h2 {
font-size: 28px;
line-height: 1.1;
color: var(--xboard-text-strong);
}
.toolbar,
.toolbar-left,
.toolbar-right,
.table-footer,
.action-group,
.status-with-switch,
.code-line {
display: flex;
align-items: center;
gap: 12px;
}
.toolbar,
.table-footer {
justify-content: space-between;
}
.toolbar-left {
flex-wrap: wrap;
}
.toolbar-search {
width: min(280px, 100%);
}
.toolbar-filter {
width: 150px;
}
.data-table {
border-radius: 18px;
overflow: hidden;
}
.data-table th.el-table__cell {
background: #fbfbfd;
color: var(--xboard-text-secondary);
}
.data-table .el-table__row td.el-table__cell {
padding-top: 14px;
padding-bottom: 14px;
}
.name-cell {
display: grid;
gap: 4px;
}
.name-cell strong,
.stats-list-card strong {
color: var(--xboard-text-strong);
}
.reward-stack {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.reward-chip,
.batch-pill,
.pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 28px;
padding: 0 10px;
border: 0;
border-radius: 999px;
background: #f5f5f7;
color: var(--xboard-text-secondary);
font-size: 12px;
}
.reward-chip {
justify-content: flex-start;
}
.pill--success {
background: rgba(35, 134, 63, 0.12);
color: var(--xboard-success);
}
.pill--warning {
background: rgba(176, 90, 0, 0.12);
color: var(--xboard-warning);
}
.pill--danger {
background: rgba(201, 52, 40, 0.12);
color: var(--xboard-danger);
}
.pill--info {
background: rgba(0, 113, 227, 0.12);
color: var(--xboard-primary);
}
.pill--soft {
background: rgba(0, 113, 227, 0.08);
color: var(--xboard-primary);
}
.code-cell {
display: grid;
gap: 8px;
}
.code-line {
justify-content: flex-start;
}
.batch-pill {
width: fit-content;
cursor: pointer;
}
.batch-pill--active {
background: rgba(0, 113, 227, 0.12);
color: var(--xboard-primary);
}
.batch-tip {
font-size: 13px;
color: var(--xboard-text-secondary);
}
.danger-btn {
color: var(--xboard-danger);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.stats-card,
.stats-list-card {
display: grid;
gap: 8px;
padding: 22px 24px;
border-radius: 22px;
background: #ffffff;
box-shadow: var(--xboard-shadow);
}
.stats-card span {
color: var(--xboard-text-secondary);
}
.stats-card strong {
font-size: 40px;
line-height: 1;
color: var(--xboard-text-strong);
}
.stats-secondary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.stats-list-card header {
display: grid;
gap: 4px;
}
.stats-list-card ul {
display: grid;
gap: 10px;
list-style: none;
}
.stats-list-card li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid var(--xboard-border);
}
.stats-list-card li:last-child {
border-bottom: 0;
}
.stats-list-card li div {
display: grid;
gap: 4px;
}
.stats-list-card li div span {
color: var(--xboard-text-muted);
font-size: 13px;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
.code-edit-form {
display: grid;
gap: 12px;
}
.full-width {
width: 100%;
}
@media (max-width: 1280px) {
.stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.stats-secondary {
grid-template-columns: 1fr;
}
}
@media (max-width: 960px) {
.segment-tabs {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.toolbar,
.table-footer {
flex-direction: column;
align-items: stretch;
}
.toolbar-right {
justify-content: flex-end;
flex-wrap: wrap;
}
}
@media (max-width: 720px) {
.stats-grid,
.segment-tabs {
grid-template-columns: 1fr;
}
.toolbar-filter,
.toolbar-search {
width: 100%;
}
}
@@ -0,0 +1,197 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { updateGiftCardCode } from '@/api/admin'
import type { AdminGiftCardCodeItem, AdminGiftCardCodeStatus } from '@/types/api'
import GiftCardCodeBatchDialog from './GiftCardCodeBatchDialog.vue'
import GiftCardCodesTab from './GiftCardCodesTab.vue'
import GiftCardStatsTab from './GiftCardStatsTab.vue'
import GiftCardTemplateDrawer from './GiftCardTemplateDrawer.vue'
import GiftCardTemplatesTab from './GiftCardTemplatesTab.vue'
import GiftCardUsagesTab from './GiftCardUsagesTab.vue'
import { useGiftCardsManagement } from './useGiftCardsManagement'
const vm = reactive(useGiftCardsManagement())
const codeEditorVisible = ref(false)
const codeSaving = ref(false)
const codeForm = reactive({
id: 0,
expires_at: '' as string | number,
max_usage: 1,
status: 0 as AdminGiftCardCodeStatus,
})
function openCodeEditor(code: AdminGiftCardCodeItem) {
codeForm.id = code.id
codeForm.max_usage = code.max_usage
codeForm.status = code.status
codeForm.expires_at = code.expires_at ? String(Number(code.expires_at)) : ''
codeEditorVisible.value = true
}
async function submitCodeEdit() {
codeSaving.value = true
try {
await updateGiftCardCode({
id: codeForm.id,
expires_at: codeForm.expires_at ? Number(codeForm.expires_at) : null,
max_usage: codeForm.max_usage,
status: codeForm.status,
})
ElMessage.success('兑换码信息已更新')
codeEditorVisible.value = false
await Promise.all([vm.loadCodes(), vm.loadStatistics()])
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '兑换码更新失败')
} finally {
codeSaving.value = false
}
}
</script>
<template>
<div class="gift-cards-page">
<section class="page-intro">
<div class="intro-copy">
<h1>礼品卡管理</h1>
<p>在这里可以管理礼品卡模板兑换码和使用记录等功能</p>
</div>
</section>
<section class="segment-shell">
<div class="segment-tabs">
<button type="button" :class="['segment-tab', { 'is-active': vm.activeTab === 'templates' }]" @click="vm.activeTab = 'templates'">模板管理</button>
<button type="button" :class="['segment-tab', { 'is-active': vm.activeTab === 'codes' }]" @click="vm.activeTab = 'codes'">兑换码管理</button>
<button type="button" :class="['segment-tab', { 'is-active': vm.activeTab === 'usages' }]" @click="vm.activeTab = 'usages'">使用记录</button>
<button type="button" :class="['segment-tab', { 'is-active': vm.activeTab === 'statistics' }]" @click="vm.activeTab = 'statistics'">统计数据</button>
</div>
<GiftCardTemplatesTab
v-if="vm.activeTab === 'templates'"
:loading="vm.templateLoading"
:error="vm.templateError"
:templates="vm.visibleTemplates"
:keyword="vm.templateKeyword"
:type-filter="vm.templateTypeFilter"
:status-filter="vm.templateStatusFilter"
:current="vm.templateCurrent"
:page-size="vm.templatePageSize"
:total="vm.filteredTemplates.length"
:type-options="vm.typeOptions"
:plans="vm.plans"
@update:keyword="vm.templateKeyword = $event"
@update:type-filter="vm.templateTypeFilter = $event"
@update:status-filter="vm.templateStatusFilter = $event"
@update:current="vm.templateCurrent = $event"
@update:page-size="vm.templatePageSize = $event"
@create="vm.openCreateTemplate"
@reset="vm.resetTemplateFilters"
@edit="vm.openEditTemplate"
@delete="vm.handleTemplateDelete"
@toggle="vm.handleTemplateToggle"
/>
<GiftCardCodesTab
v-else-if="vm.activeTab === 'codes'"
:loading="vm.codesLoading"
:error="vm.codesError"
:codes="vm.visibleCodes"
:keyword="vm.codeKeyword"
:template-filter="vm.codeTemplateFilter"
:status-filter="vm.codeStatusFilter"
:current="vm.codeCurrent"
:page-size="vm.codePageSize"
:total="vm.filteredCodes.length"
:templates="vm.templates"
:resolved-batch-id="vm.resolvedBatchId"
@update:keyword="vm.codeKeyword = $event"
@update:template-filter="vm.codeTemplateFilter = $event"
@update:status-filter="vm.codeStatusFilter = $event"
@update:current="vm.codeCurrent = $event"
@update:page-size="vm.codePageSize = $event"
@create="vm.batchDialogVisible = true"
@export="vm.handleExportBatch"
@reset="vm.resetCodeFilters"
@copy="vm.copyCode"
@select-batch="vm.setSelectedBatchId($event)"
@edit="openCodeEditor"
@delete="vm.handleCodeDelete"
@toggle="vm.handleCodeToggle"
/>
<GiftCardUsagesTab
v-else-if="vm.activeTab === 'usages'"
:loading="vm.usagesLoading"
:error="vm.usagesError"
:usages="vm.visibleUsages"
:keyword="vm.usageKeyword"
:current="vm.usageCurrent"
:page-size="vm.usagePageSize"
:total="vm.filteredUsages.length"
@update:keyword="vm.usageKeyword = $event"
@update:current="vm.usageCurrent = $event"
@update:page-size="vm.usagePageSize = $event"
@reset="vm.resetUsageFilters"
@refresh="vm.loadUsages"
/>
<GiftCardStatsTab
v-else
:loading="vm.statsLoading"
:error="vm.statsError"
:statistics="vm.statistics"
/>
</section>
<GiftCardTemplateDrawer
v-model:visible="vm.templateDrawerVisible"
:mode="vm.templateDrawerMode"
:template="vm.activeTemplate"
:plans="vm.plans"
:type-map="vm.typeMap"
@success="vm.handleTemplateSuccess"
/>
<GiftCardCodeBatchDialog
v-model:visible="vm.batchDialogVisible"
:templates="vm.templates.filter((item) => Boolean(item.status))"
@success="vm.handleBatchGenerated"
/>
<ElDialog v-model="codeEditorVisible" title="编辑兑换码" width="440px">
<ElForm label-position="top" class="code-edit-form">
<ElFormItem label="过期时间">
<ElDatePicker
v-model="codeForm.expires_at"
type="datetime"
value-format="X"
class="full-width"
placeholder="留空则长期有效"
/>
</ElFormItem>
<ElFormItem label="最大使用次数">
<ElInputNumber v-model="codeForm.max_usage" :min="1" :max="1000" class="full-width" />
</ElFormItem>
<ElFormItem label="状态">
<ElSelect v-model="codeForm.status" class="full-width">
<ElOption label="未使用" :value="0" />
<ElOption label="已使用" :value="1" />
<ElOption label="已过期" :value="2" />
<ElOption label="已禁用" :value="3" />
</ElSelect>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="codeEditorVisible = false">取消</ElButton>
<ElButton type="primary" :loading="codeSaving" @click="submitCodeEdit">保存修改</ElButton>
</div>
</template>
</ElDialog>
</div>
</template>
<style lang="scss" src="./GiftCardsView.scss"></style>
@@ -0,0 +1,428 @@
import { computed, onMounted, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
deleteGiftCardCode,
deleteGiftCardTemplate,
exportGiftCardCodes,
fetchGiftCardCodes,
fetchGiftCardTemplates,
fetchGiftCardUsages,
getGiftCardStatistics,
getGiftCardTypes,
getPlans,
toggleGiftCardCode,
updateGiftCardTemplate,
} from '@/api/admin'
import type {
AdminGiftCardCodeItem,
AdminGiftCardStatistics,
AdminGiftCardTemplateItem,
AdminGiftCardTemplateType,
AdminGiftCardUsageItem,
AdminPlanOption,
} from '@/types/api'
import {
buildGiftCardTypeOptions,
filterGiftCardCodes,
filterGiftCardTemplates,
filterGiftCardUsages,
type GiftCardCodeStatusFilter,
type GiftCardTemplateStatusFilter,
} from '@/utils/giftCards'
export type GiftCardTabKey = 'templates' | 'codes' | 'usages' | 'statistics'
type TemplateDrawerMode = 'create' | 'edit'
export function useGiftCardsManagement() {
const activeTab = ref<GiftCardTabKey>('templates')
const templateLoading = ref(false)
const codesLoading = ref(false)
const usagesLoading = ref(false)
const statsLoading = ref(false)
const templateError = ref('')
const codesError = ref('')
const usagesError = ref('')
const statsError = ref('')
const templates = ref<AdminGiftCardTemplateItem[]>([])
const codes = ref<AdminGiftCardCodeItem[]>([])
const usages = ref<AdminGiftCardUsageItem[]>([])
const statistics = ref<AdminGiftCardStatistics | null>(null)
const plans = ref<AdminPlanOption[]>([])
const typeMap = ref<Record<string, string>>({})
const templateKeyword = ref('')
const templateTypeFilter = ref<AdminGiftCardTemplateType | 'all'>('all')
const templateStatusFilter = ref<GiftCardTemplateStatusFilter>('all')
const templateCurrent = ref(1)
const templatePageSize = ref(20)
const codeKeyword = ref('')
const codeTemplateFilter = ref<number | 'all'>('all')
const codeStatusFilter = ref<GiftCardCodeStatusFilter>('all')
const codeCurrent = ref(1)
const codePageSize = ref(20)
const selectedBatchId = ref('')
const usageKeyword = ref('')
const usageCurrent = ref(1)
const usagePageSize = ref(20)
const templateDrawerVisible = ref(false)
const templateDrawerMode = ref<TemplateDrawerMode>('create')
const activeTemplate = ref<AdminGiftCardTemplateItem | null>(null)
const batchDialogVisible = ref(false)
const typeOptions = computed(() => buildGiftCardTypeOptions(typeMap.value))
const filteredTemplates = computed(() => filterGiftCardTemplates(
templates.value,
templateKeyword.value,
templateTypeFilter.value,
templateStatusFilter.value,
))
const visibleTemplates = computed(() => {
const start = (templateCurrent.value - 1) * templatePageSize.value
return filteredTemplates.value.slice(start, start + templatePageSize.value)
})
const filteredCodes = computed(() => filterGiftCardCodes(
codes.value,
codeKeyword.value,
codeTemplateFilter.value,
codeStatusFilter.value,
))
const visibleCodes = computed(() => {
const start = (codeCurrent.value - 1) * codePageSize.value
return filteredCodes.value.slice(start, start + codePageSize.value)
})
const filteredUsages = computed(() => filterGiftCardUsages(usages.value, usageKeyword.value))
const visibleUsages = computed(() => {
const start = (usageCurrent.value - 1) * usagePageSize.value
return filteredUsages.value.slice(start, start + usagePageSize.value)
})
const resolvedBatchId = computed(() => {
if (selectedBatchId.value) {
return selectedBatchId.value
}
const batchIds = [...new Set(filteredCodes.value.map((item) => item.batch_id).filter(Boolean))]
return batchIds.length === 1 ? String(batchIds[0]) : ''
})
async function loadMeta() {
try {
const [plansResponse, typeResponse] = await Promise.all([
getPlans(),
getGiftCardTypes(),
])
plans.value = plansResponse.data ?? []
typeMap.value = typeResponse.data ?? {}
} catch (error) {
ElMessage.warning(error instanceof Error ? error.message : '礼品卡元数据加载失败')
}
}
async function loadTemplates() {
templateLoading.value = true
templateError.value = ''
try {
const response = await fetchGiftCardTemplates({
page: 1,
per_page: 500,
type: templateTypeFilter.value === 'all' ? undefined : templateTypeFilter.value,
status: templateStatusFilter.value === 'all'
? undefined
: (templateStatusFilter.value === 'enabled' ? 1 : 0),
})
templates.value = response.data ?? []
} catch (error) {
templateError.value = error instanceof Error ? error.message : '模板列表加载失败'
} finally {
templateLoading.value = false
}
}
async function loadCodes() {
codesLoading.value = true
codesError.value = ''
try {
const response = await fetchGiftCardCodes({
page: 1,
per_page: 500,
template_id: codeTemplateFilter.value === 'all' ? undefined : codeTemplateFilter.value,
status: codeStatusFilter.value === 'all' ? undefined : codeStatusFilter.value,
})
codes.value = response.data ?? []
if (selectedBatchId.value && !codes.value.some((item) => item.batch_id === selectedBatchId.value)) {
selectedBatchId.value = ''
}
} catch (error) {
codesError.value = error instanceof Error ? error.message : '兑换码列表加载失败'
} finally {
codesLoading.value = false
}
}
async function loadUsages() {
usagesLoading.value = true
usagesError.value = ''
try {
const response = await fetchGiftCardUsages({
page: 1,
per_page: 500,
})
usages.value = response.data ?? []
} catch (error) {
usagesError.value = error instanceof Error ? error.message : '使用记录加载失败'
} finally {
usagesLoading.value = false
}
}
async function loadStatistics() {
statsLoading.value = true
statsError.value = ''
try {
const response = await getGiftCardStatistics()
statistics.value = response.data
} catch (error) {
statsError.value = error instanceof Error ? error.message : '统计数据加载失败'
} finally {
statsLoading.value = false
}
}
function openCreateTemplate() {
templateDrawerMode.value = 'create'
activeTemplate.value = null
templateDrawerVisible.value = true
}
function openEditTemplate(template: AdminGiftCardTemplateItem) {
templateDrawerMode.value = 'edit'
activeTemplate.value = template
templateDrawerVisible.value = true
}
async function handleTemplateToggle(template: AdminGiftCardTemplateItem, nextValue: string | number | boolean) {
const normalized = Boolean(nextValue)
if (Boolean(template.status) === normalized) {
return
}
try {
await updateGiftCardTemplate({
id: template.id,
name: template.name,
type: template.type,
status: normalized,
rewards: template.rewards ?? {},
})
template.status = normalized
ElMessage.success('模板状态已更新')
await loadStatistics()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '模板状态更新失败')
}
}
async function handleTemplateDelete(template: AdminGiftCardTemplateItem) {
try {
await ElMessageBox.confirm(`删除模板「${template.name}」后无法恢复,确认继续吗?`, '删除模板', {
type: 'warning',
})
await deleteGiftCardTemplate(template.id)
ElMessage.success('模板已删除')
await Promise.all([loadTemplates(), loadCodes(), loadStatistics()])
} catch (error) {
if (error === 'cancel' || error === 'close') {
return
}
ElMessage.error(error instanceof Error ? error.message : '模板删除失败')
}
}
async function handleTemplateSuccess() {
await Promise.all([loadTemplates(), loadCodes(), loadStatistics()])
}
async function handleCodeToggle(code: AdminGiftCardCodeItem, nextValue: string | number | boolean) {
const targetEnabled = Boolean(nextValue)
if (targetEnabled === (code.status !== 3)) {
return
}
try {
await toggleGiftCardCode(code.id, targetEnabled ? 'enable' : 'disable')
code.status = targetEnabled ? 0 : 3
ElMessage.success(targetEnabled ? '兑换码已启用' : '兑换码已禁用')
await loadStatistics()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '兑换码状态更新失败')
}
}
async function copyCode(code: string) {
try {
await navigator.clipboard.writeText(code)
ElMessage.success('兑换码已复制')
} catch {
ElMessage.error('复制失败,请手动复制')
}
}
async function handleCodeDelete(code: AdminGiftCardCodeItem) {
try {
await ElMessageBox.confirm(`删除兑换码 ${code.code} 后无法恢复,确认继续吗?`, '删除兑换码', {
type: 'warning',
})
await deleteGiftCardCode(code.id)
ElMessage.success('兑换码已删除')
await Promise.all([loadCodes(), loadStatistics()])
} catch (error) {
if (error === 'cancel' || error === 'close') {
return
}
ElMessage.error(error instanceof Error ? error.message : '兑换码删除失败')
}
}
async function handleExportBatch() {
if (!resolvedBatchId.value) {
ElMessage.warning('请先选中一个批次后再导出')
return
}
try {
const blob = await exportGiftCardCodes(resolvedBatchId.value)
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `gift_cards_${resolvedBatchId.value}.txt`
link.click()
URL.revokeObjectURL(url)
ElMessage.success('兑换码文本已导出')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '兑换码导出失败')
}
}
function handleBatchGenerated(payload: { batchId: string }) {
selectedBatchId.value = payload.batchId
void Promise.all([loadCodes(), loadStatistics()])
}
function resetTemplateFilters() {
templateKeyword.value = ''
templateTypeFilter.value = 'all'
templateStatusFilter.value = 'all'
}
function resetCodeFilters() {
codeKeyword.value = ''
codeTemplateFilter.value = 'all'
codeStatusFilter.value = 'all'
selectedBatchId.value = ''
}
function resetUsageFilters() {
usageKeyword.value = ''
}
watch([templateKeyword, templateTypeFilter, templateStatusFilter, templatePageSize], () => {
templateCurrent.value = 1
})
watch([codeKeyword, codeTemplateFilter, codeStatusFilter, codePageSize], () => {
codeCurrent.value = 1
})
watch([usageKeyword, usagePageSize], () => {
usageCurrent.value = 1
})
watch([templateTypeFilter, templateStatusFilter], () => {
void loadTemplates()
})
watch([codeTemplateFilter, codeStatusFilter], () => {
void loadCodes()
})
onMounted(() => {
void Promise.allSettled([
loadMeta(),
loadTemplates(),
loadCodes(),
loadUsages(),
loadStatistics(),
])
})
return {
activeTab,
templateLoading,
codesLoading,
usagesLoading,
statsLoading,
templateError,
codesError,
usagesError,
statsError,
templates,
statistics,
plans,
typeMap,
typeOptions,
templateKeyword,
templateTypeFilter,
templateStatusFilter,
templateCurrent,
templatePageSize,
filteredTemplates,
visibleTemplates,
codeKeyword,
codeTemplateFilter,
codeStatusFilter,
codeCurrent,
codePageSize,
filteredCodes,
visibleCodes,
usageKeyword,
usageCurrent,
usagePageSize,
filteredUsages,
visibleUsages,
resolvedBatchId,
templateDrawerVisible,
templateDrawerMode,
activeTemplate,
batchDialogVisible,
loadUsages,
loadCodes,
loadStatistics,
openCreateTemplate,
openEditTemplate,
handleTemplateToggle,
handleTemplateDelete,
handleTemplateSuccess,
handleCodeToggle,
copyCode,
handleCodeDelete,
handleExportBatch,
handleBatchGenerated,
resetTemplateFilters,
resetCodeFilters,
resetUsageFilters,
setSelectedBatchId: (batchId: string) => {
selectedBatchId.value = batchId
},
}
}
@@ -169,7 +169,7 @@ watch(
>
<div class="dialog-shell">
<div class="dialog-copy">
<p>Knowledge Base</p>
<p>系统管理</p>
<h2>{{ dialogTitle }}</h2>
<span>发布或维护知识库文案支持分类语言显示状态和 Markdown 正文编辑</span>
</div>
+43 -26
View File
@@ -1,53 +1,45 @@
.knowledge-page {
display: grid;
gap: 24px;
gap: 20px;
}
.knowledge-hero {
display: flex;
justify-content: space-between;
gap: 24px;
padding: 32px;
border-radius: 28px;
background: #ffffff;
box-shadow: var(--xboard-shadow);
gap: 20px;
align-items: flex-start;
}
.knowledge-copy {
display: grid;
gap: 10px;
gap: 8px;
max-width: 640px;
}
.knowledge-kicker {
color: var(--xboard-text-muted);
font-size: 11px;
letter-spacing: 0.24em;
text-transform: uppercase;
}
.knowledge-copy h1 {
margin: 0;
font-size: clamp(34px, 5vw, 52px);
line-height: 1.08;
letter-spacing: -0.28px;
color: var(--xboard-text-strong);
}
.knowledge-copy span {
.knowledge-copy p {
margin: 0;
color: var(--xboard-text-secondary);
line-height: 1.6;
}
.hero-stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 160px));
grid-template-columns: repeat(4, minmax(0, 128px));
gap: 12px;
}
.hero-stats article {
display: grid;
gap: 6px;
padding: 18px;
padding: 16px 18px;
border-radius: 18px;
background: #fbfbfd;
border: 1px solid rgba(0, 0, 0, 0.05);
@@ -66,9 +58,9 @@
.table-shell {
display: grid;
gap: 18px;
padding: 24px;
border-radius: 26px;
gap: 16px;
padding: 20px 20px 18px;
border-radius: 24px;
background: #ffffff;
box-shadow: var(--xboard-shadow);
}
@@ -103,17 +95,21 @@
}
.toolbar-search {
width: min(320px, 100%);
width: min(260px, 100%);
}
.toolbar-filter {
width: 172px;
width: 148px;
}
.table-alert {
margin-bottom: 2px;
}
.knowledge-table :deep(.el-table__cell) {
vertical-align: top;
}
.knowledge-table :deep(th.el-table__cell) {
color: var(--xboard-text-secondary);
background: #fbfbfd;
@@ -135,6 +131,8 @@
.title-cell strong,
.sort-meta strong {
color: var(--xboard-text-strong);
font-size: 16px;
line-height: 1.35;
}
.title-cell span,
@@ -144,15 +142,34 @@
color: var(--xboard-text-muted);
}
.title-cell small {
color: var(--xboard-text-muted);
}
.action-btn {
width: 36px;
height: 36px;
padding: 0;
border-radius: 10px;
font-size: 18px;
}
.action-btn:hover {
color: #0071e3;
background: rgba(0, 113, 227, 0.08);
}
.danger-btn {
color: var(--xboard-danger);
}
.danger-btn:hover {
color: #d92d20;
background: rgba(217, 45, 32, 0.08);
}
.sort-copy {
margin: 0;
line-height: 1.47;
}
@@ -176,6 +193,7 @@
@media (max-width: 1080px) {
.knowledge-hero,
.hero-stats,
.table-toolbar,
.table-footer,
.sort-item,
@@ -187,16 +205,15 @@
}
.hero-stats {
grid-template-columns: 1fr;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 767px) {
.table-shell,
.knowledge-hero {
padding: 22px;
.hero-stats {
grid-template-columns: 1fr;
}
.toolbar-filter {
width: 100%;
}
@@ -1,7 +1,394 @@
<script setup lang="ts">
import SystemPlaceholderView from './SystemPlaceholderView.vue'
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 {
deleteKnowledge,
getKnowledgeById,
getKnowledgeCategories,
getKnowledges,
sortKnowledges,
toggleKnowledgeVisibility,
} from '@/api/admin'
import type { AdminKnowledgeDetail, AdminKnowledgeListItem } from '@/types/api'
import { formatDateTime } from '@/utils/dashboard'
import {
countVisibleKnowledges,
filterKnowledges,
getKnowledgeCategoryLabel,
moveKnowledgeOrder,
normalizeKnowledgeCategories,
normalizeKnowledgeItem,
} from '@/utils/knowledge'
import KnowledgeEditorDialog from './KnowledgeEditorDialog.vue'
type EditorMode = 'create' | 'edit'
const loading = ref(false)
const sortSubmitting = ref(false)
const editorVisible = ref(false)
const editorMode = ref<EditorMode>('create')
const activeKnowledge = ref<AdminKnowledgeDetail | null>(null)
const sortDialogVisible = ref(false)
const errorMessage = ref('')
const keyword = ref('')
const categoryFilter = ref('')
const current = ref(1)
const pageSize = ref(10)
const knowledges = ref<AdminKnowledgeListItem[]>([])
const categories = ref<string[]>([])
const sortDraft = ref<AdminKnowledgeListItem[]>([])
const toggleLoadingMap = ref<Record<number, boolean>>({})
const detailLoadingId = ref<number | null>(null)
const filteredKnowledges = computed(() => filterKnowledges(
knowledges.value,
keyword.value,
categoryFilter.value,
))
const visibleKnowledges = computed(() => {
const start = (current.value - 1) * pageSize.value
return filteredKnowledges.value.slice(start, start + pageSize.value)
})
const heroStats = computed(() => [
{ label: '知识总数', value: String(knowledges.value.length) },
{ label: '显示中', value: String(countVisibleKnowledges(knowledges.value)) },
{ label: '隐藏中', value: String(Math.max(knowledges.value.length - countVisibleKnowledges(knowledges.value), 0)) },
{ label: '分类数', value: String(categories.value.length) },
])
function normalizeKnowledgeDetail(detail: AdminKnowledgeDetail): AdminKnowledgeDetail {
return {
...detail,
category: typeof detail.category === 'string' ? detail.category.trim() : detail.category,
show: Boolean(detail.show),
body: detail.body || '',
language: detail.language || 'zh-CN',
}
}
function isToggleLoading(id: number): boolean {
return Boolean(toggleLoadingMap.value[id])
}
function isDetailLoading(id: number): boolean {
return detailLoadingId.value === id
}
async function loadData() {
loading.value = true
errorMessage.value = ''
try {
const knowledgeResponse = await getKnowledges()
const items = (knowledgeResponse.data ?? []).map((item) => normalizeKnowledgeItem(item))
knowledges.value = items
try {
const categoryResponse = await getKnowledgeCategories()
categories.value = normalizeKnowledgeCategories(categoryResponse.data ?? [], items)
} catch (error) {
categories.value = normalizeKnowledgeCategories([], items)
ElMessage.warning(error instanceof Error ? error.message : '知识分类加载失败,已回退列表分类')
}
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '知识库管理页面初始化失败'
} finally {
loading.value = false
}
}
function openCreateDialog() {
editorMode.value = 'create'
activeKnowledge.value = null
editorVisible.value = true
}
async function openEditDialog(knowledge: AdminKnowledgeListItem) {
detailLoadingId.value = knowledge.id
try {
const response = await getKnowledgeById(knowledge.id)
activeKnowledge.value = normalizeKnowledgeDetail(response.data)
editorMode.value = 'edit'
editorVisible.value = true
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '知识详情读取失败')
} finally {
detailLoadingId.value = null
}
}
async function handleToggle(knowledge: AdminKnowledgeListItem, nextValue: boolean | string | number) {
const normalizedNextValue = Boolean(nextValue)
if (Boolean(knowledge.show) === normalizedNextValue) {
return
}
toggleLoadingMap.value[knowledge.id] = true
try {
await toggleKnowledgeVisibility(knowledge.id)
knowledge.show = normalizedNextValue
ElMessage.success('知识显示状态已更新')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '知识显示状态更新失败')
} finally {
toggleLoadingMap.value[knowledge.id] = false
}
}
async function handleDelete(knowledge: AdminKnowledgeListItem) {
try {
await ElMessageBox.confirm(`删除知识「${knowledge.title}」后无法恢复,确认继续吗?`, '删除知识', {
type: 'warning',
})
await deleteKnowledge(knowledge.id)
ElMessage.success('知识已删除')
await loadData()
} catch (error) {
if (error === 'cancel' || error === 'close') {
return
}
ElMessage.error(error instanceof Error ? error.message : '知识删除失败')
}
}
function openSortEditor() {
sortDraft.value = knowledges.value.map((item) => ({ ...item }))
sortDialogVisible.value = true
}
function moveDraft(index: number, direction: -1 | 1) {
sortDraft.value = moveKnowledgeOrder(sortDraft.value, index, direction)
}
async function submitSort() {
sortSubmitting.value = true
try {
await sortKnowledges(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, categoryFilter], () => {
current.value = 1
})
watch(filteredKnowledges, (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()
})
</script>
<template>
<SystemPlaceholderView />
<div class="knowledge-page">
<section class="knowledge-hero">
<div class="knowledge-copy">
<h1>知识库管理</h1>
<p>在这里可以配置知识库包括添加删除编辑显隐与排序等操作</p>
</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="openCreateDialog">
<ElIcon><Plus /></ElIcon>
添加知识
</ElButton>
<ElInput
v-model="keyword"
clearable
placeholder="搜索知识..."
class="toolbar-search"
>
<template #prefix>
<ElIcon><Search /></ElIcon>
</template>
</ElInput>
<ElSelect
v-model="categoryFilter"
clearable
placeholder="分类"
class="toolbar-filter"
>
<ElOption
v-for="item in categories"
:key="item"
:label="item"
:value="item"
/>
</ElSelect>
</div>
<div class="toolbar-right">
<ElButton @click="openSortEditor">编辑排序</ElButton>
</div>
</header>
<ElAlert
v-if="errorMessage"
type="error"
show-icon
:closable="false"
:title="errorMessage"
class="table-alert"
>
<template #default>
<ElButton size="small" @click="loadData">重新加载</ElButton>
</template>
</ElAlert>
<ElTable
:data="visibleKnowledges"
v-loading="loading"
class="knowledge-table"
row-key="id"
empty-text="当前筛选条件下暂无知识"
>
<ElTableColumn prop="id" label="ID" width="88" />
<ElTableColumn label="状态" width="110">
<template #default="{ row }">
<ElSwitch
:model-value="Boolean(row.show)"
:loading="isToggleLoading(row.id)"
@change="handleToggle(row, $event)"
/>
</template>
</ElTableColumn>
<ElTableColumn label="标题" min-width="560">
<template #default="{ row }">
<div class="title-cell">
<strong>{{ row.title }}</strong>
<small>最近更新 {{ formatDateTime(row.updated_at) }}</small>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="分类" min-width="140">
<template #default="{ row }">
<ElTag effect="plain" round>{{ getKnowledgeCategoryLabel(row.category) }}</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="120" fixed="right">
<template #default="{ row }">
<div class="action-group">
<ElButton
text
class="action-btn"
:loading="isDetailLoading(row.id)"
@click="openEditDialog(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 {{ filteredKnowledges.length }} </span>
<ElPagination
v-model:current-page="current"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]"
layout="sizes, prev, pager, next"
:total="filteredKnowledges.length"
background
/>
</footer>
</section>
<KnowledgeEditorDialog
v-model:visible="editorVisible"
:mode="editorMode"
:knowledge="activeKnowledge"
:categories="categories"
@success="() => loadData()"
/>
<ElDialog
v-model="sortDialogVisible"
width="min(680px, calc(100vw - 32px))"
title="编辑排序"
class="sort-dialog"
>
<div class="sort-shell">
<p class="sort-copy">按照当前展示顺序调整知识条目排序保存后会同步到后台 `/knowledge/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.title }}</strong>
<span>{{ getKnowledgeCategoryLabel(item.category) }}</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="./SystemKnowledgeView.scss"></style>