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

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

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

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

补充 admin-frontend 独立 Dockerfile、Caddy 配置与 GHCR
发布工作流,支持通过独立镜像部署管理前端
This commit is contained in:
yinjianm
2026-04-24 23:15:48 +08:00
parent e393b11b61
commit d4168720ac
65 changed files with 4114 additions and 438 deletions
+345 -7
View File
@@ -1,4 +1,4 @@
import type { AdminUserListItem, AdminUserUpdatePayload } from '@/types/api'
import type { AdminPlanOption, AdminUserFilter, AdminUserListItem, AdminUserUpdatePayload } from '@/types/api'
export interface UserStatusMeta {
label: string
@@ -28,6 +28,187 @@ export interface UserFormModel {
remarks: string
}
export type UserAdvancedFieldKey =
| 'email'
| 'id'
| 'plan_id'
| 'transfer_enable'
| 'total_used'
| 'online_count'
| 'expired_at'
| 'uuid'
| 'token'
| 'banned'
| 'remarks'
export type UserAdvancedOperator =
| 'like'
| 'notlike'
| 'eq'
| 'gt'
| 'gte'
| 'lt'
| 'lte'
| 'null'
| 'notnull'
export type UserAdvancedInputKind = 'text' | 'number' | 'plan' | 'status' | 'date'
export interface UserAdvancedFilterItem {
key: string
field: UserAdvancedFieldKey
operator: UserAdvancedOperator
value: string | number | null
logic: 'and' | 'or'
}
export interface UserAdvancedFieldDefinition {
field: UserAdvancedFieldKey
label: string
input: UserAdvancedInputKind
placeholder?: string
unit?: string
step?: number
operators: Array<{ value: UserAdvancedOperator; label: string }>
}
export const USER_STATUS_VALUE_OPTIONS = [
{ label: '正常', value: 0 },
{ label: '封禁', value: 1 },
] as const
export const USER_ADVANCED_FIELD_DEFINITIONS: UserAdvancedFieldDefinition[] = [
{
field: 'email',
label: '邮箱',
input: 'text',
placeholder: '输入邮箱关键字',
operators: [
{ value: 'like', label: '包含' },
{ value: 'notlike', label: '不包含' },
{ value: 'eq', label: '等于' },
],
},
{
field: 'id',
label: '用户ID',
input: 'number',
placeholder: '输入用户 ID',
step: 1,
operators: [
{ value: 'eq', label: '等于' },
{ value: 'gt', label: '大于' },
{ value: 'gte', label: '大于等于' },
{ value: 'lt', label: '小于' },
{ value: 'lte', label: '小于等于' },
],
},
{
field: 'plan_id',
label: '订阅',
input: 'plan',
operators: [
{ value: 'eq', label: '是' },
{ value: 'null', label: '未订阅' },
{ value: 'notnull', label: '已订阅' },
],
},
{
field: 'transfer_enable',
label: '流量',
input: 'number',
placeholder: '输入流量值',
unit: 'GB',
step: 1,
operators: [
{ value: 'eq', label: '等于' },
{ value: 'gt', label: '大于' },
{ value: 'gte', label: '大于等于' },
{ value: 'lt', label: '小于' },
{ value: 'lte', label: '小于等于' },
],
},
{
field: 'total_used',
label: '已用流量',
input: 'number',
placeholder: '输入已用流量',
unit: 'GB',
step: 1,
operators: [
{ value: 'eq', label: '等于' },
{ value: 'gt', label: '大于' },
{ value: 'gte', label: '大于等于' },
{ value: 'lt', label: '小于' },
{ value: 'lte', label: '小于等于' },
],
},
{
field: 'online_count',
label: '在线设备',
input: 'number',
placeholder: '输入在线设备数',
step: 1,
operators: [
{ value: 'eq', label: '等于' },
{ value: 'gt', label: '大于' },
{ value: 'gte', label: '大于等于' },
{ value: 'lt', label: '小于' },
{ value: 'lte', label: '小于等于' },
],
},
{
field: 'expired_at',
label: '到期时间',
input: 'date',
operators: [
{ value: 'gte', label: '晚于' },
{ value: 'lte', label: '早于' },
{ value: 'null', label: '长期有效' },
{ value: 'notnull', label: '已设置到期时间' },
],
},
{
field: 'uuid',
label: 'UUID',
input: 'text',
placeholder: '输入 UUID',
operators: [
{ value: 'like', label: '包含' },
{ value: 'notlike', label: '不包含' },
{ value: 'eq', label: '等于' },
],
},
{
field: 'token',
label: 'Token',
input: 'text',
placeholder: '输入 Token',
operators: [
{ value: 'like', label: '包含' },
{ value: 'notlike', label: '不包含' },
{ value: 'eq', label: '等于' },
],
},
{
field: 'banned',
label: '账号状态',
input: 'status',
operators: [{ value: 'eq', label: '是' }],
},
{
field: 'remarks',
label: '备注',
input: 'text',
placeholder: '输入备注关键字',
operators: [
{ value: 'like', label: '包含' },
{ value: 'notlike', label: '不包含' },
{ value: 'eq', label: '等于' },
],
},
] as const
export const COMMISSION_TYPE_OPTIONS = [
{ label: '跟随系统', value: 0 },
{ label: '周期返佣', value: 1 },
@@ -36,6 +217,10 @@ export const COMMISSION_TYPE_OPTIONS = [
const GIGABYTE = 1024 ** 3
function createFilterKey(): string {
return `user-filter-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
}
function toNumber(value: unknown): number {
const numeric = Number(value)
return Number.isFinite(numeric) ? numeric : 0
@@ -68,6 +253,96 @@ export function normalizeTimestampSeconds(value: number | string | null | undefi
return Number.isFinite(numeric) && numeric > 0 ? Math.floor(numeric) : null
}
export function getUserAdvancedFieldDefinition(field: UserAdvancedFieldKey): UserAdvancedFieldDefinition {
return USER_ADVANCED_FIELD_DEFINITIONS.find((item) => item.field === field) ?? USER_ADVANCED_FIELD_DEFINITIONS[0]
}
export function createEmptyUserAdvancedFilter(): UserAdvancedFilterItem {
return {
key: createFilterKey(),
field: 'email',
operator: 'like',
value: '',
logic: 'and',
}
}
export function cloneUserAdvancedFilters(filters: UserAdvancedFilterItem[]): UserAdvancedFilterItem[] {
return filters.map((item) => ({
key: item.key || createFilterKey(),
field: item.field,
operator: item.operator,
value: item.value ?? '',
logic: item.logic ?? 'and',
}))
}
function requiresFilterValue(operator: UserAdvancedOperator): boolean {
return operator !== 'null' && operator !== 'notnull'
}
function normalizeAdvancedFilterValue(item: UserAdvancedFilterItem): string | null {
if (!requiresFilterValue(item.operator)) {
return `${item.operator}:1`
}
if (item.value === null || item.value === undefined || item.value === '') {
return null
}
if (item.field === 'transfer_enable' || item.field === 'total_used') {
const numeric = Number(item.value)
if (!Number.isFinite(numeric) || numeric < 0) {
return null
}
return `${item.operator}:${Math.round(numeric * GIGABYTE)}`
}
if (item.field === 'expired_at') {
const timestamp = normalizeTimestampSeconds(item.value)
return timestamp ? `${item.operator}:${timestamp}` : null
}
if (item.field === 'id' || item.field === 'plan_id' || item.field === 'online_count' || item.field === 'banned') {
const numeric = Number(item.value)
return Number.isFinite(numeric) ? `${item.operator}:${numeric}` : null
}
const normalized = String(item.value).trim()
return normalized ? `${item.operator}:${normalized}` : null
}
function getPlanNameById(plans: AdminPlanOption[], id: string | number): string {
const numericId = Number(id)
const target = plans.find((plan) => Number(plan.id) === numericId)
return target?.name || `订阅 #${numericId}`
}
function formatAdvancedFilterValue(item: UserAdvancedFilterItem, plans: AdminPlanOption[]): string {
if (!requiresFilterValue(item.operator)) {
return item.operator === 'null' ? '未设置' : '已设置'
}
if (item.field === 'plan_id') {
return getPlanNameById(plans, item.value ?? '')
}
if (item.field === 'banned') {
return Number(item.value) === 1 ? '封禁' : '正常'
}
if (item.field === 'transfer_enable' || item.field === 'total_used') {
return `${Number(item.value)} GB`
}
if (item.field === 'expired_at') {
const seconds = normalizeTimestampSeconds(item.value)
return seconds ? new Date(seconds * 1000).toLocaleString('zh-CN', { hour12: false }) : '未设置'
}
return String(item.value)
}
export function splitEmailAddress(email: string): { prefix: string; suffix: string } | null {
const normalized = email.trim()
const atIndex = normalized.lastIndexOf('@')
@@ -184,24 +459,87 @@ export function toUserUpdatePayload(form: UserFormModel): AdminUserUpdatePayload
}
}
export function buildUserFilters(keyword: string, status: string, planId: string): Array<{ id: string; value: string | number[] }> {
const filters: Array<{ id: string; value: string | number[] }> = []
export function buildUserFilters(
keyword: string,
status: string,
planId: string,
advancedFilters: UserAdvancedFilterItem[] = [],
): AdminUserFilter[] {
const filters: AdminUserFilter[] = []
if (keyword.trim()) {
filters.push({ id: 'email', value: keyword.trim() })
filters.push({ id: 'email', value: `like:${keyword.trim()}` })
}
if (status === 'active') {
filters.push({ id: 'banned', value: [0] })
filters.push({ id: 'banned', value: 'eq:0' })
}
if (status === 'banned') {
filters.push({ id: 'banned', value: [1] })
filters.push({ id: 'banned', value: 'eq:1' })
}
if (planId && planId !== 'all') {
filters.push({ id: 'plan_id', value: [Number(planId)] })
filters.push({ id: 'plan_id', value: `eq:${Number(planId)}` })
}
for (const item of advancedFilters) {
const normalizedValue = normalizeAdvancedFilterValue(item)
if (!normalizedValue) {
continue
}
filters.push({
id: item.field,
value: normalizedValue,
logic: item.logic,
})
}
return filters
}
export function hasUserFilters(
keyword: string,
status: string,
planId: string,
advancedFilters: UserAdvancedFilterItem[] = [],
): boolean {
return buildUserFilters(keyword, status, planId, advancedFilters).length > 0
}
export function summarizeUserFilters(
keyword: string,
status: string,
planId: string,
advancedFilters: UserAdvancedFilterItem[] = [],
plans: AdminPlanOption[] = [],
): string[] {
const summaries: string[] = []
if (keyword.trim()) {
summaries.push(`邮箱包含 ${keyword.trim()}`)
}
if (status === 'active') {
summaries.push('快捷状态:正常')
}
if (status === 'banned') {
summaries.push('快捷状态:封禁')
}
if (planId && planId !== 'all') {
summaries.push(`快捷订阅:${getPlanNameById(plans, planId)}`)
}
for (const item of advancedFilters) {
const definition = getUserAdvancedFieldDefinition(item.field)
const operatorLabel = definition.operators.find((option) => option.value === item.operator)?.label ?? item.operator
const prefix = item.logic === 'or' ? '或' : '且'
const valueText = formatAdvancedFilterValue(item, plans)
summaries.push(`${prefix} ${definition.label} ${operatorLabel} ${valueText}`.trim())
}
return summaries
}