feat(admin-frontend): 补齐用户节点与订单运营工作台

新增用户高级筛选、批量操作与更多行级动作,支持邮件、
CSV、封禁恢复、订单分配、邀请查看、流量记录与重置流量

增强节点管理页的分页、父子筛选、跨页勾选、批量修改与
单节点置顶,并补齐后端批量更新 host、group_ids、rate

修复订单佣金状态误判问题,新增真实佣金筛选与行级确认,
同时优化仪表盘排行悬浮详情展示

补充 admin-frontend 独立 Dockerfile、Caddy 配置与 GHCR
发布工作流,支持通过独立镜像部署管理前端
This commit is contained in:
yinjianm
2026-04-24 23:15:48 +08:00
parent e393b11b61
commit d4168720ac
65 changed files with 4114 additions and 438 deletions
+77
View File
@@ -0,0 +1,77 @@
.node-batch-edit-dialog {
.batch-shell,
.batch-section {
display: grid;
gap: 16px;
}
.batch-shell {
gap: 20px;
}
.batch-hero,
.batch-switch-card,
.batch-footer,
.batch-footer__actions {
display: flex;
align-items: center;
gap: 12px;
}
.batch-hero,
.batch-footer,
.batch-switch-card {
justify-content: space-between;
}
.batch-hero h2 {
margin: 0 0 6px;
font-size: 28px;
line-height: 1.08;
letter-spacing: -0.24px;
color: var(--xboard-text-strong);
}
.batch-hero p,
.batch-switch-card span,
.batch-footer__hint {
color: var(--xboard-text-muted);
line-height: 1.55;
}
.batch-section {
padding: 18px 20px;
border-radius: 22px;
background: #fbfbfd;
border: 1px solid rgba(0, 0, 0, 0.05);
}
.batch-switch-card {
align-items: flex-start;
}
.batch-switch-card strong {
display: block;
margin-bottom: 4px;
color: var(--xboard-text-strong);
}
.full-width,
.full-width .el-input__wrapper {
width: 100%;
}
.batch-footer {
width: 100%;
}
@media (max-width: 767px) {
.batch-hero,
.batch-switch-card,
.batch-footer,
.batch-footer__actions {
flex-direction: column;
align-items: stretch;
}
}
}
@@ -0,0 +1,178 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { AdminServerGroupItem } from '@/types/api'
interface NodeBatchEditPayload {
host?: string
rate?: number
group_ids?: number[]
}
const props = defineProps<{
visible: boolean
groups: AdminServerGroupItem[]
selectedCount: number
loading?: boolean
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
submit: [payload: NodeBatchEditPayload]
}>()
const form = reactive({
updateHost: false,
host: '',
updateRate: false,
rate: 1,
updateGroups: false,
groupIds: [] as number[],
})
const hasEnabledField = computed(() => form.updateHost || form.updateRate || form.updateGroups)
function resetForm() {
form.updateHost = false
form.host = ''
form.updateRate = false
form.rate = 1
form.updateGroups = false
form.groupIds = []
}
function closeDialog() {
emit('update:visible', false)
}
function handleSubmit() {
if (!hasEnabledField.value) {
ElMessage.warning('请至少开启一个需要批量修改的字段')
return
}
if (form.updateHost && !form.host.trim()) {
ElMessage.warning('请输入新的节点地址 host')
return
}
if (form.updateRate && (!Number.isFinite(Number(form.rate)) || Number(form.rate) <= 0)) {
ElMessage.warning('请输入大于 0 的倍率')
return
}
emit('submit', {
host: form.updateHost ? form.host.trim() : undefined,
rate: form.updateRate ? Number(form.rate) : undefined,
group_ids: form.updateGroups ? [...form.groupIds] : undefined,
})
}
watch(
() => props.visible,
(visible) => {
if (visible) {
resetForm()
}
},
)
</script>
<template>
<ElDialog
:model-value="props.visible"
width="min(680px, calc(100vw - 24px))"
class="node-batch-edit-dialog"
destroy-on-close
@close="closeDialog"
@update:model-value="emit('update:visible', $event)"
>
<div class="batch-shell">
<header class="batch-hero">
<div>
<h2>批量修改节点</h2>
<p>本轮仅对已勾选节点生效支持统一修改节点地址 host权限组和倍率</p>
</div>
<ElTag round effect="dark">
已选 {{ props.selectedCount }} 个节点
</ElTag>
</header>
<section class="batch-section">
<label class="batch-switch-card">
<div>
<strong>批量修改节点地址</strong>
<span>只修改 `host`不改端口适合整批切换域名或 IP</span>
</div>
<ElSwitch v-model="form.updateHost" />
</label>
<ElInput
v-model="form.host"
:disabled="!form.updateHost"
placeholder="例如 node.example.com 或 1.2.3.4"
/>
</section>
<section class="batch-section">
<label class="batch-switch-card">
<div>
<strong>批量修改权限组</strong>
<span>启用后会整体替换所选节点的权限组留空表示清空权限组</span>
</div>
<ElSwitch v-model="form.updateGroups" />
</label>
<ElSelect
v-model="form.groupIds"
multiple
collapse-tags
collapse-tags-tooltip
:disabled="!form.updateGroups"
placeholder="请选择权限组"
>
<ElOption
v-for="group in props.groups"
:key="group.id"
:label="group.name"
:value="group.id"
/>
</ElSelect>
</section>
<section class="batch-section">
<label class="batch-switch-card">
<div>
<strong>批量修改倍率</strong>
<span>适合统一调整节点倍率不会改动动态倍率规则</span>
</div>
<ElSwitch v-model="form.updateRate" />
</label>
<ElInputNumber
v-model="form.rate"
:disabled="!form.updateRate"
:min="0.01"
:step="0.01"
:precision="2"
:controls="false"
class="full-width"
/>
</section>
</div>
<template #footer>
<div class="batch-footer">
<span class="batch-footer__hint">批量修改不会影响端口协议配置与显隐状态</span>
<div class="batch-footer__actions">
<ElButton @click="closeDialog">取消</ElButton>
<ElButton type="primary" :loading="props.loading" @click="handleSubmit">
确认批量修改
</ElButton>
</div>
</div>
</template>
</ElDialog>
</template>
<style scoped lang="scss" src="./NodeBatchEditDialog.scss"></style>
+248 -12
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { TableInstance } from 'element-plus'
import {
Connection,
MoreFilled,
@@ -11,14 +12,22 @@ import {
User,
} from '@element-plus/icons-vue'
import {
batchUpdateNodes,
copyNode,
deleteNode,
fetchNodes,
fetchNodeRoutes,
getServerGroups,
sortNodes,
updateNode,
} from '@/api/admin'
import type { AdminNodeItem, AdminNodeRouteItem, AdminServerGroupItem } from '@/types/api'
import type {
AdminNodeBatchUpdatePayload,
AdminNodeItem,
AdminNodeRouteItem,
AdminServerGroupItem,
} from '@/types/api'
import NodeBatchEditDialog from './NodeBatchEditDialog.vue'
import NodeEditorDialog from './NodeEditorDialog.vue'
import NodeSortDialog from './NodeSortDialog.vue'
import {
@@ -32,14 +41,17 @@ import {
getNodeIdLabel,
getNodeStatusMeta,
getNodeTypeLabel,
type NodeRelationFilter,
} from '@/utils/nodes'
import { sortNodesByOrder } from '@/utils/nodeEditor'
type NodeAction = 'edit' | 'copy' | 'delete'
type NodeAction = 'edit' | 'copy' | 'pin-top' | 'delete'
type NodeDialogMode = 'create' | 'edit'
type NodeBatchEditPayload = Omit<AdminNodeBatchUpdatePayload, 'ids'>
const route = useRoute()
const router = useRouter()
const tableRef = ref<TableInstance>()
const loading = ref(false)
const errorMessage = ref('')
const nodes = ref<AdminNodeItem[]>([])
@@ -48,30 +60,55 @@ const routes = ref<AdminNodeRouteItem[]>([])
const keyword = ref('')
const typeFilter = ref('all')
const groupFilter = ref('all')
const relationFilter = ref<NodeRelationFilter>('all')
const currentPage = ref(1)
const pageSize = ref(20)
const selectedNodeIds = ref<number[]>([])
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 batchEditVisible = ref(false)
const batchSubmitting = ref(false)
const filteredNodes = computed(() => sortNodesByOrder(filterNodes(
nodes.value,
keyword.value,
typeFilter.value,
groupFilter.value,
relationFilter.value,
)))
const paginatedNodes = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredNodes.value.slice(start, start + pageSize.value)
})
const selectedNodes = computed(() => nodes.value.filter((node) => selectedNodeIds.value.includes(node.id)))
const typeOptions = computed(() => buildNodeTypeOptions(nodes.value))
const hasActiveFilters = computed(() => keyword.value !== '' || typeFilter.value !== 'all' || groupFilter.value !== 'all')
const hasSelectedNodes = computed(() => selectedNodes.value.length > 0)
const hasActiveFilters = computed(() => (
keyword.value !== ''
|| typeFilter.value !== 'all'
|| groupFilter.value !== 'all'
|| relationFilter.value !== 'all'
))
const summaryCards = computed(() => [
{ label: '节点总数', value: String(nodes.value.length) },
{ label: '在线节点', value: String(countOnlineNodes(nodes.value)) },
{ label: '显示中', value: String(countVisibleNodes(nodes.value)) },
{ label: '当前结果', value: String(filteredNodes.value.length) },
{ label: '已勾选', value: String(selectedNodes.value.length) },
])
const batchTargetLabel = computed(() => (
hasSelectedNodes.value
? `当前已选 ${selectedNodes.value.length} 个节点`
: '批量修改仅作用于已勾选节点'
))
function getRouteGroupQuery(): string {
const rawValue = route.query.group
if (Array.isArray(rawValue)) {
@@ -88,6 +125,7 @@ function applyRouteGroupFilter() {
const exists = groups.value.some((group) => String(group.id) === groupValue)
groupFilter.value = exists ? groupValue : 'all'
currentPage.value = 1
}
function markPending(list: typeof switchingIds, id: number, pending: boolean) {
@@ -125,6 +163,34 @@ function openSortEditor() {
sortDialogVisible.value = true
}
function setCurrentPageInRange() {
const totalPages = Math.max(1, Math.ceil(filteredNodes.value.length / pageSize.value))
if (currentPage.value > totalPages) {
currentPage.value = totalPages
}
}
function pruneSelection() {
const validIds = new Set(nodes.value.map((node) => node.id))
selectedNodeIds.value = selectedNodeIds.value.filter((id) => validIds.has(id))
}
function syncTableSelection() {
nextTick(() => {
const table = tableRef.value
if (!table) {
return
}
table.clearSelection()
paginatedNodes.value.forEach((node) => {
if (selectedNodeIds.value.includes(node.id)) {
table.toggleRowSelection(node, true)
}
})
})
}
async function loadNodeBoard() {
loading.value = true
errorMessage.value = ''
@@ -139,7 +205,10 @@ async function loadNodeBoard() {
nodes.value = sortNodesByOrder(nodesResponse.data ?? [])
groups.value = groupsResponse.data ?? []
routes.value = routesResponse.data ?? []
pruneSelection()
applyRouteGroupFilter()
setCurrentPageInRange()
syncTableSelection()
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '节点数据加载失败'
} finally {
@@ -151,12 +220,67 @@ function handleReset() {
keyword.value = ''
typeFilter.value = 'all'
groupFilter.value = 'all'
relationFilter.value = 'all'
currentPage.value = 1
}
function openNodeGroupManagement() {
void router.push('/node-groups')
}
function handleSelectionChange(selection: AdminNodeItem[]) {
const currentPageIds = paginatedNodes.value.map((item) => item.id)
const selectionIds = selection.map((item) => item.id)
const persistedIds = selectedNodeIds.value.filter((id) => !currentPageIds.includes(id))
selectedNodeIds.value = [...new Set([...persistedIds, ...selectionIds])]
}
function clearSelection() {
selectedNodeIds.value = []
syncTableSelection()
}
function openBatchEditor() {
if (!hasSelectedNodes.value) {
ElMessage.warning('请先勾选需要批量修改的节点')
return
}
batchEditVisible.value = true
}
async function handleBatchSubmit(payload: NodeBatchEditPayload) {
const updatePayload: AdminNodeBatchUpdatePayload = {
ids: [...selectedNodeIds.value],
host: payload.host,
rate: payload.rate,
group_ids: payload.group_ids,
}
try {
await ElMessageBox.confirm(
`确认批量修改 ${selectedNodeIds.value.length} 个节点吗?本次只会更新已启用的字段。`,
'批量修改节点',
{ type: 'warning' },
)
batchSubmitting.value = true
await batchUpdateNodes(updatePayload)
batchEditVisible.value = false
clearSelection()
ElMessage.success(`已批量更新 ${updatePayload.ids.length} 个节点`)
await loadNodeBoard()
} catch (error) {
if (error === 'cancel' || error === 'close') {
return
}
ElMessage.error(error instanceof Error ? error.message : '批量修改失败')
} finally {
batchSubmitting.value = false
}
}
async function handleToggleShow(node: AdminNodeItem, nextValue: boolean) {
const previous = Boolean(node.show)
if (previous === nextValue) {
@@ -180,12 +304,42 @@ async function handleToggleShow(node: AdminNodeItem, nextValue: boolean) {
}
}
async function handlePinTop(node: AdminNodeItem) {
const orderedNodes = sortNodesByOrder(nodes.value)
if (orderedNodes[0]?.id === node.id) {
ElMessage.info('当前节点已经在列表顶部')
return
}
markPending(workingIds, node.id, true)
try {
const nextOrder = [node, ...orderedNodes.filter((item) => item.id !== node.id)]
await sortNodes(nextOrder.map((item, index) => ({
id: item.id,
order: index + 1,
})))
currentPage.value = 1
ElMessage.success(`已将“${node.name}”置顶`)
await loadNodeBoard()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '节点置顶失败')
} finally {
markPending(workingIds, node.id, false)
}
}
async function handleAction(action: NodeAction, node: AdminNodeItem) {
if (action === 'edit') {
openEditEditor(node)
return
}
if (action === 'pin-top') {
await handlePinTop(node)
return
}
markPending(workingIds, node.id, true)
try {
@@ -226,6 +380,32 @@ watch(
applyRouteGroupFilter()
},
)
watch([keyword, typeFilter, groupFilter, relationFilter], () => {
currentPage.value = 1
})
watch(pageSize, () => {
currentPage.value = 1
})
watch(
() => filteredNodes.value.length,
() => {
setCurrentPageInRange()
},
)
watch(
() => [
paginatedNodes.value.map((item) => item.id).join(','),
selectedNodeIds.value.join(','),
],
() => {
syncTableSelection()
},
{ flush: 'post' },
)
</script>
<template>
@@ -235,7 +415,7 @@ watch(
<p class="nodes-kicker">Nodes</p>
<h1>节点管理</h1>
<span>
管理所有节点包括添加筛选显隐控制复制和删除等首批运营动作
现在可以在同一页完成节点筛选分页浏览单行置顶批量修改以及新增编辑显隐和删除等运营动作
</span>
</div>
@@ -285,9 +465,17 @@ watch(
:value="String(group.id)"
/>
</ElSelect>
<ElSelect v-model="relationFilter" class="toolbar-select" placeholder="节点关系">
<ElOption label="全部节点" value="all" />
<ElOption label="父节点" value="parent" />
<ElOption label="子节点" value="child" />
</ElSelect>
</div>
<div class="toolbar-actions">
<span class="scope-hint">{{ batchTargetLabel }}</span>
<ElButton :disabled="!hasSelectedNodes" @click="openBatchEditor">批量修改</ElButton>
<ElButton @click="openNodeGroupManagement">管理权限组</ElButton>
<ElButton @click="handleReset" :disabled="!hasActiveFilters">
<ElIcon><RefreshRight /></ElIcon>
@@ -297,6 +485,11 @@ watch(
</div>
</header>
<div v-if="hasSelectedNodes" class="selection-summary">
<span class="selection-summary__label">已勾选 {{ selectedNodes.length }} 个节点批量修改只会作用于这些节点</span>
<ElButton text @click="clearSelection">清空勾选</ElButton>
</div>
<ElAlert
v-if="errorMessage"
type="error"
@@ -311,11 +504,14 @@ watch(
</ElAlert>
<ElTable
:data="filteredNodes"
ref="tableRef"
:data="paginatedNodes"
v-loading="loading"
row-key="id"
class="nodes-table"
@selection-change="handleSelectionChange"
>
<ElTableColumn type="selection" width="52" reserve-selection />
<ElTableColumn label="节点ID" width="132">
<template #default="{ row }">
<ElTag
@@ -420,8 +616,9 @@ watch(
<ElIcon><MoreFilled /></ElIcon>
</ElButton>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownMenu>
<ElDropdownItem command="edit">编辑节点</ElDropdownItem>
<ElDropdownItem command="pin-top">置顶节点</ElDropdownItem>
<ElDropdownItem command="copy">复制节点</ElDropdownItem>
<ElDropdownItem command="delete" divided>删除节点</ElDropdownItem>
</ElDropdownMenu>
@@ -446,10 +643,19 @@ watch(
</ElTable>
<footer class="board-footer">
<span>已显示 {{ filteredNodes.length }} / {{ nodes.length }} 个节点</span>
<span> {{ currentPage }} · 已显示 {{ paginatedNodes.length }} / {{ filteredNodes.length }} 个节点</span>
<ElPagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
:total="filteredNodes.length"
background
class="footer-pagination"
/>
<div class="footer-hint">
<ElIcon><Connection /></ElIcon>
<span>节点新增编辑与排序已在当前工作台内接入真实流程</span>
<span>节点新增编辑置顶排序与批量修改已收敛到同一工作台</span>
</div>
</footer>
</section>
@@ -469,6 +675,14 @@ watch(
:nodes="nodes"
@success="() => loadNodeBoard()"
/>
<NodeBatchEditDialog
v-model:visible="batchEditVisible"
:groups="groups"
:selected-count="selectedNodes.length"
:loading="batchSubmitting"
@submit="handleBatchSubmit"
/>
</div>
</template>
@@ -550,7 +764,8 @@ watch(
.toolbar-fields,
.toolbar-actions,
.board-footer,
.footer-hint {
.footer-hint,
.selection-summary {
display: flex;
align-items: center;
gap: 12px;
@@ -578,6 +793,21 @@ watch(
border-radius: 16px;
}
.scope-hint,
.selection-summary__label {
color: var(--xboard-text-muted);
line-height: 1.5;
}
.selection-summary {
justify-content: space-between;
flex-wrap: wrap;
padding: 14px 16px;
border-radius: 18px;
background: #fbfbfd;
border: 1px solid rgba(0, 0, 0, 0.05);
}
.nodes-table :deep(th.el-table__cell) {
color: var(--xboard-text-secondary);
background: #fbfbfd;
@@ -667,6 +897,10 @@ watch(
flex-wrap: wrap;
}
.footer-pagination {
margin-left: auto;
}
.footer-hint {
justify-content: flex-end;
color: var(--xboard-text-muted);
@@ -675,7 +909,8 @@ watch(
@media (max-width: 1180px) {
.nodes-hero,
.board-toolbar,
.board-footer {
.board-footer,
.selection-summary {
flex-direction: column;
align-items: stretch;
}
@@ -687,6 +922,7 @@ watch(
.toolbar-actions {
justify-content: flex-end;
flex-wrap: wrap;
}
}