feat(admin-frontend): 新增用户管理页面和导航

This commit is contained in:
yinjianm
2026-04-21 05:19:07 +08:00
parent f68ba190a8
commit 97cf167090
22 changed files with 1795 additions and 15 deletions
+61
View File
@@ -1,5 +1,11 @@
import { adminClient } from './client'
import type {
AdminPaginationResult,
AdminPlanOption,
AdminUserFetchParams,
AdminUserGeneratePayload,
AdminUserListItem,
AdminUserUpdatePayload,
ApiResponse,
DashboardStats,
OrderTrendData,
@@ -14,6 +20,25 @@ function unwrap<T>(url: string, params?: Record<string, unknown>): Promise<ApiRe
.then((res) => res.data)
}
function unwrapPost<T>(url: string, data?: Record<string, unknown>): Promise<ApiResponse<T>> {
return adminClient
.post<ApiResponse<T>>(url, data)
.then((res) => res.data)
}
function splitEmail(email: string): { email_prefix: string; email_suffix: string } {
const normalized = email.trim()
const atIndex = normalized.lastIndexOf('@')
if (atIndex <= 0 || atIndex === normalized.length - 1) {
throw new Error('请输入有效的邮箱地址')
}
return {
email_prefix: normalized.slice(0, atIndex),
email_suffix: normalized.slice(atIndex + 1),
}
}
export function getDashboardStats(): Promise<ApiResponse<DashboardStats>> {
return unwrap<DashboardStats>('/stat/getStats')
}
@@ -53,3 +78,39 @@ export function getSystemStatus(): Promise<ApiResponse<SystemStatus>> {
export function getQueueStats(): Promise<ApiResponse<QueueStats>> {
return unwrap<QueueStats>('/system/getQueueStats')
}
export function getPlans(): Promise<ApiResponse<AdminPlanOption[]>> {
return unwrap<AdminPlanOption[]>('/plan/fetch')
}
export function fetchUsers(params: AdminUserFetchParams): Promise<AdminPaginationResult<AdminUserListItem>> {
return adminClient
.get<AdminPaginationResult<AdminUserListItem>>('/user/fetch', { params })
.then((res) => res.data)
}
export function getUserById(id: number): Promise<ApiResponse<AdminUserListItem>> {
return unwrap<AdminUserListItem>('/user/getUserInfoById', { id })
}
export function createUser(payload: AdminUserGeneratePayload): Promise<ApiResponse<boolean>> {
const email = splitEmail(payload.email)
return unwrapPost<boolean>('/user/generate', {
...email,
password: payload.password,
plan_id: payload.plan_id,
expired_at: payload.expired_at,
})
}
export function updateUser(payload: AdminUserUpdatePayload): Promise<ApiResponse<boolean>> {
return unwrapPost<boolean>('/user/update', payload as unknown as Record<string, unknown>)
}
export function resetUserSecret(id: number): Promise<ApiResponse<boolean>> {
return unwrapPost<boolean>('/user/resetSecret', { id })
}
export function deleteUser(id: number): Promise<ApiResponse<boolean>> {
return unwrapPost<boolean>('/user/destroy', { id })
}
+42 -7
View File
@@ -5,9 +5,12 @@ import { useAuthStore } from '@/stores/auth'
import { useAppStore } from '@/stores/app'
import {
Odometer,
Tickets,
SwitchButton,
Fold,
Expand,
User,
UserFilled,
} from '@element-plus/icons-vue'
const route = useRoute()
@@ -24,6 +27,11 @@ const menuItems = [
{ index: '/dashboard', title: '仪表盘', icon: Odometer },
]
const managementItems = [
{ index: '/users', title: '用户管理', icon: User },
{ index: '/tickets', title: '工单管理', icon: Tickets },
]
function syncViewport() {
isMobile.value = window.innerWidth < 960
if (isMobile.value) {
@@ -66,20 +74,37 @@ onBeforeUnmount(() => {
<ElMenu
:default-active="route.path"
:default-openeds="['management']"
:collapse="app.sidebarCollapsed"
:collapse-transition="false"
router
class="admin-menu"
@select="handleMenuSelect"
>
<ElMenuItem
v-for="item in menuItems"
>
<ElMenuItem
v-for="item in menuItems"
:key="item.index"
:index="item.index"
>
<ElIcon><component :is="item.icon" /></ElIcon>
<template #title>{{ item.title }}</template>
</ElMenuItem>
>
<ElIcon><component :is="item.icon" /></ElIcon>
<template #title>{{ item.title }}</template>
</ElMenuItem>
<ElSubMenu index="management">
<template #title>
<ElIcon><UserFilled /></ElIcon>
<span>用户管理</span>
</template>
<ElMenuItem
v-for="item in managementItems"
:key="item.index"
:index="item.index"
>
<ElIcon><component :is="item.icon" /></ElIcon>
<template #title>{{ item.title }}</template>
</ElMenuItem>
</ElSubMenu>
</ElMenu>
</ElAside>
@@ -191,6 +216,16 @@ onBeforeUnmount(() => {
color: #0071e3;
}
.admin-menu :deep(.el-sub-menu__title) {
border-radius: 12px;
color: var(--xboard-text-secondary);
height: 44px;
}
.admin-menu :deep(.el-sub-menu .el-menu-item) {
margin-left: 8px;
}
.admin-stage {
background: #f5f5f7;
}
+12
View File
@@ -23,6 +23,18 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/dashboard/DashboardView.vue'),
meta: { title: '仪表盘', kicker: 'Overview' },
},
{
path: 'users',
name: 'Users',
component: () => import('@/views/users/UsersView.vue'),
meta: { title: '用户管理', kicker: 'Users' },
},
{
path: 'tickets',
name: 'Tickets',
component: () => import('@/views/tickets/TicketsView.vue'),
meta: { title: '工单管理', kicker: 'Tickets' },
},
],
},
]
+105
View File
@@ -116,6 +116,111 @@ export interface QueueStats {
wait?: QueueWaitEntry[]
}
export interface AdminPaginationResult<T> {
data: T[]
total: number
}
export interface AdminGroupOption {
id: number
name: string
}
export interface AdminPlanOption {
id: number
name: string
sort?: number
transfer_enable?: number | null
group_id?: number | null
users_count?: number
active_users_count?: number
group?: AdminGroupOption | null
}
export interface AdminUserRef {
id: number
email: string
}
export interface AdminUserListItem {
id: number
email: string
token: string
uuid: string
plan_id: number | null
group_id: number | null
transfer_enable: number
u: number
d: number
total_used: number
expired_at: number | null
balance: number
commission_balance: number
commission_rate: number | null
commission_type: number | null
discount: number | null
speed_limit: number | null
device_limit: number | null
remarks: string | null
banned: boolean
is_admin: boolean
is_staff: boolean
created_at: number
updated_at: number
subscribe_url: string
plan?: AdminPlanOption | null
group?: AdminGroupOption | null
invite_user?: AdminUserRef | null
}
export interface AdminUserFilter {
id: string
value: string | number | boolean | Array<string | number>
logic?: 'and' | 'or'
}
export interface AdminUserSort {
id: string
desc: boolean
}
export interface AdminUserFetchParams {
current: number
pageSize: number
filter?: AdminUserFilter[]
sort?: AdminUserSort[]
}
export interface AdminUserGeneratePayload {
email: string
password: string
plan_id?: number | null
expired_at?: number | null
}
export interface AdminUserUpdatePayload {
id: number
email?: string
password?: string
transfer_enable?: number
expired_at?: number | null
banned?: boolean | number
plan_id?: number | null
commission_rate?: number | null
discount?: number | null
is_admin?: boolean
is_staff?: boolean
u?: number
d?: number
balance?: number
commission_type?: number | null
commission_balance?: number
remarks?: string | null
speed_limit?: number | null
device_limit?: number | null
invite_user_email?: string | null
}
declare global {
interface Window {
settings?: {
+18
View File
@@ -16,15 +16,33 @@ declare module 'vue' {
ElButton: typeof import('element-plus/es')['ElButton']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElHeader: typeof import('element-plus/es')['ElHeader']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElMain: typeof import('element-plus/es')['ElMain']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElProgress: typeof import('element-plus/es')['ElProgress']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTag: typeof import('element-plus/es')['ElTag']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
export interface GlobalDirectives {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}
+207
View File
@@ -0,0 +1,207 @@
import type { AdminUserListItem, AdminUserUpdatePayload } from '@/types/api'
export interface UserStatusMeta {
label: string
type: 'success' | 'danger' | 'warning' | 'info'
}
export interface UserFormModel {
id?: number
email: string
password: string
uploadGb: number | null
downloadGb: number | null
totalTrafficGb: number | null
expiredAt: number | null
planId: number | null
banned: boolean
commissionType: number | null
commissionRate: number | null
discount: number | null
speedLimit: number | null
deviceLimit: number | null
balance: number | null
commissionBalance: number | null
inviteUserEmail: string
isAdmin: boolean
isStaff: boolean
remarks: string
}
export const COMMISSION_TYPE_OPTIONS = [
{ label: '跟随系统', value: 0 },
{ label: '周期返佣', value: 1 },
{ label: '一次性返佣', value: 2 },
] as const
const GIGABYTE = 1024 ** 3
function toNumber(value: unknown): number {
const numeric = Number(value)
return Number.isFinite(numeric) ? numeric : 0
}
export function bytesToGigabytes(value: unknown): number | null {
const numeric = toNumber(value)
if (numeric <= 0) {
return null
}
return Number((numeric / GIGABYTE).toFixed(2))
}
export function gigabytesToBytes(value: number | null | undefined): number {
const numeric = Number(value)
if (!Number.isFinite(numeric) || numeric <= 0) {
return 0
}
return Math.round(numeric * GIGABYTE)
}
export function normalizeTimestampSeconds(value: number | string | null | undefined): number | null {
if (value === null || value === undefined || value === '') {
return null
}
const numeric = Number(value)
return Number.isFinite(numeric) && numeric > 0 ? Math.floor(numeric) : null
}
export function splitEmailAddress(email: string): { prefix: string; suffix: string } | null {
const normalized = email.trim()
const atIndex = normalized.lastIndexOf('@')
if (atIndex <= 0 || atIndex === normalized.length - 1) {
return null
}
return {
prefix: normalized.slice(0, atIndex),
suffix: normalized.slice(atIndex + 1),
}
}
export function getUserUsagePercent(user: Pick<AdminUserListItem, 'transfer_enable' | 'total_used'>): number {
const total = toNumber(user.transfer_enable)
if (total <= 0) {
return 0
}
return Math.min(100, Number(((toNumber(user.total_used) / total) * 100).toFixed(1)))
}
export function getUserStatusMeta(user: Pick<AdminUserListItem, 'banned' | 'expired_at' | 'plan_id'>): UserStatusMeta {
if (user.banned) {
return { label: '封禁', type: 'danger' }
}
if (!user.plan_id) {
return { label: '未订阅', type: 'info' }
}
if (user.expired_at && user.expired_at < Math.floor(Date.now() / 1000)) {
return { label: '已到期', type: 'warning' }
}
return { label: '正常', type: 'success' }
}
export function createEmptyUserForm(): UserFormModel {
return {
email: '',
password: '',
uploadGb: null,
downloadGb: null,
totalTrafficGb: null,
expiredAt: null,
planId: null,
banned: false,
commissionType: 0,
commissionRate: null,
discount: null,
speedLimit: null,
deviceLimit: null,
balance: null,
commissionBalance: null,
inviteUserEmail: '',
isAdmin: false,
isStaff: false,
remarks: '',
}
}
export function toUserFormModel(user?: AdminUserListItem | null): UserFormModel {
if (!user) {
return createEmptyUserForm()
}
return {
id: user.id,
email: user.email,
password: '',
uploadGb: bytesToGigabytes(user.u),
downloadGb: bytesToGigabytes(user.d),
totalTrafficGb: bytesToGigabytes(user.transfer_enable),
expiredAt: normalizeTimestampSeconds(user.expired_at),
planId: user.plan_id,
banned: Boolean(user.banned),
commissionType: user.commission_type ?? 0,
commissionRate: user.commission_rate ?? null,
discount: user.discount ?? null,
speedLimit: user.speed_limit ?? null,
deviceLimit: user.device_limit ?? null,
balance: user.balance ?? null,
commissionBalance: user.commission_balance ?? null,
inviteUserEmail: user.invite_user?.email ?? '',
isAdmin: Boolean(user.is_admin),
isStaff: Boolean(user.is_staff),
remarks: user.remarks ?? '',
}
}
export function toUserUpdatePayload(form: UserFormModel): AdminUserUpdatePayload {
return {
id: Number(form.id),
email: form.email.trim(),
password: form.password.trim() || undefined,
u: gigabytesToBytes(form.uploadGb),
d: gigabytesToBytes(form.downloadGb),
transfer_enable: gigabytesToBytes(form.totalTrafficGb),
expired_at: normalizeTimestampSeconds(form.expiredAt),
plan_id: form.planId,
banned: form.banned,
commission_type: form.commissionType,
commission_rate: form.commissionRate,
discount: form.discount,
speed_limit: form.speedLimit,
device_limit: form.deviceLimit,
balance: form.balance ?? 0,
commission_balance: form.commissionBalance ?? 0,
invite_user_email: form.inviteUserEmail.trim() || null,
is_admin: form.isAdmin,
is_staff: form.isStaff,
remarks: form.remarks.trim() || null,
}
}
export function buildUserFilters(keyword: string, status: string, planId: string): Array<{ id: string; value: string | number[] }> {
const filters: Array<{ id: string; value: string | number[] }> = []
if (keyword.trim()) {
filters.push({ id: 'email', value: keyword.trim() })
}
if (status === 'active') {
filters.push({ id: 'banned', value: [0] })
}
if (status === 'banned') {
filters.push({ id: 'banned', value: [1] })
}
if (planId && planId !== 'all') {
filters.push({ id: 'plan_id', value: [Number(planId)] })
}
return filters
}
@@ -0,0 +1,77 @@
<script setup lang="ts">
const features = [
'工单列表与状态筛选',
'工单会话详情与回复',
'关闭工单与处理记录',
]
</script>
<template>
<section class="tickets-placeholder">
<div class="placeholder-copy">
<p>Tickets</p>
<h1>工单管理将在下一步补齐</h1>
<span>本轮先把导航和路由结构铺平避免后续再改后台信息架构</span>
</div>
<div class="placeholder-card">
<strong>预留能力</strong>
<ul>
<li v-for="item in features" :key="item">{{ item }}</li>
</ul>
</div>
</section>
</template>
<style scoped>
.tickets-placeholder {
display: grid;
gap: 20px;
padding: 32px;
border-radius: 26px;
background: #ffffff;
box-shadow: var(--xboard-shadow);
}
.placeholder-copy {
display: grid;
gap: 8px;
}
.placeholder-copy p {
font-size: 11px;
letter-spacing: 0.24em;
text-transform: uppercase;
color: var(--xboard-text-muted);
}
.placeholder-copy h1 {
font-size: clamp(30px, 5vw, 44px);
line-height: 1.1;
letter-spacing: -0.28px;
}
.placeholder-copy span {
color: var(--xboard-text-secondary);
max-width: 620px;
line-height: 1.47;
}
.placeholder-card {
padding: 22px 24px;
border-radius: 22px;
background: #f5f5f7;
}
.placeholder-card strong {
display: block;
margin-bottom: 12px;
}
.placeholder-card ul {
display: grid;
gap: 8px;
padding-left: 18px;
color: var(--xboard-text-secondary);
}
</style>
@@ -0,0 +1,322 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { createUser, fetchUsers, updateUser } from '@/api/admin'
import type { AdminPlanOption, AdminUserListItem } from '@/types/api'
import {
COMMISSION_TYPE_OPTIONS,
buildUserFilters,
createEmptyUserForm,
splitEmailAddress,
toUserFormModel,
toUserUpdatePayload,
type UserFormModel,
} from '@/utils/users'
const props = defineProps<{
visible: boolean
mode: 'create' | 'edit'
user?: AdminUserListItem | null
plans: AdminPlanOption[]
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
success: [message: string]
}>()
const formRef = ref<FormInstance>()
const submitting = ref(false)
const form = reactive<UserFormModel>(createEmptyUserForm())
const drawerTitle = computed(() => props.mode === 'create' ? '创建用户' : '编辑用户')
const rules = computed<FormRules<UserFormModel>>(() => ({
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入有效邮箱', trigger: ['blur', 'change'] },
],
password: props.mode === 'create'
? [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 8, message: '密码至少 8 位', trigger: 'blur' },
]
: [{ min: 8, message: '密码至少 8 位', trigger: 'blur' }],
}))
function closeDrawer() {
emit('update:visible', false)
}
function syncForm() {
Object.assign(form, toUserFormModel(props.user))
}
async function handleSubmit() {
const instance = formRef.value
if (!instance) {
return
}
const valid = await instance.validate().catch(() => false)
if (!valid) {
return
}
submitting.value = true
try {
if (props.mode === 'create') {
if (!splitEmailAddress(form.email)) {
throw new Error('请输入有效的邮箱地址')
}
await createUser({
email: form.email,
password: form.password,
plan_id: form.planId,
expired_at: form.expiredAt,
})
const created = await fetchUsers({
current: 1,
pageSize: 1,
filter: buildUserFilters(form.email, 'all', 'all'),
})
const createdUser = created.data.find((item) => item.email === form.email)
if (!createdUser) {
throw new Error('用户已创建,但未能回查到新记录')
}
await updateUser(toUserUpdatePayload({
...form,
id: createdUser.id,
password: '',
}))
ElMessage.success('用户已创建')
emit('success', '用户已创建')
closeDrawer()
return
}
await updateUser(toUserUpdatePayload(form))
ElMessage.success('用户资料已更新')
emit('success', '用户资料已更新')
closeDrawer()
} finally {
submitting.value = false
}
}
watch(
() => [props.visible, props.user, props.mode],
([visible]) => {
if (!visible) {
return
}
syncForm()
formRef.value?.clearValidate()
},
{ immediate: true },
)
</script>
<template>
<ElDrawer
:model-value="props.visible"
:title="drawerTitle"
size="min(520px, 100vw)"
class="user-form-drawer"
destroy-on-close
@close="closeDrawer"
@update:model-value="emit('update:visible', $event)"
>
<div class="drawer-shell">
<div class="drawer-copy">
<p>用户管理</p>
<h2>{{ drawerTitle }}</h2>
<span>表单字段与后端现有用户接口保持一致</span>
</div>
<ElForm
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
class="drawer-form"
>
<div class="drawer-grid drawer-grid--single">
<ElFormItem label="邮箱" prop="email">
<ElInput v-model="form.email" placeholder="请输入邮箱" />
</ElFormItem>
<ElFormItem label="密码" prop="password">
<ElInput
v-model="form.password"
type="password"
show-password
:placeholder="props.mode === 'create' ? '创建时必填' : '留空则不修改'"
/>
</ElFormItem>
</div>
<div class="drawer-grid">
<ElFormItem label="余额">
<ElInputNumber v-model="form.balance" :min="0" :precision="2" :controls="false" />
</ElFormItem>
<ElFormItem label="佣金余额">
<ElInputNumber v-model="form.commissionBalance" :min="0" :precision="2" :controls="false" />
</ElFormItem>
<ElFormItem label="上传">
<ElInputNumber v-model="form.uploadGb" :min="0" :precision="2" :controls="false" />
</ElFormItem>
<ElFormItem label="下载">
<ElInputNumber v-model="form.downloadGb" :min="0" :precision="2" :controls="false" />
</ElFormItem>
<ElFormItem label="总流量">
<ElInputNumber v-model="form.totalTrafficGb" :min="0" :precision="2" :controls="false" />
</ElFormItem>
<ElFormItem label="到期时间">
<ElDatePicker
v-model="form.expiredAt"
type="datetime"
value-format="X"
placeholder="长期有效"
style="width: 100%"
/>
</ElFormItem>
<ElFormItem label="订阅计划">
<ElSelect v-model="form.planId" clearable placeholder="请选择订阅">
<ElOption
v-for="plan in props.plans"
:key="plan.id"
:label="plan.name"
:value="plan.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="用户状态">
<ElSelect v-model="form.banned" placeholder="请选择状态">
<ElOption :value="false" label="正常" />
<ElOption :value="true" label="封禁" />
</ElSelect>
</ElFormItem>
<ElFormItem label="佣金类型">
<ElSelect v-model="form.commissionType" placeholder="请选择类型">
<ElOption
v-for="option in COMMISSION_TYPE_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="推荐返利比例">
<ElInputNumber v-model="form.commissionRate" :min="0" :max="100" :controls="false" />
</ElFormItem>
<ElFormItem label="专属折扣比例">
<ElInputNumber v-model="form.discount" :min="0" :max="100" :controls="false" />
</ElFormItem>
<ElFormItem label="限速">
<ElInputNumber v-model="form.speedLimit" :min="0" :controls="false" />
</ElFormItem>
<ElFormItem label="设备限制">
<ElInputNumber v-model="form.deviceLimit" :min="0" :controls="false" />
</ElFormItem>
<ElFormItem label="邀请人邮箱">
<ElInput v-model="form.inviteUserEmail" placeholder="请输入邀请人邮箱" />
</ElFormItem>
</div>
<div class="drawer-grid drawer-grid--toggles">
<ElFormItem label="是否管理员">
<ElSwitch v-model="form.isAdmin" />
</ElFormItem>
<ElFormItem label="是否员工">
<ElSwitch v-model="form.isStaff" />
</ElFormItem>
</div>
<ElFormItem label="备注">
<ElInput
v-model="form.remarks"
type="textarea"
:rows="4"
placeholder="请输入备注信息"
/>
</ElFormItem>
</ElForm>
</div>
<template #footer>
<div class="drawer-actions">
<ElButton @click="closeDrawer">取消</ElButton>
<ElButton type="primary" :loading="submitting" @click="handleSubmit">
{{ props.mode === 'create' ? '提交创建' : '保存修改' }}
</ElButton>
</div>
</template>
</ElDrawer>
</template>
<style scoped>
.drawer-shell {
display: grid;
gap: 20px;
}
.drawer-copy {
display: grid;
gap: 4px;
}
.drawer-copy p {
font-size: 12px;
color: var(--xboard-text-muted);
letter-spacing: 0.18em;
text-transform: uppercase;
}
.drawer-copy h2 {
font-size: 30px;
line-height: 1.08;
color: var(--xboard-text-strong);
}
.drawer-copy span {
color: var(--xboard-text-secondary);
line-height: 1.47;
}
.drawer-form {
display: grid;
gap: 12px;
}
.drawer-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 16px;
}
.drawer-grid--single,
.drawer-grid--toggles {
grid-template-columns: 1fr;
}
.drawer-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
width: 100%;
}
@media (max-width: 767px) {
.drawer-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,478 @@
<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 UserFormDrawer from './UserFormDrawer.vue'
type DrawerMode = 'create' | 'edit'
type UserAction = 'edit' | 'copy' | 'reset-secret' | '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('用户管理页面初始化失败')
})
})
</script>
<template>
<div class="users-page">
<section class="users-hero">
<div class="users-copy">
<p class="users-kicker">Users</p>
<h1>用户管理工作台</h1>
<span>用一页完成搜索筛选编辑与账户维护保留 Apple 风格的轻量信息层次</span>
</div>
<div class="hero-stats">
<article v-for="item in pageStats" :key="item.label">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</article>
</div>
</section>
<section class="table-shell">
<header class="table-toolbar">
<div class="toolbar-fields">
<ElInput
v-model="keyword"
clearable
placeholder="搜索用户邮箱..."
class="toolbar-input"
@keyup.enter="handleSearch"
>
<template #prefix>
<ElIcon><Search /></ElIcon>
</template>
</ElInput>
<ElSelect v-model="statusFilter" class="toolbar-select" placeholder="用户状态">
<ElOption label="全部状态" value="all" />
<ElOption label="正常" value="active" />
<ElOption label="封禁" value="banned" />
</ElSelect>
<ElSelect
v-model="planFilter"
class="toolbar-select"
:loading="plansLoading"
placeholder="订阅计划"
>
<ElOption label="全部订阅" value="all" />
<ElOption
v-for="plan in plans"
:key="plan.id"
:label="plan.name"
:value="String(plan.id)"
/>
</ElSelect>
</div>
<div class="toolbar-actions">
<ElButton @click="handleReset">
<ElIcon><RefreshRight /></ElIcon>
重置筛选
</ElButton>
<ElButton type="primary" @click="openCreateDrawer">
<ElIcon><Plus /></ElIcon>
创建用户
</ElButton>
</div>
</header>
<ElTable :data="users" v-loading="loading" class="users-table" row-key="id">
<ElTableColumn prop="id" label="ID" width="92" />
<ElTableColumn label="邮箱" min-width="220">
<template #default="{ row }">
<div class="email-cell">
<strong>{{ row.email }}</strong>
<span>{{ row.group?.name || '未分组' }}</span>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="状态" width="108">
<template #default="{ row }">
<ElTag :type="getUserStatusMeta(row).type" effect="plain" round>
{{ getUserStatusMeta(row).label }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="订阅" min-width="170">
<template #default="{ row }">
<div class="stack-cell">
<strong>{{ row.plan?.name || '无订阅' }}</strong>
<span>{{ row.device_limit ? `设备限制 ${row.device_limit}` : '未设设备限制' }}</span>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="已用流量" min-width="152">
<template #default="{ row }">
<div class="traffic-cell">
<strong>{{ formatTraffic(row.total_used) }}</strong>
<ElProgress
:percentage="getUserUsagePercent(row)"
:stroke-width="6"
:show-text="false"
color="#0071e3"
/>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="总流量" width="120">
<template #default="{ row }">
{{ formatTraffic(row.transfer_enable) }}
</template>
</ElTableColumn>
<ElTableColumn label="余额" width="118">
<template #default="{ row }">
¥{{ Number(row.balance || 0).toFixed(2) }}
</template>
</ElTableColumn>
<ElTableColumn label="到期时间" width="140">
<template #default="{ row }">
{{ row.expired_at ? formatDateTime(row.expired_at) : '长期有效' }}
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="104" fixed="right">
<template #default="{ row }">
<ElDropdown trigger="click" @command="(command) => handleAction(command as UserAction, row)">
<ElButton text class="action-trigger">
<ElIcon><MoreFilled /></ElIcon>
</ElButton>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem command="edit">编辑</ElDropdownItem>
<ElDropdownItem command="copy">复制订阅 URL</ElDropdownItem>
<ElDropdownItem command="reset-secret">重置 UUID 及订阅 URL</ElDropdownItem>
<ElDropdownItem command="toggle-ban">
{{ row.banned ? '恢复正常' : '封禁用户' }}
</ElDropdownItem>
<ElDropdownItem command="delete" divided>删除</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</template>
</ElTableColumn>
</ElTable>
<footer class="table-footer">
<span>已加载 {{ users.length }} {{ total }} </span>
<ElPagination
v-model:current-page="current"
v-model:page-size="pageSize"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next"
:total="total"
background
/>
</footer>
</section>
<UserFormDrawer
v-model:visible="drawerVisible"
:mode="drawerMode"
:user="activeUser"
:plans="plans"
@success="() => loadUsers()"
/>
</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>