feat(admin-frontend): 补齐用户节点与订单运营工作台
新增用户高级筛选、批量操作与更多行级动作,支持邮件、 CSV、封禁恢复、订单分配、邀请查看、流量记录与重置流量 增强节点管理页的分页、父子筛选、跨页勾选、批量修改与 单节点置顶,并补齐后端批量更新 host、group_ids、rate 修复订单佣金状态误判问题,新增真实佣金筛选与行级确认, 同时优化仪表盘排行悬浮详情展示 补充 admin-frontend 独立 Dockerfile、Caddy 配置与 GHCR 发布工作流,支持通过独立镜像部署管理前端
This commit is contained in:
@@ -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 })
|
||||
|
||||
Vendored
+28
@@ -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
@@ -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']
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user