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
+31
View File
@@ -24,6 +24,7 @@ import type {
AdminNoticeItem,
AdminNoticeSavePayload,
AdminNodeItem,
AdminNodeBatchUpdatePayload,
AdminNodeSavePayload,
AdminNodeRouteItem,
AdminNodeRouteSavePayload,
@@ -45,6 +46,9 @@ import type {
AdminTicketFetchParams,
AdminTicketListItem,
AdminTrafficLogResult,
AdminUserBulkBanPayload,
AdminUserBulkMailPayload,
AdminUserBulkScopePayload,
AdminUserFetchParams,
AdminUserGeneratePayload,
AdminUserListItem,
@@ -506,6 +510,10 @@ export function updateNode(payload: AdminNodeUpdatePayload): Promise<ApiResponse
return unwrapPost<boolean>('/server/manage/update', payload as unknown as Record<string, unknown>)
}
export function batchUpdateNodes(payload: AdminNodeBatchUpdatePayload): Promise<ApiResponse<boolean>> {
return unwrapPost<boolean>('/server/manage/batchUpdate', 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>)
}
@@ -554,6 +562,29 @@ export function deleteUser(id: number): Promise<ApiResponse<boolean>> {
return unwrapPost<boolean>('/user/destroy', { id })
}
export function exportUsersCsv(payload: AdminUserBulkScopePayload): Promise<Blob> {
return adminClient
.post<Blob>('/user/dumpCSV', payload as unknown as Record<string, unknown>, {
responseType: 'blob',
})
.then((res) => res.data)
}
export function sendUsersMail(payload: AdminUserBulkMailPayload): Promise<ApiResponse<boolean>> {
return unwrapPost<boolean>('/user/sendMail', payload as unknown as Record<string, unknown>)
}
export function batchUpdateUserBan(payload: AdminUserBulkBanPayload): Promise<ApiResponse<boolean>> {
return unwrapPost<boolean>('/user/ban', payload as unknown as Record<string, unknown>)
}
export function resetUserTraffic(userId: number, reason?: string): Promise<ApiResponse<Record<string, unknown>>> {
return unwrapPost<Record<string, unknown>>('/traffic-reset/reset-user', {
user_id: userId,
reason,
})
}
export function fetchTickets(params: AdminTicketFetchParams): Promise<AdminPaginationResult<AdminTicketListItem>> {
return adminClient
.get<AdminPaginationResult<AdminTicketListItem>>('/ticket/fetch', { params })
+28
View File
@@ -664,6 +664,8 @@ export interface AdminUserListItem {
discount: number | null
speed_limit: number | null
device_limit: number | null
online_count?: number | null
last_online_at?: number | null
remarks: string | null
banned: boolean
is_admin: boolean
@@ -691,6 +693,25 @@ export interface AdminUserFetchParams {
sort?: AdminUserSort[]
}
export interface AdminUserBulkScopePayload {
scope?: 'selected' | 'filtered' | 'all'
user_ids?: number[]
filter?: AdminUserFilter[]
}
export interface AdminUserBulkMailPayload extends AdminUserBulkScopePayload {
subject: string
content: string
sort?: string
sort_type?: 'ASC' | 'DESC'
}
export interface AdminUserBulkBanPayload extends AdminUserBulkScopePayload {
banned?: boolean | number
sort?: string
sort_type?: 'ASC' | 'DESC'
}
export interface AdminUserGeneratePayload {
email: string
password: string
@@ -872,6 +893,13 @@ export interface AdminNodeUpdatePayload {
machine_id?: number | null
}
export interface AdminNodeBatchUpdatePayload {
ids: number[]
host?: string
rate?: number
group_ids?: number[]
}
export interface AdminNodeSavePayload {
id?: number
type: AdminNodeType
+1
View File
@@ -45,6 +45,7 @@ declare module 'vue' {
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTag: typeof import('element-plus/es')['ElTag']
ElTimePicker: typeof import('element-plus/es')['ElTimePicker']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElUpload: typeof import('element-plus/es')['ElUpload']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
+12
View File
@@ -1,5 +1,7 @@
import type { AdminNodeItem } from '@/types/api'
export type NodeRelationFilter = 'all' | 'parent' | 'child'
export interface NodeStatusMeta {
label: string
dotClass: 'online' | 'pending' | 'offline' | 'disabled'
@@ -117,10 +119,12 @@ export function filterNodes(
keyword: string,
typeFilter: string,
groupFilter: string,
relationFilter: NodeRelationFilter = 'all',
): AdminNodeItem[] {
const normalizedKeyword = normalizeText(keyword)
const normalizedType = normalizeText(typeFilter)
const normalizedGroup = normalizeText(groupFilter)
const normalizedRelation = normalizeText(relationFilter)
return nodes.filter((node) => {
if (normalizedKeyword && !buildNodeSearchText(node).includes(normalizedKeyword)) {
@@ -138,6 +142,14 @@ export function filterNodes(
}
}
if (normalizedRelation === 'parent' && node.parent_id) {
return false
}
if (normalizedRelation === 'child' && !node.parent_id) {
return false
}
return true
})
}
+44 -6
View File
@@ -207,9 +207,35 @@ export function getOrderStatusMeta(status: number | null | undefined): OrderStat
}
}
export function getCommissionStatusMeta(status: number | null | undefined, amount?: number | null): OrderStatusMeta {
if ((amount ?? 0) <= 0 && (status === null || status === undefined)) {
return { label: '-', tone: 'neutral' }
interface CommissionStateTarget {
commission_status?: number | null
commission_balance?: number | null
actual_commission_balance?: number | null
}
export function hasOrderCommission(order?: CommissionStateTarget | null): boolean {
if (!order) {
return false
}
if (toAmount(order.commission_balance) > 0 || toAmount(order.actual_commission_balance) > 0) {
return true
}
return [1, 2, 3].includes(Number(order.commission_status ?? -1))
}
export function getCommissionStatusMeta(
status: number | null | undefined,
amount?: number | null,
actualAmount?: number | null,
): OrderStatusMeta {
if (!hasOrderCommission({
commission_status: status,
commission_balance: amount,
actual_commission_balance: actualAmount,
})) {
return { label: '无佣金', tone: 'neutral' }
}
switch (status) {
@@ -222,7 +248,7 @@ export function getCommissionStatusMeta(status: number | null | undefined, amoun
case 3:
return { label: '无效', tone: 'danger' }
default:
return { label: '未参与', tone: 'neutral' }
return { label: '待确认', tone: 'warning' }
}
}
@@ -315,14 +341,26 @@ export function canCancelOrder(order?: Pick<AdminOrderListItem, 'status'> | null
return order?.status === 0
}
export function canUpdateCommissionStatus(order?: Pick<AdminOrderDetail, 'commission_balance' | 'commission_status'> | null): boolean {
export function canUpdateCommissionStatus(
order?: Pick<AdminOrderDetail, 'commission_balance' | 'actual_commission_balance' | 'commission_status'> | null,
): boolean {
if (!order) {
return false
}
if ((order.commission_balance ?? 0) <= 0) {
if (!hasOrderCommission(order)) {
return false
}
return order.commission_status !== 2
}
export function canQuickConfirmCommission(
order?: Pick<AdminOrderListItem, 'status' | 'commission_balance' | 'actual_commission_balance' | 'commission_status'> | null,
): boolean {
if (!order) {
return false
}
return order.status === 3 && order.commission_status === 0 && hasOrderCommission(order)
}
+345 -7
View File
@@ -1,4 +1,4 @@
import type { AdminUserListItem, AdminUserUpdatePayload } from '@/types/api'
import type { AdminPlanOption, AdminUserFilter, AdminUserListItem, AdminUserUpdatePayload } from '@/types/api'
export interface UserStatusMeta {
label: string
@@ -28,6 +28,187 @@ export interface UserFormModel {
remarks: string
}
export type UserAdvancedFieldKey =
| 'email'
| 'id'
| 'plan_id'
| 'transfer_enable'
| 'total_used'
| 'online_count'
| 'expired_at'
| 'uuid'
| 'token'
| 'banned'
| 'remarks'
export type UserAdvancedOperator =
| 'like'
| 'notlike'
| 'eq'
| 'gt'
| 'gte'
| 'lt'
| 'lte'
| 'null'
| 'notnull'
export type UserAdvancedInputKind = 'text' | 'number' | 'plan' | 'status' | 'date'
export interface UserAdvancedFilterItem {
key: string
field: UserAdvancedFieldKey
operator: UserAdvancedOperator
value: string | number | null
logic: 'and' | 'or'
}
export interface UserAdvancedFieldDefinition {
field: UserAdvancedFieldKey
label: string
input: UserAdvancedInputKind
placeholder?: string
unit?: string
step?: number
operators: Array<{ value: UserAdvancedOperator; label: string }>
}
export const USER_STATUS_VALUE_OPTIONS = [
{ label: '正常', value: 0 },
{ label: '封禁', value: 1 },
] as const
export const USER_ADVANCED_FIELD_DEFINITIONS: UserAdvancedFieldDefinition[] = [
{
field: 'email',
label: '邮箱',
input: 'text',
placeholder: '输入邮箱关键字',
operators: [
{ value: 'like', label: '包含' },
{ value: 'notlike', label: '不包含' },
{ value: 'eq', label: '等于' },
],
},
{
field: 'id',
label: '用户ID',
input: 'number',
placeholder: '输入用户 ID',
step: 1,
operators: [
{ value: 'eq', label: '等于' },
{ value: 'gt', label: '大于' },
{ value: 'gte', label: '大于等于' },
{ value: 'lt', label: '小于' },
{ value: 'lte', label: '小于等于' },
],
},
{
field: 'plan_id',
label: '订阅',
input: 'plan',
operators: [
{ value: 'eq', label: '是' },
{ value: 'null', label: '未订阅' },
{ value: 'notnull', label: '已订阅' },
],
},
{
field: 'transfer_enable',
label: '流量',
input: 'number',
placeholder: '输入流量值',
unit: 'GB',
step: 1,
operators: [
{ value: 'eq', label: '等于' },
{ value: 'gt', label: '大于' },
{ value: 'gte', label: '大于等于' },
{ value: 'lt', label: '小于' },
{ value: 'lte', label: '小于等于' },
],
},
{
field: 'total_used',
label: '已用流量',
input: 'number',
placeholder: '输入已用流量',
unit: 'GB',
step: 1,
operators: [
{ value: 'eq', label: '等于' },
{ value: 'gt', label: '大于' },
{ value: 'gte', label: '大于等于' },
{ value: 'lt', label: '小于' },
{ value: 'lte', label: '小于等于' },
],
},
{
field: 'online_count',
label: '在线设备',
input: 'number',
placeholder: '输入在线设备数',
step: 1,
operators: [
{ value: 'eq', label: '等于' },
{ value: 'gt', label: '大于' },
{ value: 'gte', label: '大于等于' },
{ value: 'lt', label: '小于' },
{ value: 'lte', label: '小于等于' },
],
},
{
field: 'expired_at',
label: '到期时间',
input: 'date',
operators: [
{ value: 'gte', label: '晚于' },
{ value: 'lte', label: '早于' },
{ value: 'null', label: '长期有效' },
{ value: 'notnull', label: '已设置到期时间' },
],
},
{
field: 'uuid',
label: 'UUID',
input: 'text',
placeholder: '输入 UUID',
operators: [
{ value: 'like', label: '包含' },
{ value: 'notlike', label: '不包含' },
{ value: 'eq', label: '等于' },
],
},
{
field: 'token',
label: 'Token',
input: 'text',
placeholder: '输入 Token',
operators: [
{ value: 'like', label: '包含' },
{ value: 'notlike', label: '不包含' },
{ value: 'eq', label: '等于' },
],
},
{
field: 'banned',
label: '账号状态',
input: 'status',
operators: [{ value: 'eq', label: '是' }],
},
{
field: 'remarks',
label: '备注',
input: 'text',
placeholder: '输入备注关键字',
operators: [
{ value: 'like', label: '包含' },
{ value: 'notlike', label: '不包含' },
{ value: 'eq', label: '等于' },
],
},
] as const
export const COMMISSION_TYPE_OPTIONS = [
{ label: '跟随系统', value: 0 },
{ label: '周期返佣', value: 1 },
@@ -36,6 +217,10 @@ export const COMMISSION_TYPE_OPTIONS = [
const GIGABYTE = 1024 ** 3
function createFilterKey(): string {
return `user-filter-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
}
function toNumber(value: unknown): number {
const numeric = Number(value)
return Number.isFinite(numeric) ? numeric : 0
@@ -68,6 +253,96 @@ export function normalizeTimestampSeconds(value: number | string | null | undefi
return Number.isFinite(numeric) && numeric > 0 ? Math.floor(numeric) : null
}
export function getUserAdvancedFieldDefinition(field: UserAdvancedFieldKey): UserAdvancedFieldDefinition {
return USER_ADVANCED_FIELD_DEFINITIONS.find((item) => item.field === field) ?? USER_ADVANCED_FIELD_DEFINITIONS[0]
}
export function createEmptyUserAdvancedFilter(): UserAdvancedFilterItem {
return {
key: createFilterKey(),
field: 'email',
operator: 'like',
value: '',
logic: 'and',
}
}
export function cloneUserAdvancedFilters(filters: UserAdvancedFilterItem[]): UserAdvancedFilterItem[] {
return filters.map((item) => ({
key: item.key || createFilterKey(),
field: item.field,
operator: item.operator,
value: item.value ?? '',
logic: item.logic ?? 'and',
}))
}
function requiresFilterValue(operator: UserAdvancedOperator): boolean {
return operator !== 'null' && operator !== 'notnull'
}
function normalizeAdvancedFilterValue(item: UserAdvancedFilterItem): string | null {
if (!requiresFilterValue(item.operator)) {
return `${item.operator}:1`
}
if (item.value === null || item.value === undefined || item.value === '') {
return null
}
if (item.field === 'transfer_enable' || item.field === 'total_used') {
const numeric = Number(item.value)
if (!Number.isFinite(numeric) || numeric < 0) {
return null
}
return `${item.operator}:${Math.round(numeric * GIGABYTE)}`
}
if (item.field === 'expired_at') {
const timestamp = normalizeTimestampSeconds(item.value)
return timestamp ? `${item.operator}:${timestamp}` : null
}
if (item.field === 'id' || item.field === 'plan_id' || item.field === 'online_count' || item.field === 'banned') {
const numeric = Number(item.value)
return Number.isFinite(numeric) ? `${item.operator}:${numeric}` : null
}
const normalized = String(item.value).trim()
return normalized ? `${item.operator}:${normalized}` : null
}
function getPlanNameById(plans: AdminPlanOption[], id: string | number): string {
const numericId = Number(id)
const target = plans.find((plan) => Number(plan.id) === numericId)
return target?.name || `订阅 #${numericId}`
}
function formatAdvancedFilterValue(item: UserAdvancedFilterItem, plans: AdminPlanOption[]): string {
if (!requiresFilterValue(item.operator)) {
return item.operator === 'null' ? '未设置' : '已设置'
}
if (item.field === 'plan_id') {
return getPlanNameById(plans, item.value ?? '')
}
if (item.field === 'banned') {
return Number(item.value) === 1 ? '封禁' : '正常'
}
if (item.field === 'transfer_enable' || item.field === 'total_used') {
return `${Number(item.value)} GB`
}
if (item.field === 'expired_at') {
const seconds = normalizeTimestampSeconds(item.value)
return seconds ? new Date(seconds * 1000).toLocaleString('zh-CN', { hour12: false }) : '未设置'
}
return String(item.value)
}
export function splitEmailAddress(email: string): { prefix: string; suffix: string } | null {
const normalized = email.trim()
const atIndex = normalized.lastIndexOf('@')
@@ -184,24 +459,87 @@ export function toUserUpdatePayload(form: UserFormModel): AdminUserUpdatePayload
}
}
export function buildUserFilters(keyword: string, status: string, planId: string): Array<{ id: string; value: string | number[] }> {
const filters: Array<{ id: string; value: string | number[] }> = []
export function buildUserFilters(
keyword: string,
status: string,
planId: string,
advancedFilters: UserAdvancedFilterItem[] = [],
): AdminUserFilter[] {
const filters: AdminUserFilter[] = []
if (keyword.trim()) {
filters.push({ id: 'email', value: keyword.trim() })
filters.push({ id: 'email', value: `like:${keyword.trim()}` })
}
if (status === 'active') {
filters.push({ id: 'banned', value: [0] })
filters.push({ id: 'banned', value: 'eq:0' })
}
if (status === 'banned') {
filters.push({ id: 'banned', value: [1] })
filters.push({ id: 'banned', value: 'eq:1' })
}
if (planId && planId !== 'all') {
filters.push({ id: 'plan_id', value: [Number(planId)] })
filters.push({ id: 'plan_id', value: `eq:${Number(planId)}` })
}
for (const item of advancedFilters) {
const normalizedValue = normalizeAdvancedFilterValue(item)
if (!normalizedValue) {
continue
}
filters.push({
id: item.field,
value: normalizedValue,
logic: item.logic,
})
}
return filters
}
export function hasUserFilters(
keyword: string,
status: string,
planId: string,
advancedFilters: UserAdvancedFilterItem[] = [],
): boolean {
return buildUserFilters(keyword, status, planId, advancedFilters).length > 0
}
export function summarizeUserFilters(
keyword: string,
status: string,
planId: string,
advancedFilters: UserAdvancedFilterItem[] = [],
plans: AdminPlanOption[] = [],
): string[] {
const summaries: string[] = []
if (keyword.trim()) {
summaries.push(`邮箱包含 ${keyword.trim()}`)
}
if (status === 'active') {
summaries.push('快捷状态:正常')
}
if (status === 'banned') {
summaries.push('快捷状态:封禁')
}
if (planId && planId !== 'all') {
summaries.push(`快捷订阅:${getPlanNameById(plans, planId)}`)
}
for (const item of advancedFilters) {
const definition = getUserAdvancedFieldDefinition(item.field)
const operatorLabel = definition.operators.find((option) => option.value === item.operator)?.label ?? item.operator
const prefix = item.logic === 'or' ? '或' : '且'
const valueText = formatAdvancedFilterValue(item, plans)
summaries.push(`${prefix} ${definition.label} ${operatorLabel} ${valueText}`.trim())
}
return summaries
}
@@ -483,6 +483,12 @@ function rankScrollClass(limit: RankDisplayCount): string {
return limit === 20 ? 'rank-scroll rank-scroll--extended' : 'rank-scroll'
}
function rankChangeClass(change: number): string {
if (Number(change) > 0) return 'positive'
if (Number(change) < 0) return 'negative'
return 'neutral'
}
watch(trendPreset, () => {
void loadTrend().catch(() => ElMessage.error('趋势数据刷新失败'))
})
@@ -757,22 +763,45 @@ onMounted(() => {
:class="rankScrollClass(nodeRankLimit)"
>
<div class="rank-list">
<div
<ElTooltip
v-for="(item, index) in displayedNodeRanks"
:key="item.id"
class="rank-item"
placement="top-end"
:show-after="80"
popper-class="dashboard-rank-tooltip-popper"
>
<div class="rank-item__copy">
<strong>{{ item.name }}</strong>
<span>{{ formatTraffic(item.value) }}</span>
<template #content>
<div class="rank-tooltip">
<div class="rank-tooltip__row">
<span>当前流量</span>
<strong>{{ formatTraffic(item.value) }}</strong>
</div>
<div class="rank-tooltip__row">
<span>上期流量</span>
<strong>{{ formatTraffic(item.previousValue) }}</strong>
</div>
<div class="rank-tooltip__row">
<span>变化率</span>
<strong :class="rankChangeClass(item.change)">{{ formatPercent(item.change) }}</strong>
</div>
</div>
</template>
<div class="rank-item">
<div class="rank-item__copy">
<strong>{{ item.name }}</strong>
</div>
<div class="rank-item__bar">
<span :style="{ width: rankBarWidth(index) }" />
</div>
<div class="rank-item__meta">
<em :class="rankChangeClass(item.change)">
{{ formatPercent(item.change) }}
</em>
<span class="rank-item__value">{{ formatTraffic(item.value) }}</span>
</div>
</div>
<div class="rank-item__bar">
<span :style="{ width: rankBarWidth(index) }" />
</div>
<em :class="Number(item.change) >= 0 ? 'positive' : 'negative'">
{{ formatPercent(item.change) }}
</em>
</div>
</ElTooltip>
</div>
</div>
<div v-else class="panel-state">暂无节点排行数据</div>
@@ -822,22 +851,45 @@ onMounted(() => {
:class="rankScrollClass(userRankLimit)"
>
<div class="rank-list">
<div
<ElTooltip
v-for="(item, index) in displayedUserRanks"
:key="item.id"
class="rank-item"
placement="top-end"
:show-after="80"
popper-class="dashboard-rank-tooltip-popper"
>
<div class="rank-item__copy">
<strong>{{ item.name }}</strong>
<span>{{ formatTraffic(item.value) }}</span>
<template #content>
<div class="rank-tooltip">
<div class="rank-tooltip__row">
<span>当前流量</span>
<strong>{{ formatTraffic(item.value) }}</strong>
</div>
<div class="rank-tooltip__row">
<span>上期流量</span>
<strong>{{ formatTraffic(item.previousValue) }}</strong>
</div>
<div class="rank-tooltip__row">
<span>变化率</span>
<strong :class="rankChangeClass(item.change)">{{ formatPercent(item.change) }}</strong>
</div>
</div>
</template>
<div class="rank-item">
<div class="rank-item__copy">
<strong>{{ item.name }}</strong>
</div>
<div class="rank-item__bar">
<span :style="{ width: rankBarWidth(index) }" />
</div>
<div class="rank-item__meta">
<em :class="rankChangeClass(item.change)">
{{ formatPercent(item.change) }}
</em>
<span class="rank-item__value">{{ formatTraffic(item.value) }}</span>
</div>
</div>
<div class="rank-item__bar">
<span :style="{ width: rankBarWidth(index) }" />
</div>
<em :class="Number(item.change) >= 0 ? 'positive' : 'negative'">
{{ formatPercent(item.change) }}
</em>
</div>
</ElTooltip>
</div>
</div>
<div v-else class="panel-state">暂无用户排行数据</div>
@@ -1297,11 +1349,12 @@ onMounted(() => {
grid-template-columns: minmax(0, 1fr) 150px auto;
gap: 14px;
align-items: center;
cursor: default;
}
.rank-item__copy {
display: grid;
gap: 6px;
display: flex;
align-items: center;
min-width: 0;
}
@@ -1328,6 +1381,58 @@ onMounted(() => {
.rank-item em {
font-style: normal;
font-size: 14px;
font-weight: 600;
}
.rank-item__meta {
display: grid;
gap: 4px;
justify-items: end;
text-align: right;
}
.rank-item__value {
color: var(--xboard-text-strong);
font-size: 15px;
font-weight: 600;
}
.rank-tooltip {
display: grid;
gap: 10px;
min-width: 188px;
}
.rank-tooltip__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
}
.rank-tooltip__row span {
color: rgba(255, 255, 255, 0.68);
font-size: 12px;
}
.rank-tooltip__row strong {
color: #ffffff;
font-size: 13px;
font-weight: 600;
}
:global(.dashboard-rank-tooltip-popper) {
border: 1px solid rgba(255, 255, 255, 0.08) !important;
border-radius: 16px !important;
background: rgba(6, 12, 24, 0.94) !important;
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.28) !important;
backdrop-filter: blur(18px);
}
:global(.dashboard-rank-tooltip-popper .el-popper__arrow::before) {
background: rgba(6, 12, 24, 0.94) !important;
border-color: rgba(255, 255, 255, 0.08) !important;
}
.status-grid {
@@ -1443,6 +1548,11 @@ onMounted(() => {
.rank-item {
grid-template-columns: 1fr;
}
.rank-item__meta {
justify-items: start;
text-align: left;
}
}
@keyframes dashboard-refresh-spin {
+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;
}
}
@@ -20,6 +20,7 @@ interface AssignOrderFormModel {
const props = defineProps<{
visible: boolean
plans: AdminPlanListItem[]
initialEmail?: string
}>()
const emit = defineEmits<{
@@ -52,7 +53,7 @@ const rules = computed<FormRules<AssignOrderFormModel>>(() => ({
}))
function resetForm() {
form.email = ''
form.email = props.initialEmail?.trim() || ''
form.planId = null
form.period = ''
form.totalAmountYuan = null
@@ -5,6 +5,7 @@ import {
COMMISSION_STATUS_UPDATE_OPTIONS,
canCancelOrder,
canMarkOrderPaid,
hasOrderCommission,
canUpdateCommissionStatus,
formatOrderAmount,
formatOrderDateTime,
@@ -34,7 +35,12 @@ const commissionStatusDraft = ref<number | null>(null)
const statusMeta = computed(() => getOrderStatusMeta(props.order?.status))
const typeMeta = computed(() => getOrderTypeMeta(props.order?.type))
const commissionMeta = computed(() => getCommissionStatusMeta(props.order?.commission_status, props.order?.commission_balance))
const commissionMeta = computed(() => getCommissionStatusMeta(
props.order?.commission_status,
props.order?.commission_balance,
props.order?.actual_commission_balance,
))
const hasCommission = computed(() => hasOrderCommission(props.order))
const summaryCards = computed(() => [
{ label: '订单状态', value: statusMeta.value.label, detail: typeMeta.value.label },
@@ -165,7 +171,7 @@ watch(
<header class="card-header">
<div>
<h3>佣金状态</h3>
<p>仅对存在佣金金额的订单开放状态维护</p>
<p>{{ hasCommission ? '仅对真实佣金订单开放状态维护。' : '当前订单未产生佣金,不进入佣金确认或发放流程。' }}</p>
</div>
<span class="hero-badge" :class="`is-${commissionMeta.tone}`">{{ commissionMeta.label }}</span>
</header>
@@ -198,6 +204,10 @@ watch(
保存佣金状态
</ElButton>
</div>
<p v-else class="commission-empty-note">
{{ hasCommission ? '当前佣金状态已完成发放,列表页与详情页均不再提供编辑入口。' : '该订单没有真实佣金,列表页也不会出现在“确认佣金”筛选结果中。' }}
</p>
</section>
<section v-if="props.order.surplus_orders?.length" class="detail-card">
@@ -443,6 +453,12 @@ watch(
gap: 12px;
}
.commission-empty-note {
margin: 0;
color: var(--xboard-text-muted);
line-height: 1.6;
}
.commission-actions :deep(.el-select) {
flex: 1;
}
+16
View File
@@ -72,6 +72,12 @@
color: var(--xboard-text-secondary);
}
.filter-pill--accent {
border-color: rgba(0, 113, 227, 0.16);
background: rgba(0, 113, 227, 0.08);
color: #0071e3;
}
.filter-pill:hover,
.toolbar-ghost:hover {
color: #0071e3;
@@ -86,6 +92,10 @@
margin-bottom: -4px;
}
.orders-alert--info :deep(.el-alert__title) {
color: var(--xboard-text-strong);
}
.orders-table :deep(th.el-table__cell) {
background: #fbfbfd;
color: var(--xboard-text-secondary);
@@ -110,6 +120,12 @@
white-space: nowrap;
}
.action-trigger {
justify-content: center;
min-width: 34px;
color: var(--xboard-text-secondary);
}
.plan-cell {
display: grid;
gap: 4px;
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, RefreshRight, Search, TopRight } from '@element-plus/icons-vue'
import { CircleCheck, MoreFilled, Plus, RefreshRight, Search, TopRight } from '@element-plus/icons-vue'
import { useRoute, useRouter } from 'vue-router'
import {
cancelOrder,
fetchOrders,
@@ -17,6 +18,7 @@ import type {
AdminTableSort,
} from '@/types/api'
import {
canQuickConfirmCommission,
COMMISSION_STATUS_OPTIONS,
ORDER_PERIOD_OPTIONS,
ORDER_STATUS_OPTIONS,
@@ -38,9 +40,14 @@ import {
import OrderAssignDrawer from './OrderAssignDrawer.vue'
import OrderDetailDrawer from './OrderDetailDrawer.vue'
type CommissionWorkbenchMode = 'all' | 'pending' | 'commission'
type OrderRowAction = 'detail' | 'confirm-commission'
const loading = ref(false)
const metaLoading = ref(false)
const errorMessage = ref('')
const route = useRoute()
const router = useRouter()
const orders = ref<AdminOrderListItem[]>([])
const plans = ref<AdminPlanListItem[]>([])
@@ -53,6 +60,7 @@ const typeFilter = ref<OrderFilterValue<number>>('all')
const periodFilter = ref<OrderFilterValue<OrderPeriodKey>>('all')
const statusFilter = ref<OrderFilterValue<number>>('all')
const commissionFilter = ref<OrderFilterValue<number>>('all')
const commissionWorkbench = ref<CommissionWorkbenchMode>('all')
const sortState = ref<AdminTableSort>({ id: 'created_at', desc: true })
const assignVisible = ref(false)
@@ -62,6 +70,7 @@ const detailOrder = ref<AdminOrderDetail | null>(null)
const paying = ref(false)
const cancelling = ref(false)
const updatingCommission = ref(false)
const quickConfirmTradeNo = ref('')
const filterButtonLabels = computed(() => ({
type: typeFilter.value === 'all' ? '类型' : `类型 · ${getOrderFilterLabel(typeFilter.value)}`,
@@ -72,6 +81,54 @@ const filterButtonLabels = computed(() => ({
: `佣金状态 · ${getCommissionStatusFilterLabel(commissionFilter.value)}`,
}))
const scopedUserId = computed(() => {
const raw = route.query.user_id
const value = Array.isArray(raw) ? raw[0] : raw
const numeric = Number(value)
return Number.isFinite(numeric) && numeric > 0 ? numeric : null
})
const scopedUserEmail = computed(() => {
const raw = route.query.user_email
const value = Array.isArray(raw) ? raw[0] : raw
return typeof value === 'string' ? value : ''
})
const scopedUserFilters = computed(() => (
scopedUserId.value
? [{ id: 'user_id', value: [scopedUserId.value] }]
: []
))
const scopedUserNotice = computed(() => (
scopedUserId.value
? `当前仅展示 ${scopedUserEmail.value || `用户 #${scopedUserId.value}`} 的订单。`
: ''
))
const commissionWorkbenchLabel = computed(() => {
switch (commissionWorkbench.value) {
case 'pending':
return '确认佣金 · 待确认'
case 'commission':
return '确认佣金 · 全部佣金'
default:
return '确认佣金'
}
})
const commissionWorkbenchNotice = computed(() => {
if (commissionWorkbench.value === 'pending') {
return '当前仅展示真实待确认佣金订单,可在操作列直接确认。'
}
if (commissionWorkbench.value === 'commission' || commissionFilter.value !== 'all') {
return '当前仅展示真实佣金订单,佣金状态筛选不会再混入无佣金数据。'
}
return ''
})
async function loadPlans() {
metaLoading.value = true
try {
@@ -91,14 +148,18 @@ async function loadOrders() {
const response = await fetchOrders({
current: current.value,
pageSize: pageSize.value,
filter: buildOrderFetchFilters({
keyword: keyword.value,
type: typeFilter.value,
period: periodFilter.value,
status: statusFilter.value,
commissionStatus: commissionFilter.value,
}),
filter: [
...buildOrderFetchFilters({
keyword: keyword.value,
type: typeFilter.value,
period: periodFilter.value,
status: statusFilter.value,
commissionStatus: commissionFilter.value,
}),
...scopedUserFilters.value,
],
sort: sortState.value ? [sortState.value] : undefined,
is_commission: commissionWorkbench.value !== 'all' || commissionFilter.value !== 'all',
})
orders.value = response.data ?? []
@@ -134,6 +195,26 @@ function handleDropdownSelect(kind: 'type' | 'period' | 'status' | 'commission',
if (kind === 'commission') {
commissionFilter.value = value === 'all' ? 'all' : Number(value)
commissionWorkbench.value = commissionFilter.value === 'all'
? 'all'
: commissionFilter.value === 0
? 'pending'
: 'commission'
}
refreshOrders(true)
}
function handleCommissionWorkbench(command: string) {
if (command === 'pending') {
commissionWorkbench.value = 'pending'
commissionFilter.value = 0
} else if (command === 'commission') {
commissionWorkbench.value = 'commission'
commissionFilter.value = 'all'
} else {
commissionWorkbench.value = 'all'
commissionFilter.value = 'all'
}
refreshOrders(true)
@@ -145,7 +226,15 @@ function clearFilters() {
periodFilter.value = 'all'
statusFilter.value = 'all'
commissionFilter.value = 'all'
commissionWorkbench.value = 'all'
sortState.value = { id: 'created_at', desc: true }
if (scopedUserId.value) {
void router.replace({ name: 'SubscriptionOrders' }).finally(() => {
refreshOrders(true)
})
return
}
refreshOrders(true)
}
@@ -237,6 +326,53 @@ async function handleCommissionStatusUpdate(value: number) {
}
}
function isRowActionWorking(order: Pick<AdminOrderListItem, 'trade_no'>) {
return quickConfirmTradeNo.value === order.trade_no
}
async function handleQuickConfirmCommission(order: AdminOrderListItem) {
if (!canQuickConfirmCommission(order)) {
return
}
try {
await ElMessageBox.confirm(
`确认将订单 ${order.trade_no} 的佣金状态更新为“发放中”吗?`,
'确认佣金',
{ type: 'warning' },
)
quickConfirmTradeNo.value = order.trade_no
await updateOrderCommissionStatus(order.trade_no, 1)
ElMessage.success('佣金已确认,状态已更新为发放中')
const shouldReloadDetail = detailOrder.value?.trade_no === order.trade_no
await Promise.all([
loadOrders(),
shouldReloadDetail ? reloadDetail() : Promise.resolve(),
])
} catch (error) {
if (error === 'cancel' || error === 'close') {
return
}
ElMessage.error(error instanceof Error ? error.message : '佣金确认失败')
} finally {
quickConfirmTradeNo.value = ''
}
}
async function handleRowAction(command: OrderRowAction, order: AdminOrderListItem) {
if (command === 'detail') {
await openDetail(order)
return
}
if (command === 'confirm-commission') {
await handleQuickConfirmCommission(order)
}
}
function handleAssignSuccess() {
assignVisible.value = false
refreshOrders(true)
@@ -260,6 +396,13 @@ watch([current, pageSize], () => {
void loadOrders()
})
watch(
() => [route.query.user_id, route.query.user_email],
() => {
refreshOrders(true)
},
)
onMounted(() => {
void Promise.all([loadPlans(), loadOrders()]).catch(() => {
ElMessage.error('订单管理页面初始化失败')
@@ -372,6 +515,20 @@ onMounted(() => {
</ElDropdownMenu>
</template>
</ElDropdown>
<ElDropdown trigger="click" @command="handleCommissionWorkbench">
<ElButton class="filter-pill filter-pill--accent">
<ElIcon><CircleCheck /></ElIcon>
{{ commissionWorkbenchLabel }}
</ElButton>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem command="pending">真实待确认订单</ElDropdownItem>
<ElDropdownItem command="commission">全部佣金订单</ElDropdownItem>
<ElDropdownItem command="clear" divided>清空佣金筛选</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
<div class="toolbar-right">
@@ -398,6 +555,32 @@ onMounted(() => {
</template>
</ElAlert>
<ElAlert
v-if="!errorMessage && scopedUserNotice"
class="orders-alert orders-alert--info"
type="info"
:closable="false"
show-icon
:title="scopedUserNotice"
>
<template #default>
<ElButton size="small" @click="clearFilters">查看全部订单</ElButton>
</template>
</ElAlert>
<ElAlert
v-if="!errorMessage && commissionWorkbenchNotice"
class="orders-alert orders-alert--info"
type="info"
:closable="false"
show-icon
:title="commissionWorkbenchNotice"
>
<template #default>
<ElButton size="small" @click="handleCommissionWorkbench('clear')">清空佣金筛选</ElButton>
</template>
</ElAlert>
<ElTable
:data="orders"
v-loading="loading"
@@ -461,9 +644,9 @@ onMounted(() => {
<ElTableColumn prop="commission_status" label="佣金状态" min-width="150" sortable="custom">
<template #default="{ row }">
<span class="status-pill" :class="`is-${getCommissionStatusMeta(row.commission_status, row.commission_balance).tone}`">
<span class="status-pill" :class="`is-${getCommissionStatusMeta(row.commission_status, row.commission_balance, row.actual_commission_balance).tone}`">
<span class="status-dot" />
{{ getCommissionStatusMeta(row.commission_status, row.commission_balance).label }}
{{ getCommissionStatusMeta(row.commission_status, row.commission_balance, row.actual_commission_balance).label }}
</span>
</template>
</ElTableColumn>
@@ -473,6 +656,28 @@ onMounted(() => {
<span>{{ formatOrderDateTime(row.created_at) }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="96" fixed="right">
<template #default="{ row }">
<ElDropdown trigger="click" @command="(command) => handleRowAction(command as OrderRowAction, row)">
<ElButton text class="action-trigger" :loading="isRowActionWorking(row)">
<ElIcon><MoreFilled /></ElIcon>
</ElButton>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem command="detail">查看详情</ElDropdownItem>
<ElDropdownItem
v-if="canQuickConfirmCommission(row)"
command="confirm-commission"
divided
>
确认佣金
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</template>
</ElTableColumn>
</ElTable>
<footer class="table-footer">
@@ -0,0 +1,358 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Delete, Plus } from '@element-plus/icons-vue'
import type { AdminPlanOption } from '@/types/api'
import {
cloneUserAdvancedFilters,
createEmptyUserAdvancedFilter,
getUserAdvancedFieldDefinition,
USER_ADVANCED_FIELD_DEFINITIONS,
USER_STATUS_VALUE_OPTIONS,
type UserAdvancedFilterItem,
type UserAdvancedOperator,
} from '@/utils/users'
const props = defineProps<{
visible: boolean
filters: UserAdvancedFilterItem[]
plans: AdminPlanOption[]
}>()
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void
(e: 'apply', value: UserAdvancedFilterItem[]): void
}>()
const draftFilters = ref<UserAdvancedFilterItem[]>([])
const fieldOptions = computed(() => USER_ADVANCED_FIELD_DEFINITIONS.map((item) => ({
label: item.label,
value: item.field,
})))
function resetDraft() {
draftFilters.value = props.filters.length > 0
? cloneUserAdvancedFilters(props.filters)
: [createEmptyUserAdvancedFilter()]
}
function closeDialog() {
emit('update:visible', false)
}
function addCondition() {
draftFilters.value.push(createEmptyUserAdvancedFilter())
}
function removeCondition(index: number) {
if (draftFilters.value.length === 1) {
draftFilters.value = [createEmptyUserAdvancedFilter()]
return
}
draftFilters.value.splice(index, 1)
}
function clearAll() {
draftFilters.value = [createEmptyUserAdvancedFilter()]
}
function getDefinition(field: UserAdvancedFilterItem['field']) {
return getUserAdvancedFieldDefinition(field)
}
function needsValue(operator: UserAdvancedOperator) {
return operator !== 'null' && operator !== 'notnull'
}
function handleFieldChange(filter: UserAdvancedFilterItem) {
const definition = getDefinition(filter.field)
filter.operator = definition.operators[0]?.value ?? 'eq'
filter.value = definition.input === 'number' ? null : ''
}
function handleOperatorChange(filter: UserAdvancedFilterItem) {
if (!needsValue(filter.operator)) {
filter.value = ''
}
}
function applyFilters() {
const cleaned = draftFilters.value
.map((item) => ({
...item,
value: typeof item.value === 'string' ? item.value.trim() : item.value,
}))
.filter((item) => {
if (!needsValue(item.operator)) {
return true
}
return item.value !== '' && item.value !== null && item.value !== undefined
})
emit('apply', cleaned)
}
watch(() => props.visible, (visible) => {
if (visible) {
resetDraft()
}
}, { immediate: true })
</script>
<template>
<ElDialog
:model-value="visible"
width="820px"
destroy-on-close
class="advanced-filter-dialog"
@close="closeDialog"
>
<template #header>
<div class="dialog-header">
<div>
<h2>高级筛选</h2>
<p>添加一个或多个筛选条件来精准查找用户</p>
</div>
<ElButton class="header-button" @click="addCondition">
<ElIcon><Plus /></ElIcon>
添加条件
</ElButton>
</div>
</template>
<div class="dialog-body">
<article v-for="(filter, index) in draftFilters" :key="filter.key" class="condition-card">
<header class="condition-header">
<div class="condition-title">
<strong>条件 {{ index + 1 }}</strong>
<ElSelect
v-if="index > 0"
v-model="filter.logic"
size="small"
class="logic-select"
>
<ElOption label="并且" value="and" />
<ElOption label="或者" value="or" />
</ElSelect>
</div>
<ElButton text class="remove-button" @click="removeCondition(index)">
<ElIcon><Delete /></ElIcon>
</ElButton>
</header>
<div class="condition-grid">
<div class="field-block">
<span>字段</span>
<ElSelect v-model="filter.field" @change="handleFieldChange(filter)">
<ElOption
v-for="option in fieldOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</div>
<div class="field-block">
<span>条件</span>
<ElSelect v-model="filter.operator" @change="handleOperatorChange(filter)">
<ElOption
v-for="option in getDefinition(filter.field).operators"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</div>
<div class="field-block field-block--value">
<span></span>
<template v-if="!needsValue(filter.operator)">
<div class="value-placeholder">当前条件无需填写值</div>
</template>
<ElInput
v-else-if="getDefinition(filter.field).input === 'text'"
v-model="filter.value"
:placeholder="getDefinition(filter.field).placeholder || '请输入筛选值'"
/>
<ElInputNumber
v-else-if="getDefinition(filter.field).input === 'number'"
:model-value="typeof filter.value === 'number' ? filter.value : null"
:min="0"
:step="getDefinition(filter.field).step || 1"
controls-position="right"
class="full-width"
@update:model-value="filter.value = $event ?? null"
/>
<ElSelect
v-else-if="getDefinition(filter.field).input === 'plan'"
v-model="filter.value"
:disabled="!needsValue(filter.operator)"
placeholder="选择订阅计划"
>
<ElOption
v-for="plan in plans"
:key="plan.id"
:label="plan.name"
:value="plan.id"
/>
</ElSelect>
<ElSelect
v-else-if="getDefinition(filter.field).input === 'status'"
v-model="filter.value"
placeholder="选择账号状态"
>
<ElOption
v-for="option in USER_STATUS_VALUE_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
<ElDatePicker
v-else
v-model="filter.value"
type="datetime"
value-format="x"
format="YYYY-MM-DD HH:mm"
placeholder="选择时间"
class="full-width"
/>
<small v-if="getDefinition(filter.field).unit && needsValue(filter.operator)">
单位{{ getDefinition(filter.field).unit }}
</small>
</div>
</div>
</article>
</div>
<template #footer>
<div class="dialog-footer">
<ElButton @click="closeDialog">取消</ElButton>
<ElButton @click="clearAll">清空</ElButton>
<ElButton type="primary" @click="applyFilters">应用筛选</ElButton>
</div>
</template>
</ElDialog>
</template>
<style scoped lang="scss">
.dialog-header,
.condition-header,
.condition-title,
.dialog-footer {
display: flex;
align-items: center;
gap: 12px;
}
.dialog-header,
.condition-header,
.dialog-footer {
justify-content: space-between;
}
.dialog-header h2 {
margin: 0;
font-size: 24px;
color: var(--xboard-text-strong);
}
.dialog-header p {
margin: 8px 0 0;
color: var(--xboard-text-secondary);
}
.header-button {
border-radius: 999px;
}
.dialog-body {
display: grid;
gap: 16px;
}
.condition-card {
display: grid;
gap: 14px;
padding: 18px;
border-radius: 20px;
border: 1px solid rgba(15, 23, 42, 0.08);
background: #fbfbfd;
}
.condition-title strong {
color: var(--xboard-text-strong);
}
.logic-select {
width: 96px;
}
.remove-button {
color: var(--xboard-text-muted);
}
.condition-grid {
display: grid;
grid-template-columns: 1.2fr 1fr 1.6fr;
gap: 14px;
}
.field-block {
display: grid;
gap: 8px;
}
.field-block span {
color: var(--xboard-text-secondary);
font-size: 13px;
}
.field-block small {
color: var(--xboard-text-muted);
font-size: 12px;
}
.field-block--value {
align-self: start;
}
.value-placeholder {
display: flex;
align-items: center;
min-height: 40px;
padding: 0 14px;
border-radius: 12px;
background: #f1f3f7;
color: var(--xboard-text-muted);
font-size: 13px;
}
.full-width {
width: 100%;
}
@media (max-width: 860px) {
.dialog-header,
.condition-header,
.dialog-footer {
flex-direction: column;
align-items: stretch;
}
.condition-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,123 @@
<script setup lang="ts">
import { reactive, watch } from 'vue'
const props = defineProps<{
visible: boolean
loading: boolean
targetLabel: string
}>()
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void
(e: 'submit', value: { subject: string; content: string }): void
}>()
const form = reactive({
subject: '',
content: '',
})
function resetForm() {
form.subject = ''
form.content = ''
}
function closeDialog() {
emit('update:visible', false)
}
function handleSubmit() {
emit('submit', {
subject: form.subject.trim(),
content: form.content.trim(),
})
}
watch(() => props.visible, (visible) => {
if (visible) {
resetForm()
}
})
</script>
<template>
<ElDialog
:model-value="visible"
width="620px"
destroy-on-close
class="batch-mail-dialog"
@close="closeDialog"
>
<template #header>
<div class="dialog-header">
<h2>发送邮件</h2>
<p>邮件将发送给{{ targetLabel }}</p>
</div>
</template>
<div class="dialog-body">
<ElForm label-position="top">
<ElFormItem label="邮件主题" required>
<ElInput v-model="form.subject" maxlength="120" show-word-limit placeholder="请输入邮件主题" />
</ElFormItem>
<ElFormItem label="邮件内容" required>
<ElInput
v-model="form.content"
type="textarea"
:rows="8"
maxlength="5000"
show-word-limit
placeholder="请输入要发送给用户的内容"
/>
</ElFormItem>
<p class="helper-text">建议在执行前再次确认筛选范围避免误发给不需要通知的用户</p>
</ElForm>
</div>
<template #footer>
<div class="dialog-footer">
<ElButton @click="closeDialog">取消</ElButton>
<ElButton
type="primary"
:loading="loading"
:disabled="!form.subject.trim() || !form.content.trim()"
@click="handleSubmit"
>
发送邮件
</ElButton>
</div>
</template>
</ElDialog>
</template>
<style scoped lang="scss">
.dialog-header h2 {
margin: 0;
font-size: 24px;
color: var(--xboard-text-strong);
}
.dialog-header p {
margin: 8px 0 0;
color: var(--xboard-text-secondary);
}
.dialog-body {
padding-top: 4px;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
.helper-text {
margin: 0;
color: var(--xboard-text-muted);
font-size: 12px;
line-height: 1.5;
}
</style>
+216
View File
@@ -0,0 +1,216 @@
.users-page {
display: grid;
gap: 24px;
}
.users-hero {
display: flex;
justify-content: space-between;
gap: 24px;
padding: 30px 32px;
border-radius: 28px;
background: #000000;
}
.users-copy {
display: grid;
gap: 10px;
max-width: 680px;
}
.users-kicker {
font-size: 11px;
letter-spacing: 0.24em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.68);
}
.users-copy h1 {
font-size: clamp(34px, 5vw, 52px);
line-height: 1.08;
letter-spacing: -0.28px;
color: #ffffff;
}
.users-copy span {
color: rgba(255, 255, 255, 0.72);
line-height: 1.47;
}
.hero-stats {
display: grid;
grid-template-columns: repeat(3, 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: 22px;
}
.table-shell {
display: grid;
gap: 18px;
padding: 24px;
border-radius: 26px;
background: #ffffff;
box-shadow: var(--xboard-shadow);
}
.table-toolbar,
.toolbar-fields,
.toolbar-actions,
.table-footer,
.filter-summary {
display: flex;
align-items: center;
gap: 12px;
}
.table-toolbar,
.table-footer {
justify-content: space-between;
}
.toolbar-fields {
flex: 1;
flex-wrap: wrap;
}
.toolbar-actions {
justify-content: flex-end;
flex-wrap: wrap;
}
.toolbar-input {
width: min(360px, 100%);
}
.toolbar-select {
width: 160px;
}
.filter-pill {
border-radius: 999px;
border-color: var(--xboard-border);
background: #ffffff;
color: var(--xboard-text-secondary);
}
.filter-pill:hover,
.toolbar-ghost:hover {
color: #0071e3;
border-color: rgba(0, 113, 227, 0.18);
}
.filter-pill__count {
min-width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 8px;
border-radius: 999px;
background: rgba(0, 113, 227, 0.12);
color: #0071e3;
font-size: 12px;
}
.toolbar-ghost {
color: var(--xboard-text-secondary);
}
.scope-hint {
color: var(--xboard-text-muted);
font-size: 13px;
}
.filter-summary {
flex-wrap: wrap;
padding: 12px 14px;
border-radius: 18px;
background: #f8f8fb;
}
.filter-summary__label {
color: var(--xboard-text-secondary);
font-size: 13px;
font-weight: 600;
}
.filter-summary__tag {
border-color: rgba(0, 113, 227, 0.18);
color: #0071e3;
}
.filter-summary__clear {
color: var(--xboard-text-secondary);
}
.users-alert {
margin-bottom: -4px;
}
.users-table :deep(th.el-table__cell) {
color: var(--xboard-text-secondary);
background: #fbfbfd;
}
.users-table :deep(.el-table__row td.el-table__cell) {
padding-top: 16px;
padding-bottom: 16px;
}
.email-cell,
.stack-cell,
.traffic-cell {
display: grid;
gap: 6px;
}
.email-cell strong,
.stack-cell strong {
color: var(--xboard-text-strong);
}
.email-cell span,
.stack-cell span,
.table-footer span {
color: var(--xboard-text-muted);
}
.traffic-cell {
min-width: 132px;
}
.action-trigger {
font-size: 18px;
}
@media (max-width: 1180px) {
.users-hero,
.table-toolbar,
.table-footer {
flex-direction: column;
align-items: stretch;
}
.hero-stats {
min-width: 0;
grid-template-columns: 1fr;
}
}
+176 -314
View File
@@ -1,160 +1,68 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { MoreFilled, Plus, RefreshRight, Search } from '@element-plus/icons-vue'
import { deleteUser, fetchUsers, getPlans, resetUserSecret, updateUser } from '@/api/admin'
import type { AdminPlanOption, AdminUserListItem } from '@/types/api'
import { formatDateTime, formatTraffic } from '@/utils/dashboard'
import { buildUserFilters, getUserStatusMeta, getUserUsagePercent } from '@/utils/users'
import { getUserStatusMeta, getUserUsagePercent } from '@/utils/users'
import OrderAssignDrawer from '@/views/subscriptions/OrderAssignDrawer.vue'
import TrafficLogDialog from '@/views/tickets/TrafficLogDialog.vue'
import UserAdvancedFilterDialog from './UserAdvancedFilterDialog.vue'
import UserBatchMailDialog from './UserBatchMailDialog.vue'
import UserFormDrawer from './UserFormDrawer.vue'
import { useUsersManagement } from './useUsersManagement'
type DrawerMode = 'create' | 'edit'
type UserAction = 'edit' | 'copy' | 'reset-secret' | 'toggle-ban' | 'delete'
type UserAction =
| 'edit'
| 'assign-order'
| 'copy'
| 'reset-secret'
| 'view-orders'
| 'view-invites'
| 'view-traffic'
| 'reset-traffic'
| 'toggle-ban'
| 'delete'
const loading = ref(false)
const plansLoading = ref(false)
const users = ref<AdminUserListItem[]>([])
const plans = ref<AdminPlanOption[]>([])
const total = ref(0)
const current = ref(1)
const pageSize = ref(20)
const keyword = ref('')
const statusFilter = ref('all')
const planFilter = ref('all')
const drawerVisible = ref(false)
const drawerMode = ref<DrawerMode>('create')
const activeUser = ref<AdminUserListItem | null>(null)
const pageStats = computed(() => [
{ label: '用户总数', value: String(total.value) },
{ label: '当前页', value: String(current.value) },
{ label: '已筛选套餐', value: planFilter.value === 'all' ? '全部' : '单套餐' },
])
async function loadPlans() {
plansLoading.value = true
try {
const response = await getPlans()
plans.value = response.data ?? []
} finally {
plansLoading.value = false
}
}
async function loadUsers() {
loading.value = true
try {
const response = await fetchUsers({
current: current.value,
pageSize: pageSize.value,
filter: buildUserFilters(keyword.value, statusFilter.value, planFilter.value),
sort: [{ id: 'id', desc: true }],
})
users.value = response.data
total.value = response.total
} finally {
loading.value = false
}
}
function openCreateDrawer() {
drawerMode.value = 'create'
activeUser.value = null
drawerVisible.value = true
}
function openEditDrawer(user: AdminUserListItem) {
drawerMode.value = 'edit'
activeUser.value = user
drawerVisible.value = true
}
async function copySubscribeUrl(user: AdminUserListItem) {
if (!navigator.clipboard?.writeText) {
ElMessage.warning('当前环境不支持复制,请手动复制订阅地址')
return
}
await navigator.clipboard.writeText(user.subscribe_url)
ElMessage.success('订阅地址已复制')
}
async function toggleBan(user: AdminUserListItem) {
const nextValue = !user.banned
const actionText = nextValue ? '封禁' : '恢复'
await ElMessageBox.confirm(`确认${actionText}用户 ${user.email} 吗?`, `${actionText}用户`, {
type: 'warning',
})
await updateUser({ id: user.id, banned: nextValue })
ElMessage.success(`用户已${actionText}`)
await loadUsers()
}
async function handleAction(action: UserAction, user: AdminUserListItem) {
if (action === 'edit') {
openEditDrawer(user)
return
}
if (action === 'copy') {
await copySubscribeUrl(user)
return
}
if (action === 'reset-secret') {
await ElMessageBox.confirm(`确认重置 ${user.email} 的 UUID 与订阅地址吗?`, '重置密钥', {
type: 'warning',
})
await resetUserSecret(user.id)
ElMessage.success('UUID 与订阅地址已重置')
await loadUsers()
return
}
if (action === 'toggle-ban') {
await toggleBan(user)
return
}
await ElMessageBox.confirm(`删除用户 ${user.email} 后无法恢复,确认继续吗?`, '删除用户', {
type: 'warning',
})
await deleteUser(user.id)
ElMessage.success('用户已删除')
await loadUsers()
}
function handleSearch() {
current.value = 1
void loadUsers()
}
function handleReset() {
keyword.value = ''
statusFilter.value = 'all'
planFilter.value = 'all'
current.value = 1
void loadUsers()
}
watch(pageSize, () => {
current.value = 1
void loadUsers()
})
watch(current, () => {
void loadUsers()
})
onMounted(() => {
void Promise.all([loadPlans(), loadUsers()]).catch(() => {
ElMessage.error('用户管理页面初始化失败')
})
})
const {
loading,
plansLoading,
errorMessage,
users,
plans,
total,
current,
pageSize,
keyword,
statusFilter,
planFilter,
advancedFilters,
advancedFilterVisible,
batchMailVisible,
batchMailSubmitting,
assignOrderVisible,
assignOrderEmail,
trafficLogVisible,
trafficLogUserId,
trafficLogUserEmail,
drawerVisible,
drawerMode,
activeUser,
selectedUsers,
pageStats,
appliedFilterSummaries,
batchTargetLabel,
batchActionDisabled,
refreshUsers,
handleSearch,
handleReset,
clearAdvancedFilters,
applyAdvancedFilters,
handleSelectionChange,
openCreateDrawer,
handleUserSaved,
handleAction,
handleBatchCommand,
submitBatchMail,
handleAssignOrderSuccess,
} = useUsersManagement()
</script>
<template>
@@ -163,7 +71,7 @@ onMounted(() => {
<div class="users-copy">
<p class="users-kicker">Users</p>
<h1>用户管理工作台</h1>
<span>用一页完成搜索筛选编辑与账户维护保留 Apple 风格的轻量信息层次</span>
<span>现在可以在同一页完成快捷筛选高级筛选行级维护与批量操作继续保持 Apple 风格的轻量运营节奏</span>
</div>
<div class="hero-stats">
@@ -189,7 +97,7 @@ onMounted(() => {
</template>
</ElInput>
<ElSelect v-model="statusFilter" class="toolbar-select" placeholder="用户状态">
<ElSelect v-model="statusFilter" class="toolbar-select" placeholder="用户状态" @change="handleSearch">
<ElOption label="全部状态" value="all" />
<ElOption label="正常" value="active" />
<ElOption label="封禁" value="banned" />
@@ -200,6 +108,7 @@ onMounted(() => {
class="toolbar-select"
:loading="plansLoading"
placeholder="订阅计划"
@change="handleSearch"
>
<ElOption label="全部订阅" value="all" />
<ElOption
@@ -209,13 +118,43 @@ onMounted(() => {
:value="String(plan.id)"
/>
</ElSelect>
<ElButton class="filter-pill" @click="advancedFilterVisible = true">
高级筛选
<span v-if="appliedFilterSummaries.length" class="filter-pill__count">
{{ appliedFilterSummaries.length }}
</span>
</ElButton>
</div>
<div class="toolbar-actions">
<ElButton @click="handleReset">
<span class="scope-hint">{{ batchTargetLabel }}</span>
<ElDropdown trigger="click" @command="handleBatchCommand">
<ElButton class="toolbar-ghost" :disabled="batchActionDisabled">
<ElIcon><MoreFilled /></ElIcon>
批量操作
</ElButton>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem command="send-mail">发送邮件</ElDropdownItem>
<ElDropdownItem command="export-csv">导出 CSV</ElDropdownItem>
<ElDropdownItem command="ban">批量封禁</ElDropdownItem>
<ElDropdownItem command="restore">恢复正常</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
<ElButton class="toolbar-ghost" @click="handleReset">
<ElIcon><RefreshRight /></ElIcon>
重置筛选
</ElButton>
<ElButton class="toolbar-ghost" :loading="loading" @click="refreshUsers(false)">
<ElIcon><RefreshRight /></ElIcon>
刷新
</ElButton>
<ElButton type="primary" @click="openCreateDrawer">
<ElIcon><Plus /></ElIcon>
创建用户
@@ -223,7 +162,44 @@ onMounted(() => {
</div>
</header>
<ElTable :data="users" v-loading="loading" class="users-table" row-key="id">
<div v-if="appliedFilterSummaries.length" class="filter-summary">
<span class="filter-summary__label">已生效筛选</span>
<ElTag
v-for="item in appliedFilterSummaries"
:key="item"
effect="plain"
round
class="filter-summary__tag"
>
{{ item }}
</ElTag>
<ElButton text class="filter-summary__clear" @click="clearAdvancedFilters">
清空高级筛选
</ElButton>
</div>
<ElAlert
v-if="errorMessage"
class="users-alert"
type="error"
:closable="false"
show-icon
:title="errorMessage"
>
<template #default>
<ElButton size="small" @click="refreshUsers(false)">重新加载</ElButton>
</template>
</ElAlert>
<ElTable
:data="users"
v-loading="loading"
class="users-table"
row-key="id"
empty-text="当前筛选条件下暂无用户"
@selection-change="handleSelectionChange"
>
<ElTableColumn type="selection" width="52" reserve-selection />
<ElTableColumn prop="id" label="ID" width="92" />
<ElTableColumn label="邮箱" min-width="220">
<template #default="{ row }">
@@ -266,6 +242,14 @@ onMounted(() => {
{{ formatTraffic(row.transfer_enable) }}
</template>
</ElTableColumn>
<ElTableColumn label="在线设备" width="118">
<template #default="{ row }">
<div class="stack-cell">
<strong>{{ row.online_count ?? 0 }}</strong>
<span>当前在线</span>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="余额" width="118">
<template #default="{ row }">
¥{{ Number(row.balance || 0).toFixed(2) }}
@@ -285,8 +269,13 @@ onMounted(() => {
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem command="edit">编辑</ElDropdownItem>
<ElDropdownItem command="copy">复制订阅 URL</ElDropdownItem>
<ElDropdownItem command="reset-secret">重置 UUID 及订阅 URL</ElDropdownItem>
<ElDropdownItem command="assign-order">分配订单</ElDropdownItem>
<ElDropdownItem command="copy">复制订阅URL</ElDropdownItem>
<ElDropdownItem command="reset-secret">重置UUID及订阅URL</ElDropdownItem>
<ElDropdownItem command="view-orders">TA的订单</ElDropdownItem>
<ElDropdownItem command="view-invites">TA的邀请</ElDropdownItem>
<ElDropdownItem command="view-traffic">TA的流量记录</ElDropdownItem>
<ElDropdownItem command="reset-traffic">重置流量</ElDropdownItem>
<ElDropdownItem command="toggle-ban">
{{ row.banned ? '恢复正常' : '封禁用户' }}
</ElDropdownItem>
@@ -299,7 +288,7 @@ onMounted(() => {
</ElTable>
<footer class="table-footer">
<span>加载 {{ users.length }} {{ total }} </span>
<span>选择 {{ selectedUsers.length }} {{ total }} </span>
<ElPagination
v-model:current-page="current"
v-model:page-size="pageSize"
@@ -316,163 +305,36 @@ onMounted(() => {
:mode="drawerMode"
:user="activeUser"
:plans="plans"
@success="() => loadUsers()"
@success="handleUserSaved"
/>
<UserAdvancedFilterDialog
v-model:visible="advancedFilterVisible"
:filters="advancedFilters"
:plans="plans"
@apply="applyAdvancedFilters"
/>
<UserBatchMailDialog
v-model:visible="batchMailVisible"
:loading="batchMailSubmitting"
:target-label="batchTargetLabel"
@submit="submitBatchMail"
/>
<OrderAssignDrawer
v-model:visible="assignOrderVisible"
:plans="plans"
:initial-email="assignOrderEmail"
@success="handleAssignOrderSuccess"
/>
<TrafficLogDialog
v-model:visible="trafficLogVisible"
:user-id="trafficLogUserId"
:user-email="trafficLogUserEmail"
/>
</div>
</template>
<style scoped>
.users-page {
display: grid;
gap: 24px;
}
.users-hero {
display: flex;
justify-content: space-between;
gap: 24px;
padding: 30px 32px;
border-radius: 28px;
background: #000000;
}
.users-copy {
display: grid;
gap: 10px;
max-width: 620px;
}
.users-kicker {
font-size: 11px;
letter-spacing: 0.24em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.68);
}
.users-copy h1 {
font-size: clamp(34px, 5vw, 52px);
line-height: 1.08;
letter-spacing: -0.28px;
color: #ffffff;
}
.users-copy span {
color: rgba(255, 255, 255, 0.72);
line-height: 1.47;
}
.hero-stats {
display: grid;
grid-template-columns: repeat(3, 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: 22px;
}
.table-shell {
display: grid;
gap: 18px;
padding: 24px;
border-radius: 26px;
background: #ffffff;
box-shadow: var(--xboard-shadow);
}
.table-toolbar,
.toolbar-fields,
.toolbar-actions,
.table-footer {
display: flex;
align-items: center;
gap: 12px;
}
.table-toolbar,
.table-footer {
justify-content: space-between;
}
.toolbar-fields {
flex: 1;
flex-wrap: wrap;
}
.toolbar-input {
width: min(360px, 100%);
}
.toolbar-select {
width: 160px;
}
.users-table :deep(th.el-table__cell) {
color: var(--xboard-text-secondary);
background: #fbfbfd;
}
.users-table :deep(.el-table__row td.el-table__cell) {
padding-top: 16px;
padding-bottom: 16px;
}
.email-cell,
.stack-cell,
.traffic-cell {
display: grid;
gap: 6px;
}
.email-cell strong,
.stack-cell strong {
color: var(--xboard-text-strong);
}
.email-cell span,
.stack-cell span,
.table-footer span {
color: var(--xboard-text-muted);
}
.traffic-cell {
min-width: 132px;
}
.action-trigger {
font-size: 18px;
}
@media (max-width: 1080px) {
.users-hero,
.table-toolbar,
.table-footer {
flex-direction: column;
align-items: stretch;
}
.hero-stats {
min-width: 0;
grid-template-columns: 1fr;
}
.toolbar-actions {
justify-content: flex-end;
}
}
</style>
<style scoped lang="scss" src="./UsersView.scss"></style>
@@ -0,0 +1,130 @@
import { computed, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { resetUserTraffic } from '@/api/admin'
import type { AdminUserFilter, AdminUserListItem } from '@/types/api'
export function useUserScopedActions() {
const route = useRoute()
const router = useRouter()
const assignOrderVisible = ref(false)
const assignOrderEmail = ref('')
const trafficLogVisible = ref(false)
const trafficLogUserId = ref<number | null>(null)
const trafficLogUserEmail = ref('')
const resettingTrafficId = ref<number | null>(null)
const scopedInviteUserId = computed(() => {
const raw = route.query.invite_user_id
const value = Array.isArray(raw) ? raw[0] : raw
const numeric = Number(value)
return Number.isFinite(numeric) && numeric > 0 ? numeric : null
})
const scopedInviteUserEmail = computed(() => {
const raw = route.query.invite_user_email
const value = Array.isArray(raw) ? raw[0] : raw
return typeof value === 'string' ? value : ''
})
const scopedInviteFilters = computed<AdminUserFilter[]>(() => (
scopedInviteUserId.value
? [{ id: 'invite_user_id', value: `eq:${scopedInviteUserId.value}` }]
: []
))
const scopedInviteSummaries = computed(() => {
if (!scopedInviteUserId.value) {
return []
}
const label = scopedInviteUserEmail.value || `用户 #${scopedInviteUserId.value}`
return [`邀请人:${label}`]
})
function clearScopedInviteQuery() {
if (!scopedInviteUserId.value && !scopedInviteUserEmail.value) {
return Promise.resolve()
}
const nextQuery = { ...route.query }
delete nextQuery.invite_user_id
delete nextQuery.invite_user_email
return router.replace({ name: 'Users', query: nextQuery })
}
function openAssignOrder(user: Pick<AdminUserListItem, 'email'>) {
assignOrderEmail.value = user.email
assignOrderVisible.value = true
}
function handleAssignOrderSuccess() {
assignOrderVisible.value = false
}
function openTrafficLogs(user: Pick<AdminUserListItem, 'id' | 'email'>) {
trafficLogUserId.value = user.id
trafficLogUserEmail.value = user.email
trafficLogVisible.value = true
}
function viewUserOrders(user: Pick<AdminUserListItem, 'id' | 'email'>) {
return router.push({
name: 'SubscriptionOrders',
query: {
user_id: String(user.id),
user_email: user.email,
},
})
}
function viewUserInvites(
user: Pick<AdminUserListItem, 'id' | 'email'>,
resetLocalFilters: () => void,
) {
resetLocalFilters()
return router.push({
name: 'Users',
query: {
invite_user_id: String(user.id),
invite_user_email: user.email,
},
})
}
async function performResetTraffic(user: Pick<AdminUserListItem, 'id' | 'email'>) {
await ElMessageBox.confirm(`确认重置用户 ${user.email} 的已用流量吗?该操作会清空当前上行和下行统计。`, '重置流量', {
type: 'warning',
})
resettingTrafficId.value = user.id
try {
await resetUserTraffic(user.id, '用户管理更多操作手动重置')
ElMessage.success('用户流量已重置')
} finally {
resettingTrafficId.value = null
}
}
return {
route,
assignOrderVisible,
assignOrderEmail,
trafficLogVisible,
trafficLogUserId,
trafficLogUserEmail,
resettingTrafficId,
scopedInviteUserId,
scopedInviteFilters,
scopedInviteSummaries,
clearScopedInviteQuery,
openAssignOrder,
handleAssignOrderSuccess,
openTrafficLogs,
viewUserOrders,
viewUserInvites,
performResetTraffic,
}
}
@@ -0,0 +1,234 @@
import { computed, ref, type ComputedRef, type Ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
batchUpdateUserBan,
exportUsersCsv,
sendUsersMail,
} from '@/api/admin'
import type {
AdminUserBulkMailPayload,
AdminUserBulkScopePayload,
AdminUserFilter,
AdminUserListItem,
} from '@/types/api'
import { hasUserFilters, type UserAdvancedFilterItem } from '@/utils/users'
interface UserBatchMailForm {
subject: string
content: string
}
interface UseUsersBatchActionsOptions {
loading: Ref<boolean>
total: Ref<number>
keyword: Ref<string>
statusFilter: Ref<string>
planFilter: Ref<string>
advancedFilters: Ref<UserAdvancedFilterItem[]>
appliedFilters: ComputedRef<AdminUserFilter[]>
loadUsers: () => Promise<void>
}
function isCancelError(error: unknown): boolean {
return error === 'cancel' || error === 'close'
}
function createTimestamp(): string {
const now = new Date()
const pad = (value: number) => String(value).padStart(2, '0')
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
}
function triggerBlobDownload(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = fileName
document.body.appendChild(anchor)
anchor.click()
document.body.removeChild(anchor)
URL.revokeObjectURL(url)
}
export function useUsersBatchActions(options: UseUsersBatchActionsOptions) {
const selectedUsers = ref<AdminUserListItem[]>([])
const batchMailVisible = ref(false)
const batchMailSubmitting = ref(false)
const batchActionSubmitting = ref(false)
const batchTarget = computed(() => {
if (selectedUsers.value.length > 0) {
return {
scope: 'selected' as const,
label: `当前已选 ${selectedUsers.value.length} 个用户`,
user_ids: selectedUsers.value.map((user) => user.id),
}
}
if (hasUserFilters(
options.keyword.value,
options.statusFilter.value,
options.planFilter.value,
options.advancedFilters.value,
)) {
return {
scope: 'filtered' as const,
label: '当前筛选结果',
filter: options.appliedFilters.value,
}
}
return {
scope: 'all' as const,
label: '全部用户',
}
})
const batchTargetLabel = computed(() => batchTarget.value.label)
const batchActionDisabled = computed(() => (
options.loading.value
|| batchActionSubmitting.value
|| batchMailSubmitting.value
|| (options.total.value === 0 && selectedUsers.value.length === 0)
))
function handleSelectionChange(selection: AdminUserListItem[]) {
selectedUsers.value = selection
}
function resetSelection() {
selectedUsers.value = []
}
function buildScopePayload(): AdminUserBulkScopePayload {
if (batchTarget.value.scope === 'selected') {
return {
scope: 'selected',
user_ids: batchTarget.value.user_ids,
}
}
if (batchTarget.value.scope === 'filtered') {
return {
scope: 'filtered',
filter: batchTarget.value.filter,
}
}
return { scope: 'all' }
}
function buildMutationScopePayload(): Pick<AdminUserBulkMailPayload, 'scope' | 'user_ids' | 'filter' | 'sort' | 'sort_type'> {
return {
...buildScopePayload(),
sort: 'id',
sort_type: 'DESC',
}
}
async function ensureAllUsersConfirmation(actionText: string) {
if (batchTarget.value.scope !== 'all') {
return
}
await ElMessageBox.confirm(`当前未勾选用户且未设置筛选条件,将对全部用户执行“${actionText}”,确认继续吗?`, `确认${actionText}`, {
type: 'warning',
})
}
async function exportCurrentUsers() {
try {
await ensureAllUsersConfirmation('导出 CSV')
batchActionSubmitting.value = true
const blob = await exportUsersCsv(buildScopePayload())
triggerBlobDownload(blob, `users-${batchTarget.value.scope}-${createTimestamp()}.csv`)
ElMessage.success(`CSV 已导出(${batchTarget.value.label}`)
} catch (error) {
if (!isCancelError(error)) {
ElMessage.error(error instanceof Error ? error.message : 'CSV 导出失败')
}
} finally {
batchActionSubmitting.value = false
}
}
async function submitBatchMail(form: UserBatchMailForm) {
try {
await ensureAllUsersConfirmation('发送邮件')
batchMailSubmitting.value = true
await sendUsersMail({
...buildMutationScopePayload(),
subject: form.subject,
content: form.content,
})
batchMailVisible.value = false
ElMessage.success(`邮件发送任务已提交(${batchTarget.value.label}`)
} catch (error) {
if (!isCancelError(error)) {
ElMessage.error(error instanceof Error ? error.message : '批量邮件发送失败')
}
} finally {
batchMailSubmitting.value = false
}
}
async function updateBatchBanState(banned: boolean) {
const actionText = banned ? '批量封禁' : '恢复正常'
try {
await ensureAllUsersConfirmation(actionText)
await ElMessageBox.confirm(`确认对${batchTarget.value.label}执行“${actionText}”吗?`, actionText, {
type: 'warning',
})
batchActionSubmitting.value = true
await batchUpdateUserBan({
...buildMutationScopePayload(),
banned: banned ? 1 : 0,
})
ElMessage.success(`${actionText}已完成(${batchTarget.value.label}`)
await options.loadUsers()
} catch (error) {
if (!isCancelError(error)) {
ElMessage.error(error instanceof Error ? error.message : `${actionText}失败`)
}
} finally {
batchActionSubmitting.value = false
}
}
async function handleBatchCommand(command: string | number | object) {
const normalizedCommand = String(command)
if (normalizedCommand === 'send-mail') {
batchMailVisible.value = true
return
}
if (normalizedCommand === 'export-csv') {
await exportCurrentUsers()
return
}
if (normalizedCommand === 'ban') {
await updateBatchBanState(true)
return
}
if (normalizedCommand === 'restore') {
await updateBatchBanState(false)
}
}
return {
selectedUsers,
batchMailVisible,
batchMailSubmitting,
batchTargetLabel,
batchActionDisabled,
handleSelectionChange,
resetSelection,
handleBatchCommand,
submitBatchMail,
}
}
@@ -0,0 +1,339 @@
import { computed, onMounted, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
deleteUser,
fetchUsers,
getPlans,
resetUserSecret,
updateUser,
} from '@/api/admin'
import type {
AdminPlanListItem,
AdminUserFilter,
AdminUserFetchParams,
AdminUserListItem,
} from '@/types/api'
import {
buildUserFilters,
summarizeUserFilters,
type UserAdvancedFilterItem,
} from '@/utils/users'
import { useUsersBatchActions } from './useUsersBatchActions'
import { useUserScopedActions } from './useUserScopedActions'
type DrawerMode = 'create' | 'edit'
type UserAction =
| 'edit'
| 'assign-order'
| 'copy'
| 'reset-secret'
| 'view-orders'
| 'view-invites'
| 'view-traffic'
| 'reset-traffic'
| 'toggle-ban'
| 'delete'
function isCancelError(error: unknown): boolean {
return error === 'cancel' || error === 'close'
}
export function useUsersManagement() {
const loading = ref(false)
const plansLoading = ref(false)
const errorMessage = ref('')
const users = ref<AdminUserListItem[]>([])
const plans = ref<AdminPlanListItem[]>([])
const total = ref(0)
const current = ref(1)
const pageSize = ref(20)
const keyword = ref('')
const statusFilter = ref('all')
const planFilter = ref('all')
const advancedFilters = ref<UserAdvancedFilterItem[]>([])
const drawerVisible = ref(false)
const drawerMode = ref<DrawerMode>('create')
const activeUser = ref<AdminUserListItem | null>(null)
const advancedFilterVisible = ref(false)
const scopedActions = useUserScopedActions()
const appliedFilters = computed<AdminUserFilter[]>(() => [
...buildUserFilters(
keyword.value,
statusFilter.value,
planFilter.value,
advancedFilters.value,
),
...scopedActions.scopedInviteFilters.value,
])
const appliedFilterSummaries = computed(() => summarizeUserFilters(
keyword.value,
statusFilter.value,
planFilter.value,
advancedFilters.value,
plans.value,
).concat(scopedActions.scopedInviteSummaries.value))
const batchActions = useUsersBatchActions({
loading,
total,
keyword,
statusFilter,
planFilter,
advancedFilters,
appliedFilters,
loadUsers,
})
const pageStats = computed(() => [
{ label: '当前结果', value: String(total.value) },
{ label: '已选用户', value: String(batchActions.selectedUsers.value.length) },
{ label: '生效条件', value: String(appliedFilterSummaries.value.length) },
])
async function loadPlans() {
plansLoading.value = true
try {
const response = await getPlans()
plans.value = response.data ?? []
} catch (error) {
ElMessage.warning(error instanceof Error ? error.message : '订阅计划加载失败')
} finally {
plansLoading.value = false
}
}
async function loadUsers() {
loading.value = true
errorMessage.value = ''
try {
const params: AdminUserFetchParams = {
current: current.value,
pageSize: pageSize.value,
filter: appliedFilters.value,
sort: [{ id: 'id', desc: true }],
}
const response = await fetchUsers(params)
users.value = response.data ?? []
total.value = response.total ?? 0
batchActions.resetSelection()
} catch (error) {
users.value = []
total.value = 0
errorMessage.value = error instanceof Error ? error.message : '用户列表加载失败'
} finally {
loading.value = false
}
}
function refreshUsers(resetPage: boolean = false) {
if (resetPage && current.value !== 1) {
current.value = 1
return
}
void loadUsers()
}
function handleSearch() {
refreshUsers(true)
}
function handleReset() {
keyword.value = ''
statusFilter.value = 'all'
planFilter.value = 'all'
advancedFilters.value = []
void scopedActions.clearScopedInviteQuery().finally(() => {
refreshUsers(true)
})
}
function clearAdvancedFilters() {
advancedFilters.value = []
refreshUsers(true)
}
function applyAdvancedFilters(filters: UserAdvancedFilterItem[]) {
advancedFilters.value = filters
advancedFilterVisible.value = false
refreshUsers(true)
}
function openCreateDrawer() {
drawerMode.value = 'create'
activeUser.value = null
drawerVisible.value = true
}
function openEditDrawer(user: AdminUserListItem) {
drawerMode.value = 'edit'
activeUser.value = user
drawerVisible.value = true
}
function handleUserSaved() {
refreshUsers(false)
}
async function copySubscribeUrl(user: AdminUserListItem) {
if (!navigator.clipboard?.writeText) {
ElMessage.warning('当前环境不支持复制,请手动复制订阅地址')
return
}
await navigator.clipboard.writeText(user.subscribe_url)
ElMessage.success('订阅地址已复制')
}
async function toggleBan(user: AdminUserListItem) {
const nextValue = !user.banned
const actionText = nextValue ? '封禁' : '恢复'
await ElMessageBox.confirm(`确认${actionText}用户 ${user.email} 吗?`, `${actionText}用户`, {
type: 'warning',
})
await updateUser({ id: user.id, banned: nextValue })
ElMessage.success(`用户已${actionText}`)
await loadUsers()
}
async function handleAction(action: UserAction, user: AdminUserListItem) {
try {
if (action === 'edit') {
openEditDrawer(user)
return
}
if (action === 'assign-order') {
scopedActions.openAssignOrder(user)
return
}
if (action === 'copy') {
await copySubscribeUrl(user)
return
}
if (action === 'reset-secret') {
await ElMessageBox.confirm(`确认重置 ${user.email} 的 UUID 与订阅地址吗?`, '重置密钥', {
type: 'warning',
})
await resetUserSecret(user.id)
ElMessage.success('UUID 与订阅地址已重置')
await loadUsers()
return
}
if (action === 'toggle-ban') {
await toggleBan(user)
return
}
if (action === 'view-orders') {
await scopedActions.viewUserOrders(user)
return
}
if (action === 'view-invites') {
await scopedActions.viewUserInvites(user, () => {
keyword.value = ''
statusFilter.value = 'all'
planFilter.value = 'all'
advancedFilters.value = []
})
return
}
if (action === 'view-traffic') {
scopedActions.openTrafficLogs(user)
return
}
if (action === 'reset-traffic') {
await scopedActions.performResetTraffic(user)
await loadUsers()
return
}
await ElMessageBox.confirm(`删除用户 ${user.email} 后无法恢复,确认继续吗?`, '删除用户', {
type: 'warning',
})
await deleteUser(user.id)
ElMessage.success('用户已删除')
await loadUsers()
} catch (error) {
if (!isCancelError(error)) {
ElMessage.error(error instanceof Error ? error.message : '用户操作失败')
}
}
}
watch([current, pageSize], () => {
void loadUsers()
})
watch(
() => [
scopedActions.route.query.invite_user_id,
scopedActions.route.query.invite_user_email,
],
() => {
refreshUsers(true)
},
)
onMounted(() => {
void Promise.all([loadPlans(), loadUsers()]).catch(() => {
ElMessage.error('用户管理页面初始化失败')
})
})
return {
loading,
plansLoading,
errorMessage,
users,
plans,
total,
current,
pageSize,
keyword,
statusFilter,
planFilter,
advancedFilters,
advancedFilterVisible,
batchMailVisible: batchActions.batchMailVisible,
batchMailSubmitting: batchActions.batchMailSubmitting,
assignOrderVisible: scopedActions.assignOrderVisible,
assignOrderEmail: scopedActions.assignOrderEmail,
trafficLogVisible: scopedActions.trafficLogVisible,
trafficLogUserId: scopedActions.trafficLogUserId,
trafficLogUserEmail: scopedActions.trafficLogUserEmail,
drawerVisible,
drawerMode,
activeUser,
selectedUsers: batchActions.selectedUsers,
pageStats,
appliedFilterSummaries,
batchTargetLabel: batchActions.batchTargetLabel,
batchActionDisabled: batchActions.batchActionDisabled,
refreshUsers,
handleSearch,
handleReset,
clearAdvancedFilters,
applyAdvancedFilters,
handleSelectionChange: batchActions.handleSelectionChange,
openCreateDrawer,
handleUserSaved,
handleAction,
handleBatchCommand: batchActions.handleBatchCommand,
submitBatchMail: batchActions.submitBatchMail,
handleAssignOrderSuccess: scopedActions.handleAssignOrderSuccess,
}
}