feat(admin-frontend): 补齐活跃筛选与支付快照能力
新增用户管理“活跃状态”高级筛选,并在后端支持 activity_status 复合规则,支持按活跃与非活跃筛选用户。 补齐订单支付成功快照落库与后台展示,保存支付渠道、 支付方法、实付金额和支付 IP,并在订单详情中优先展示。 同时增强节点页在线/离线筛选与批量删除、仪表盘快捷入口, 并修复已关闭工单再次回复后自动重开的统一语义。 附带同步测试、迁移、CI 工作流命名及知识库记录
This commit is contained in:
@@ -514,6 +514,10 @@ export function batchUpdateNodes(payload: AdminNodeBatchUpdatePayload): Promise<
|
||||
return unwrapPost<boolean>('/server/manage/batchUpdate', payload as unknown as Record<string, unknown>)
|
||||
}
|
||||
|
||||
export function batchDeleteNodes(ids: number[]): Promise<ApiResponse<boolean>> {
|
||||
return unwrapPost<boolean>('/server/manage/batchDelete', { ids })
|
||||
}
|
||||
|
||||
export function saveNode(payload: AdminNodeSavePayload): Promise<ApiResponse<boolean>> {
|
||||
return unwrapPost<boolean>('/server/manage/save', payload as unknown as Record<string, unknown>)
|
||||
}
|
||||
|
||||
Vendored
+12
@@ -579,6 +579,13 @@ export interface AdminOrderUserRef {
|
||||
plan_id?: number | null
|
||||
}
|
||||
|
||||
export interface AdminOrderPaymentRef {
|
||||
id: number
|
||||
name: string
|
||||
payment: string
|
||||
icon?: string | null
|
||||
}
|
||||
|
||||
export interface AdminCommissionLogItem {
|
||||
id: number
|
||||
invite_user_id: number
|
||||
@@ -597,6 +604,10 @@ export interface AdminOrderListItem {
|
||||
plan_id: number | null
|
||||
coupon_id?: number | null
|
||||
payment_id?: number | null
|
||||
payment_channel?: string | null
|
||||
payment_method?: string | null
|
||||
payment_amount?: number | null
|
||||
payment_ip?: string | null
|
||||
type: number
|
||||
period: string
|
||||
trade_no: string
|
||||
@@ -621,6 +632,7 @@ export interface AdminOrderListItem {
|
||||
export interface AdminOrderDetail extends AdminOrderListItem {
|
||||
user?: AdminOrderUserRef | null
|
||||
invite_user?: AdminOrderUserRef | null
|
||||
payment?: AdminOrderPaymentRef | null
|
||||
commission_log?: AdminCommissionLogItem[]
|
||||
surplus_orders?: AdminOrderListItem[]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AdminNodeItem } from '@/types/api'
|
||||
|
||||
export type NodeRelationFilter = 'all' | 'parent' | 'child'
|
||||
export type NodeStatusFilter = 'all' | 'online' | 'offline'
|
||||
|
||||
export interface NodeStatusMeta {
|
||||
label: string
|
||||
@@ -119,11 +120,13 @@ export function filterNodes(
|
||||
keyword: string,
|
||||
typeFilter: string,
|
||||
groupFilter: string,
|
||||
statusFilter: NodeStatusFilter = 'all',
|
||||
relationFilter: NodeRelationFilter = 'all',
|
||||
): AdminNodeItem[] {
|
||||
const normalizedKeyword = normalizeText(keyword)
|
||||
const normalizedType = normalizeText(typeFilter)
|
||||
const normalizedGroup = normalizeText(groupFilter)
|
||||
const normalizedStatus = normalizeText(statusFilter)
|
||||
const normalizedRelation = normalizeText(relationFilter)
|
||||
|
||||
return nodes.filter((node) => {
|
||||
@@ -142,6 +145,15 @@ export function filterNodes(
|
||||
}
|
||||
}
|
||||
|
||||
const nodeStatus = getNodeStatusMeta(node).dotClass
|
||||
if (normalizedStatus === 'online' && nodeStatus !== 'online') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'offline' && nodeStatus !== 'offline') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (normalizedRelation === 'parent' && node.parent_id) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
AdminOrderDetail,
|
||||
AdminOrderFilter,
|
||||
AdminOrderListItem,
|
||||
AdminOrderPaymentRef,
|
||||
AdminPlanListItem,
|
||||
} from '@/types/api'
|
||||
import { formatPlanPrice } from './plans'
|
||||
@@ -113,6 +114,15 @@ function toAmount(value: unknown): number {
|
||||
return Number.isFinite(numeric) ? numeric : 0
|
||||
}
|
||||
|
||||
function toDisplayText(value: unknown): string | null {
|
||||
if (!['string', 'number'].includes(typeof value)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const text = String(value).trim()
|
||||
return text ? text : null
|
||||
}
|
||||
|
||||
function toTimestampMilliseconds(value: number | string | null | undefined): number | null {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return null
|
||||
@@ -256,6 +266,29 @@ export function getOrderPeriodLabel(period: string | null | undefined): string {
|
||||
return findPeriodMeta(period)?.label ?? (period || '-')
|
||||
}
|
||||
|
||||
type OrderPaymentDisplayTarget = {
|
||||
payment_channel?: string | null
|
||||
payment_method?: string | null
|
||||
payment?: AdminOrderPaymentRef | null
|
||||
}
|
||||
|
||||
export function getOrderPaymentChannel(order?: OrderPaymentDisplayTarget | null): string {
|
||||
return (
|
||||
toDisplayText(order?.payment_channel)
|
||||
?? toDisplayText(order?.payment?.name)
|
||||
?? toDisplayText(order?.payment?.payment)
|
||||
?? '-'
|
||||
)
|
||||
}
|
||||
|
||||
export function getOrderPaymentMethod(order?: OrderPaymentDisplayTarget | null): string {
|
||||
return (
|
||||
toDisplayText(order?.payment_method)
|
||||
?? toDisplayText(order?.payment?.payment)
|
||||
?? '-'
|
||||
)
|
||||
}
|
||||
|
||||
export function getOrderFilterLabel(type: OrderFilterValue<number>): string {
|
||||
return getOptionLabel(ORDER_TYPE_OPTIONS, type)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export type UserAdvancedFieldKey =
|
||||
| 'email'
|
||||
| 'id'
|
||||
| 'plan_id'
|
||||
| 'activity_status'
|
||||
| 'transfer_enable'
|
||||
| 'total_used'
|
||||
| 'online_count'
|
||||
@@ -52,7 +53,7 @@ export type UserAdvancedOperator =
|
||||
| 'null'
|
||||
| 'notnull'
|
||||
|
||||
export type UserAdvancedInputKind = 'text' | 'number' | 'plan' | 'status' | 'date'
|
||||
export type UserAdvancedInputKind = 'text' | 'number' | 'plan' | 'status' | 'activity' | 'date'
|
||||
|
||||
export interface UserAdvancedFilterItem {
|
||||
key: string
|
||||
@@ -77,6 +78,11 @@ export const USER_STATUS_VALUE_OPTIONS = [
|
||||
{ label: '封禁', value: 1 },
|
||||
] as const
|
||||
|
||||
export const USER_ACTIVITY_STATUS_OPTIONS = [
|
||||
{ label: '活跃', value: 1 },
|
||||
{ label: '非活跃', value: 0 },
|
||||
] as const
|
||||
|
||||
export const USER_ADVANCED_FIELD_DEFINITIONS: UserAdvancedFieldDefinition[] = [
|
||||
{
|
||||
field: 'email',
|
||||
@@ -113,6 +119,12 @@ export const USER_ADVANCED_FIELD_DEFINITIONS: UserAdvancedFieldDefinition[] = [
|
||||
{ value: 'notnull', label: '已订阅' },
|
||||
],
|
||||
},
|
||||
{
|
||||
field: 'activity_status',
|
||||
label: '活跃状态',
|
||||
input: 'activity',
|
||||
operators: [{ value: 'eq', label: '是' }],
|
||||
},
|
||||
{
|
||||
field: 'transfer_enable',
|
||||
label: '流量',
|
||||
@@ -303,7 +315,7 @@ function normalizeAdvancedFilterValue(item: UserAdvancedFilterItem): string | nu
|
||||
return timestamp ? `${item.operator}:${timestamp}` : null
|
||||
}
|
||||
|
||||
if (item.field === 'id' || item.field === 'plan_id' || item.field === 'online_count' || item.field === 'banned') {
|
||||
if (item.field === 'id' || item.field === 'plan_id' || item.field === 'activity_status' || item.field === 'online_count' || item.field === 'banned') {
|
||||
const numeric = Number(item.value)
|
||||
return Number.isFinite(numeric) ? `${item.operator}:${numeric}` : null
|
||||
}
|
||||
@@ -331,6 +343,10 @@ function formatAdvancedFilterValue(item: UserAdvancedFilterItem, plans: AdminPla
|
||||
return Number(item.value) === 1 ? '封禁' : '正常'
|
||||
}
|
||||
|
||||
if (item.field === 'activity_status') {
|
||||
return Number(item.value) === 1 ? '活跃' : '非活跃'
|
||||
}
|
||||
|
||||
if (item.field === 'transfer_enable' || item.field === 'total_used') {
|
||||
return `${Number(item.value)} GB`
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { LocationQueryRaw } from 'vue-router'
|
||||
import {
|
||||
Coin,
|
||||
DataAnalysis,
|
||||
@@ -9,6 +11,7 @@ import {
|
||||
Download,
|
||||
RefreshRight,
|
||||
Tickets,
|
||||
TopRight,
|
||||
Upload,
|
||||
User,
|
||||
UserFilled,
|
||||
@@ -53,9 +56,15 @@ interface MetricCard {
|
||||
change?: string
|
||||
tone: 'dark' | 'light' | 'soft'
|
||||
icon: Component
|
||||
action?: {
|
||||
routeName: 'Tickets' | 'SubscriptionOrders' | 'Users'
|
||||
query?: LocationQueryRaw
|
||||
helperText: string
|
||||
}
|
||||
}
|
||||
|
||||
const app = useAppStore()
|
||||
const router = useRouter()
|
||||
const booting = ref(true)
|
||||
const trendLoading = ref(false)
|
||||
const rankLoading = ref(false)
|
||||
@@ -151,6 +160,14 @@ const metricCards = computed<MetricCard[]>(() => [
|
||||
detail: '待客服跟进',
|
||||
tone: 'soft',
|
||||
icon: Tickets,
|
||||
action: {
|
||||
routeName: 'Tickets',
|
||||
query: {
|
||||
source: 'dashboard',
|
||||
focus: 'opening',
|
||||
},
|
||||
helperText: '进入工单台',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'commissionPendingTotal',
|
||||
@@ -160,6 +177,14 @@ const metricCards = computed<MetricCard[]>(() => [
|
||||
change: formatPercent(dashboardStats.value.commissionGrowth),
|
||||
tone: 'soft',
|
||||
icon: Discount,
|
||||
action: {
|
||||
routeName: 'SubscriptionOrders',
|
||||
query: {
|
||||
source: 'dashboard',
|
||||
workbench: 'pending',
|
||||
},
|
||||
helperText: '确认佣金',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'newUsers',
|
||||
@@ -177,6 +202,10 @@ const metricCards = computed<MetricCard[]>(() => [
|
||||
detail: `在线 ${formatCompactNumber(dashboardStats.value.onlineUsers)} · 设备 ${formatCompactNumber(dashboardStats.value.onlineDevices)}`,
|
||||
tone: 'light',
|
||||
icon: UserFilled,
|
||||
action: {
|
||||
routeName: 'Users',
|
||||
helperText: '查看用户',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'monthUpload',
|
||||
@@ -475,6 +504,17 @@ function handleRefresh() {
|
||||
void refreshDashboard()
|
||||
}
|
||||
|
||||
function openMetricCard(card: MetricCard) {
|
||||
if (!card.action) {
|
||||
return
|
||||
}
|
||||
|
||||
void router.push({
|
||||
name: card.action.routeName,
|
||||
query: card.action.query,
|
||||
})
|
||||
}
|
||||
|
||||
function rankBarWidth(index: number): string {
|
||||
return `${Math.max(28, 100 - index * 12)}%`
|
||||
}
|
||||
@@ -548,11 +588,15 @@ onMounted(() => {
|
||||
</section>
|
||||
|
||||
<section class="metrics-grid">
|
||||
<article
|
||||
<component
|
||||
v-for="card in metricCards"
|
||||
:is="card.action ? 'button' : 'article'"
|
||||
:key="card.key"
|
||||
class="metric-card"
|
||||
:class="`tone-${card.tone}`"
|
||||
:class="[`tone-${card.tone}`, { 'metric-card--interactive': Boolean(card.action) }]"
|
||||
:type="card.action ? 'button' : undefined"
|
||||
:aria-label="card.action ? `${card.label},${card.action.helperText}` : undefined"
|
||||
@click="openMetricCard(card)"
|
||||
>
|
||||
<div class="metric-card__meta">
|
||||
<span>{{ card.label }}</span>
|
||||
@@ -560,14 +604,20 @@ onMounted(() => {
|
||||
</div>
|
||||
<strong class="metric-card__value">{{ card.value }}</strong>
|
||||
<p class="metric-card__detail">{{ card.detail }}</p>
|
||||
<span
|
||||
v-if="card.change"
|
||||
class="metric-card__change"
|
||||
:class="Number(card.change.replace('%', '')) >= 0 ? 'positive' : 'negative'"
|
||||
>
|
||||
{{ card.change }}
|
||||
</span>
|
||||
</article>
|
||||
<div class="metric-card__footer">
|
||||
<span
|
||||
v-if="card.change"
|
||||
class="metric-card__change"
|
||||
:class="Number(card.change.replace('%', '')) >= 0 ? 'positive' : 'negative'"
|
||||
>
|
||||
{{ card.change }}
|
||||
</span>
|
||||
<span v-if="card.action" class="metric-card__action">
|
||||
{{ card.action.helperText }}
|
||||
<ElIcon class="metric-card__action-icon"><TopRight /></ElIcon>
|
||||
</span>
|
||||
</div>
|
||||
</component>
|
||||
</section>
|
||||
|
||||
<section class="content-grid">
|
||||
@@ -1076,12 +1126,36 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
border: 1px solid transparent;
|
||||
min-height: 150px;
|
||||
padding: 22px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.metric-card--interactive {
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.metric-card--interactive:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(0, 113, 227, 0.14);
|
||||
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.metric-card--interactive:focus-visible {
|
||||
outline: 2px solid rgba(0, 113, 227, 0.34);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.metric-card--interactive:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.metric-card__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1126,11 +1200,37 @@ onMounted(() => {
|
||||
color: var(--xboard-text-muted);
|
||||
}
|
||||
|
||||
.metric-card__change {
|
||||
.metric-card__footer {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.metric-card__change {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.metric-card__action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
color: #0071e3;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.metric-card__action-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tone-dark .metric-card__action {
|
||||
color: #2997ff;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { TableInstance } from 'element-plus'
|
||||
import {
|
||||
Connection,
|
||||
Delete,
|
||||
MoreFilled,
|
||||
Plus,
|
||||
RefreshRight,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
User,
|
||||
} from '@element-plus/icons-vue'
|
||||
import {
|
||||
batchDeleteNodes,
|
||||
batchUpdateNodes,
|
||||
copyNode,
|
||||
deleteNode,
|
||||
@@ -42,6 +44,7 @@ import {
|
||||
getNodeStatusMeta,
|
||||
getNodeTypeLabel,
|
||||
type NodeRelationFilter,
|
||||
type NodeStatusFilter,
|
||||
} from '@/utils/nodes'
|
||||
import { sortNodesByOrder } from '@/utils/nodeEditor'
|
||||
|
||||
@@ -60,10 +63,12 @@ const routes = ref<AdminNodeRouteItem[]>([])
|
||||
const keyword = ref('')
|
||||
const typeFilter = ref('all')
|
||||
const groupFilter = ref('all')
|
||||
const statusFilter = ref<NodeStatusFilter>('all')
|
||||
const relationFilter = ref<NodeRelationFilter>('all')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const selectedNodeIds = ref<number[]>([])
|
||||
const syncingSelection = ref(false)
|
||||
const switchingIds = ref<number[]>([])
|
||||
const workingIds = ref<number[]>([])
|
||||
const editorVisible = ref(false)
|
||||
@@ -72,12 +77,14 @@ const activeNode = ref<AdminNodeItem | null>(null)
|
||||
const sortDialogVisible = ref(false)
|
||||
const batchEditVisible = ref(false)
|
||||
const batchSubmitting = ref(false)
|
||||
const batchDeleting = ref(false)
|
||||
|
||||
const filteredNodes = computed(() => sortNodesByOrder(filterNodes(
|
||||
nodes.value,
|
||||
keyword.value,
|
||||
typeFilter.value,
|
||||
groupFilter.value,
|
||||
statusFilter.value,
|
||||
relationFilter.value,
|
||||
)))
|
||||
|
||||
@@ -93,6 +100,7 @@ const hasActiveFilters = computed(() => (
|
||||
keyword.value !== ''
|
||||
|| typeFilter.value !== 'all'
|
||||
|| groupFilter.value !== 'all'
|
||||
|| statusFilter.value !== 'all'
|
||||
|| relationFilter.value !== 'all'
|
||||
))
|
||||
|
||||
@@ -106,7 +114,7 @@ const summaryCards = computed(() => [
|
||||
const batchTargetLabel = computed(() => (
|
||||
hasSelectedNodes.value
|
||||
? `当前已选 ${selectedNodes.value.length} 个节点`
|
||||
: '批量修改仅作用于已勾选节点'
|
||||
: '批量修改 / 删除仅作用于已勾选节点'
|
||||
))
|
||||
|
||||
function getRouteGroupQuery(): string {
|
||||
@@ -182,12 +190,20 @@ function syncTableSelection() {
|
||||
return
|
||||
}
|
||||
|
||||
table.clearSelection()
|
||||
paginatedNodes.value.forEach((node) => {
|
||||
if (selectedNodeIds.value.includes(node.id)) {
|
||||
table.toggleRowSelection(node, true)
|
||||
}
|
||||
})
|
||||
syncingSelection.value = true
|
||||
|
||||
try {
|
||||
table.clearSelection()
|
||||
paginatedNodes.value.forEach((node) => {
|
||||
if (selectedNodeIds.value.includes(node.id)) {
|
||||
table.toggleRowSelection(node, true)
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
nextTick(() => {
|
||||
syncingSelection.value = false
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -220,6 +236,7 @@ function handleReset() {
|
||||
keyword.value = ''
|
||||
typeFilter.value = 'all'
|
||||
groupFilter.value = 'all'
|
||||
statusFilter.value = 'all'
|
||||
relationFilter.value = 'all'
|
||||
currentPage.value = 1
|
||||
}
|
||||
@@ -229,6 +246,10 @@ function openNodeGroupManagement() {
|
||||
}
|
||||
|
||||
function handleSelectionChange(selection: AdminNodeItem[]) {
|
||||
if (syncingSelection.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentPageIds = paginatedNodes.value.map((item) => item.id)
|
||||
const selectionIds = selection.map((item) => item.id)
|
||||
const persistedIds = selectedNodeIds.value.filter((id) => !currentPageIds.includes(id))
|
||||
@@ -281,6 +302,41 @@ async function handleBatchSubmit(payload: NodeBatchEditPayload) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
if (!hasSelectedNodes.value) {
|
||||
ElMessage.warning('请先勾选需要批量删除的节点')
|
||||
return
|
||||
}
|
||||
|
||||
const deleteCount = selectedNodes.value.length
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认批量删除 ${deleteCount} 个节点吗?此操作不可恢复。`,
|
||||
'批量删除节点',
|
||||
{
|
||||
type: 'warning',
|
||||
confirmButtonText: '确认删除',
|
||||
cancelButtonText: '取消',
|
||||
},
|
||||
)
|
||||
|
||||
batchDeleting.value = true
|
||||
await batchDeleteNodes([...selectedNodeIds.value])
|
||||
clearSelection()
|
||||
ElMessage.success(`已批量删除 ${deleteCount} 个节点`)
|
||||
await loadNodeBoard()
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') {
|
||||
return
|
||||
}
|
||||
|
||||
ElMessage.error(error instanceof Error ? error.message : '批量删除失败')
|
||||
} finally {
|
||||
batchDeleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleShow(node: AdminNodeItem, nextValue: boolean) {
|
||||
const previous = Boolean(node.show)
|
||||
if (previous === nextValue) {
|
||||
@@ -381,7 +437,7 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
watch([keyword, typeFilter, groupFilter, relationFilter], () => {
|
||||
watch([keyword, typeFilter, groupFilter, statusFilter, relationFilter], () => {
|
||||
currentPage.value = 1
|
||||
})
|
||||
|
||||
@@ -397,10 +453,7 @@ watch(
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [
|
||||
paginatedNodes.value.map((item) => item.id).join(','),
|
||||
selectedNodeIds.value.join(','),
|
||||
],
|
||||
() => paginatedNodes.value.map((item) => item.id).join(','),
|
||||
() => {
|
||||
syncTableSelection()
|
||||
},
|
||||
@@ -415,7 +468,7 @@ watch(
|
||||
<p class="nodes-kicker">Nodes</p>
|
||||
<h1>节点管理</h1>
|
||||
<span>
|
||||
现在可以在同一页完成节点筛选、分页浏览、单行置顶、批量修改,以及新增、编辑、显隐和删除等运营动作。
|
||||
现在可以在同一页完成节点筛选、在线 / 离线排查、分页浏览、单行置顶、批量修改与批量删除,以及新增、编辑、显隐和删除等运营动作。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -466,6 +519,12 @@ watch(
|
||||
/>
|
||||
</ElSelect>
|
||||
|
||||
<ElSelect v-model="statusFilter" class="toolbar-select" placeholder="状态">
|
||||
<ElOption label="全部节点" value="all" />
|
||||
<ElOption label="在线节点" value="online" />
|
||||
<ElOption label="离线节点" value="offline" />
|
||||
</ElSelect>
|
||||
|
||||
<ElSelect v-model="relationFilter" class="toolbar-select" placeholder="节点关系">
|
||||
<ElOption label="全部节点" value="all" />
|
||||
<ElOption label="父节点" value="parent" />
|
||||
@@ -475,7 +534,17 @@ watch(
|
||||
|
||||
<div class="toolbar-actions">
|
||||
<span class="scope-hint">{{ batchTargetLabel }}</span>
|
||||
<ElButton :disabled="!hasSelectedNodes" @click="openBatchEditor">批量修改</ElButton>
|
||||
<ElButton :disabled="!hasSelectedNodes || batchDeleting" @click="openBatchEditor">批量修改</ElButton>
|
||||
<ElButton
|
||||
type="danger"
|
||||
plain
|
||||
:disabled="!hasSelectedNodes"
|
||||
:loading="batchDeleting"
|
||||
@click="handleBatchDelete"
|
||||
>
|
||||
<ElIcon><Delete /></ElIcon>
|
||||
批量删除
|
||||
</ElButton>
|
||||
<ElButton @click="openNodeGroupManagement">管理权限组</ElButton>
|
||||
<ElButton @click="handleReset" :disabled="!hasActiveFilters">
|
||||
<ElIcon><RefreshRight /></ElIcon>
|
||||
@@ -486,7 +555,7 @@ watch(
|
||||
</header>
|
||||
|
||||
<div v-if="hasSelectedNodes" class="selection-summary">
|
||||
<span class="selection-summary__label">已勾选 {{ selectedNodes.length }} 个节点,批量修改只会作用于这些节点。</span>
|
||||
<span class="selection-summary__label">已勾选 {{ selectedNodes.length }} 个节点,批量修改与批量删除只会作用于这些节点。</span>
|
||||
<ElButton text @click="clearSelection">清空勾选</ElButton>
|
||||
</div>
|
||||
|
||||
@@ -655,7 +724,7 @@ watch(
|
||||
/>
|
||||
<div class="footer-hint">
|
||||
<ElIcon><Connection /></ElIcon>
|
||||
<span>节点新增、编辑、置顶、排序与批量修改已收敛到同一工作台。</span>
|
||||
<span>节点新增、编辑、置顶、排序、批量修改与批量删除已收敛到同一工作台。</span>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
formatOrderDateTime,
|
||||
getCommissionStatusMeta,
|
||||
getOrderPeriodLabel,
|
||||
getOrderPaymentChannel,
|
||||
getOrderPaymentMethod,
|
||||
getOrderStatusMeta,
|
||||
getOrderTypeMeta,
|
||||
} from '@/utils/orders'
|
||||
@@ -41,6 +43,8 @@ const commissionMeta = computed(() => getCommissionStatusMeta(
|
||||
props.order?.actual_commission_balance,
|
||||
))
|
||||
const hasCommission = computed(() => hasOrderCommission(props.order))
|
||||
const paymentChannel = computed(() => getOrderPaymentChannel(props.order))
|
||||
const paymentMethod = computed(() => getOrderPaymentMethod(props.order))
|
||||
|
||||
const summaryCards = computed(() => [
|
||||
{ label: '订单状态', value: statusMeta.value.label, detail: typeMeta.value.label },
|
||||
@@ -56,10 +60,31 @@ const basicFields = computed(() => [
|
||||
{ label: '订阅计划', value: props.order?.plan?.name || '-' },
|
||||
{ label: '订单类型', value: typeMeta.value.label },
|
||||
{ label: '订阅周期', value: getOrderPeriodLabel(props.order?.period) },
|
||||
{ label: '回调编号', value: props.order?.callback_no || '-' },
|
||||
{ label: '支付时间', value: formatOrderDateTime(props.order?.paid_at) },
|
||||
])
|
||||
|
||||
const paymentSummaryDescription = computed(() => (
|
||||
props.order?.paid_at
|
||||
? '集中查看支付成功后的渠道、方法与平台流水快照。'
|
||||
: '订单完成支付后,这里会展示支付渠道、支付方法与平台流水信息。'
|
||||
))
|
||||
|
||||
const paymentFields = computed(() => [
|
||||
{ label: '支付渠道', value: paymentChannel.value },
|
||||
{ label: '支付方法', value: paymentMethod.value },
|
||||
{ label: '平台订单号', value: props.order?.callback_no || '-' },
|
||||
{ label: '商户订单号', value: props.order?.trade_no || '-' },
|
||||
{ label: '订单金额', value: formatOrderAmount(props.order?.total_amount) },
|
||||
{ label: '实际支付金额', value: formatOrderAmount(props.order?.payment_amount) },
|
||||
{ label: '创建时间', value: formatOrderDateTime(props.order?.created_at) },
|
||||
{ label: '完成时间', value: formatOrderDateTime(props.order?.paid_at) },
|
||||
{ label: '支付 IP', value: props.order?.payment_ip || '-' },
|
||||
{ label: '订单状态', value: statusMeta.value.label },
|
||||
])
|
||||
const paymentBadges = computed(() => ([
|
||||
{ label: paymentChannel.value, tone: 'neutral' },
|
||||
{ label: paymentMethod.value, tone: 'info' },
|
||||
].filter((item) => item.label !== '-')))
|
||||
|
||||
const amountFields = computed(() => [
|
||||
{ label: '订单金额', value: formatOrderAmount(props.order?.total_amount) },
|
||||
{ label: '手续费', value: formatOrderAmount(props.order?.handling_amount) },
|
||||
@@ -151,6 +176,33 @@ watch(
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="detail-card">
|
||||
<header class="card-header">
|
||||
<div>
|
||||
<h3>支付成功信息</h3>
|
||||
<p>{{ paymentSummaryDescription }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="paymentBadges.length" class="payment-badges">
|
||||
<span
|
||||
v-for="item in paymentBadges"
|
||||
:key="`${item.tone}-${item.label}`"
|
||||
class="hero-badge"
|
||||
:class="`is-${item.tone}`"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="description-grid">
|
||||
<article v-for="item in paymentFields" :key="item.label">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="detail-card">
|
||||
<header class="card-header">
|
||||
<div>
|
||||
@@ -326,6 +378,13 @@ watch(
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.payment-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.hero-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -498,6 +557,7 @@ watch(
|
||||
}
|
||||
|
||||
.card-header,
|
||||
.payment-badges,
|
||||
.commission-actions,
|
||||
.list-row,
|
||||
.drawer-actions {
|
||||
|
||||
@@ -100,6 +100,34 @@ const scopedUserFilters = computed(() => (
|
||||
: []
|
||||
))
|
||||
|
||||
const dashboardWorkbench = computed<CommissionWorkbenchMode | null>(() => {
|
||||
const raw = route.query.workbench
|
||||
const value = Array.isArray(raw) ? raw[0] : raw
|
||||
if (value === 'pending' || value === 'commission') {
|
||||
return value
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const dashboardEntryNotice = computed(() => {
|
||||
const raw = route.query.source
|
||||
const source = Array.isArray(raw) ? raw[0] : raw
|
||||
if (source !== 'dashboard') {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (dashboardWorkbench.value === 'pending') {
|
||||
return '已从仪表盘进入,当前默认展示真实待确认佣金订单。'
|
||||
}
|
||||
|
||||
if (dashboardWorkbench.value === 'commission') {
|
||||
return '已从仪表盘进入,当前默认展示全部佣金订单。'
|
||||
}
|
||||
|
||||
return '已从仪表盘进入订单工作台。'
|
||||
})
|
||||
|
||||
const scopedUserNotice = computed(() => (
|
||||
scopedUserId.value
|
||||
? `当前仅展示 ${scopedUserEmail.value || `用户 #${scopedUserId.value}`} 的订单。`
|
||||
@@ -118,6 +146,10 @@ const commissionWorkbenchLabel = computed(() => {
|
||||
})
|
||||
|
||||
const commissionWorkbenchNotice = computed(() => {
|
||||
if (dashboardEntryNotice.value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (commissionWorkbench.value === 'pending') {
|
||||
return '当前仅展示真实待确认佣金订单,可在操作列直接确认。'
|
||||
}
|
||||
@@ -220,6 +252,35 @@ function handleCommissionWorkbench(command: string) {
|
||||
refreshOrders(true)
|
||||
}
|
||||
|
||||
function syncDashboardWorkbench() {
|
||||
if (dashboardWorkbench.value === 'pending') {
|
||||
commissionWorkbench.value = 'pending'
|
||||
commissionFilter.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
if (dashboardWorkbench.value === 'commission') {
|
||||
commissionWorkbench.value = 'commission'
|
||||
commissionFilter.value = 'all'
|
||||
return
|
||||
}
|
||||
|
||||
if (route.query.workbench !== undefined) {
|
||||
commissionWorkbench.value = 'all'
|
||||
commissionFilter.value = 'all'
|
||||
}
|
||||
}
|
||||
|
||||
function clearDashboardEntry() {
|
||||
const nextQuery = { ...route.query }
|
||||
delete nextQuery.source
|
||||
delete nextQuery.workbench
|
||||
void router.replace({
|
||||
name: 'SubscriptionOrders',
|
||||
query: nextQuery,
|
||||
})
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
keyword.value = ''
|
||||
typeFilter.value = 'all'
|
||||
@@ -397,13 +458,15 @@ watch([current, pageSize], () => {
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [route.query.user_id, route.query.user_email],
|
||||
() => [route.query.user_id, route.query.user_email, route.query.workbench],
|
||||
() => {
|
||||
syncDashboardWorkbench()
|
||||
refreshOrders(true)
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
syncDashboardWorkbench()
|
||||
void Promise.all([loadPlans(), loadOrders()]).catch(() => {
|
||||
ElMessage.error('订单管理页面初始化失败')
|
||||
})
|
||||
@@ -555,6 +618,19 @@ onMounted(() => {
|
||||
</template>
|
||||
</ElAlert>
|
||||
|
||||
<ElAlert
|
||||
v-if="!errorMessage && dashboardEntryNotice"
|
||||
class="orders-alert orders-alert--info"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
:title="dashboardEntryNotice"
|
||||
>
|
||||
<template #default>
|
||||
<ElButton size="small" @click="clearDashboardEntry">关闭提示</ElButton>
|
||||
</template>
|
||||
</ElAlert>
|
||||
|
||||
<ElAlert
|
||||
v-if="!errorMessage && scopedUserNotice"
|
||||
class="orders-alert orders-alert--info"
|
||||
|
||||
@@ -40,6 +40,7 @@ type UploadError = Parameters<UploadRequestOptions['onError']>[0]
|
||||
|
||||
const statusMeta = computed(() => detail.value ? getTicketStatusMeta(detail.value) : null)
|
||||
const levelMeta = computed(() => detail.value ? getTicketLevelMeta(detail.value.level) : null)
|
||||
const willReopenClosedTicket = computed(() => detail.value?.status === 1)
|
||||
|
||||
async function loadSidebarTickets() {
|
||||
if (!props.visible) {
|
||||
@@ -312,6 +313,9 @@ watch(
|
||||
</div>
|
||||
|
||||
<footer class="reply-box">
|
||||
<p v-if="willReopenClosedTicket" class="reply-box__hint">
|
||||
当前工单已关闭,发送新回复后会自动重新开启。
|
||||
</p>
|
||||
<ElInput
|
||||
v-model="replyMessage"
|
||||
type="textarea"
|
||||
@@ -335,10 +339,9 @@ watch(
|
||||
type="primary"
|
||||
:icon="ChatLineRound"
|
||||
:loading="replying"
|
||||
:disabled="detail.status === 1"
|
||||
@click="handleReply"
|
||||
>
|
||||
发送
|
||||
{{ willReopenClosedTicket ? '发送并重开' : '发送' }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -561,6 +564,12 @@ watch(
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.reply-box__hint {
|
||||
color: var(--xboard-text-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.reply-box__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { DataAnalysis, Search, View } from '@element-plus/icons-vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { closeTicket, fetchTickets } from '@/api/admin'
|
||||
import type { AdminTicketListItem } from '@/types/api'
|
||||
import { formatDateTime } from '@/utils/dashboard'
|
||||
@@ -13,6 +14,8 @@ import {
|
||||
import TicketWorkspaceDialog from './TicketWorkspaceDialog.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const tickets = ref<AdminTicketListItem[]>([])
|
||||
const total = ref(0)
|
||||
const current = ref(1)
|
||||
@@ -32,6 +35,38 @@ const headerStats = computed(() => [
|
||||
{ label: '当前页', value: String(current.value) },
|
||||
])
|
||||
|
||||
const dashboardFocus = computed<'opening' | 'closed' | 'all' | null>(() => {
|
||||
const raw = route.query.focus
|
||||
const value = Array.isArray(raw) ? raw[0] : raw
|
||||
if (value === 'closed') {
|
||||
return 'closed'
|
||||
}
|
||||
|
||||
if (value === 'all') {
|
||||
return 'all'
|
||||
}
|
||||
|
||||
if (value === 'opening' || value === 'pending') {
|
||||
return 'opening'
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const dashboardEntryNotice = computed(() => {
|
||||
const raw = route.query.source
|
||||
const source = Array.isArray(raw) ? raw[0] : raw
|
||||
if (source !== 'dashboard') {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (dashboardFocus.value === 'opening') {
|
||||
return '已从仪表盘进入,这里默认展示待处理工单。'
|
||||
}
|
||||
|
||||
return '已从仪表盘进入工单工作台。'
|
||||
})
|
||||
|
||||
function statusValueToParam() {
|
||||
if (statusFilter.value === 'opening') {
|
||||
return 0
|
||||
@@ -92,6 +127,24 @@ function handleSearch() {
|
||||
void loadTickets()
|
||||
}
|
||||
|
||||
function applyDashboardFocus() {
|
||||
if (!dashboardFocus.value) {
|
||||
return
|
||||
}
|
||||
|
||||
statusFilter.value = dashboardFocus.value
|
||||
}
|
||||
|
||||
function clearDashboardEntry() {
|
||||
const nextQuery = { ...route.query }
|
||||
delete nextQuery.source
|
||||
delete nextQuery.focus
|
||||
void router.replace({
|
||||
name: 'Tickets',
|
||||
query: nextQuery,
|
||||
})
|
||||
}
|
||||
|
||||
watch([current, pageSize], () => {
|
||||
void loadTickets()
|
||||
})
|
||||
@@ -101,7 +154,15 @@ watch([statusFilter, levelFilter], () => {
|
||||
void loadTickets()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.query.focus,
|
||||
() => {
|
||||
applyDashboardFocus()
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
applyDashboardFocus()
|
||||
void loadTickets()
|
||||
})
|
||||
</script>
|
||||
@@ -156,6 +217,19 @@ onMounted(() => {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ElAlert
|
||||
v-if="dashboardEntryNotice"
|
||||
class="tickets-alert"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
:title="dashboardEntryNotice"
|
||||
>
|
||||
<template #default>
|
||||
<ElButton size="small" @click="clearDashboardEntry">关闭提示</ElButton>
|
||||
</template>
|
||||
</ElAlert>
|
||||
|
||||
<ElTable :data="tickets" v-loading="loading" class="ticket-table" row-key="id">
|
||||
<ElTableColumn label="工单号" width="92">
|
||||
<template #default="{ row }">
|
||||
@@ -340,6 +414,10 @@ onMounted(() => {
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.tickets-alert {
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.subject-cell {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, ref, watch } from 'vue'
|
||||
import { Delete, Plus } from '@element-plus/icons-vue'
|
||||
import type { AdminPlanOption } from '@/types/api'
|
||||
import {
|
||||
USER_ACTIVITY_STATUS_OPTIONS,
|
||||
cloneUserAdvancedFilters,
|
||||
createEmptyUserAdvancedFilter,
|
||||
getUserAdvancedFieldDefinition,
|
||||
@@ -218,6 +219,19 @@ watch(() => props.visible, (visible) => {
|
||||
/>
|
||||
</ElSelect>
|
||||
|
||||
<ElSelect
|
||||
v-else-if="getDefinition(filter.field).input === 'activity'"
|
||||
v-model="filter.value"
|
||||
placeholder="选择活跃状态"
|
||||
>
|
||||
<ElOption
|
||||
v-for="option in USER_ACTIVITY_STATUS_OPTIONS"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
|
||||
<ElDatePicker
|
||||
v-else
|
||||
v-model="filter.value"
|
||||
|
||||
Reference in New Issue
Block a user