909 lines
25 KiB
Vue
909 lines
25 KiB
Vue
<script setup>
|
||
import { ref, computed, onMounted, watch } from 'vue'
|
||
import { message, Modal } from 'ant-design-vue'
|
||
import { PlusOutlined, EditOutlined, DeleteOutlined, TeamOutlined, SearchOutlined } from '@ant-design/icons-vue'
|
||
import api from '../api'
|
||
import { useAuthStore } from '../stores/auth'
|
||
import { useIsMobile } from '../composables/useIsMobile'
|
||
|
||
const isMobile = useIsMobile()
|
||
const modalWidth = computed(() => (isMobile.value ? 'calc(100vw - 32px)' : 520))
|
||
|
||
const pageCurrent = ref(1)
|
||
const pageSize = ref(10)
|
||
const searchKeyword = ref('')
|
||
|
||
const FIELD_LABELS = {
|
||
username: '用户名',
|
||
password: '密码',
|
||
display_name: '显示名称',
|
||
email: '邮箱',
|
||
role: '角色',
|
||
max_accounts: '可添加抖音账号数',
|
||
is_active: '账号状态',
|
||
email_verified: '邮箱验证状态'
|
||
}
|
||
|
||
/** FastAPI 422 detail 可能是字符串,也可能是校验错误对象数组。 */
|
||
const formatApiError = (error, fallback = '操作失败') => {
|
||
const detail = error?.response?.data?.detail
|
||
if (detail == null || detail === '') {
|
||
return error?.message || fallback
|
||
}
|
||
if (typeof detail === 'string') return detail
|
||
if (Array.isArray(detail)) {
|
||
const parts = detail.map((item) => {
|
||
if (typeof item === 'string') return item
|
||
if (!item || typeof item !== 'object') return String(item)
|
||
const rawField = Array.isArray(item.loc)
|
||
? item.loc.filter((part) => part !== 'body' && part !== 'query').join('.')
|
||
: ''
|
||
const field = FIELD_LABELS[rawField] || rawField
|
||
let msg = item.msg || item.message || JSON.stringify(item)
|
||
if (/at least 2 characters/i.test(msg)) msg = '至少 2 个字符'
|
||
else if (/at least 6 characters/i.test(msg)) msg = '至少 6 位'
|
||
else if (/valid email/i.test(msg)) msg = '邮箱格式不正确'
|
||
return field ? `${field}:${msg}` : msg
|
||
}).filter(Boolean)
|
||
return parts.length ? parts.join(';') : fallback
|
||
}
|
||
if (typeof detail === 'object') {
|
||
return detail.msg || detail.message || JSON.stringify(detail)
|
||
}
|
||
return String(detail)
|
||
}
|
||
|
||
const auth = useAuthStore()
|
||
const users = ref([])
|
||
const loading = ref(false)
|
||
const modalVisible = ref(false)
|
||
const modalTitle = ref('新增用户')
|
||
const editingId = ref(null)
|
||
|
||
const userForm = ref({
|
||
username: '',
|
||
password: '',
|
||
display_name: '',
|
||
email: '',
|
||
email_verified: true,
|
||
role: 'operator',
|
||
is_active: true,
|
||
max_accounts: 3
|
||
})
|
||
|
||
const defaultRegisterMaxAccounts = ref(3)
|
||
const emailVerificationRequired = ref(true)
|
||
const emailBindingRequired = ref(false)
|
||
|
||
const roleOptions = ref([
|
||
{ value: 'admin', label: '管理员', is_admin: true },
|
||
{ value: 'operator', label: '运营', is_admin: false },
|
||
{ value: 'viewer', label: '只读', is_admin: false }
|
||
])
|
||
|
||
const assignableRoleOptions = computed(() => {
|
||
if (auth.isAdmin) return roleOptions.value
|
||
return roleOptions.value.filter((r) => !r.is_admin)
|
||
})
|
||
|
||
const isAdminRole = computed(() => {
|
||
const hit = roleOptions.value.find((r) => r.value === userForm.value.role)
|
||
return !!(hit?.is_admin || userForm.value.role === 'admin')
|
||
})
|
||
const emailRequiredForRole = computed(
|
||
() => emailBindingRequired.value && !isAdminRole.value
|
||
)
|
||
|
||
const hasEmail = computed(() => !!userForm.value.email?.trim())
|
||
|
||
const getRoleLabel = (role) =>
|
||
roleOptions.value.find((r) => r.value === role)?.label || role
|
||
|
||
const filteredUsers = computed(() => {
|
||
const keyword = searchKeyword.value.trim().toLowerCase()
|
||
if (!keyword) return users.value
|
||
return users.value.filter((user) => {
|
||
const roleLabel = (getRoleLabel(user.role) || '').toLowerCase()
|
||
const haystack = [
|
||
String(user.id ?? ''),
|
||
user.username || '',
|
||
user.display_name || '',
|
||
user.email || '',
|
||
user.role || '',
|
||
roleLabel
|
||
].join(' ').toLowerCase()
|
||
return haystack.includes(keyword)
|
||
})
|
||
})
|
||
|
||
const paginatedUsers = computed(() => {
|
||
const start = (pageCurrent.value - 1) * pageSize.value
|
||
return filteredUsers.value.slice(start, start + pageSize.value)
|
||
})
|
||
|
||
const paginationConfig = computed(() => ({
|
||
current: pageCurrent.value,
|
||
pageSize: pageSize.value,
|
||
total: filteredUsers.value.length,
|
||
showSizeChanger: !isMobile.value,
|
||
pageSizeOptions: ['10', '20', '50'],
|
||
showTotal: (total) => `共 ${total} 条`,
|
||
size: isMobile.value ? 'small' : 'default',
|
||
onChange: (page, size) => {
|
||
pageCurrent.value = page
|
||
pageSize.value = size
|
||
}
|
||
}))
|
||
|
||
const fetchUsers = async () => {
|
||
loading.value = true
|
||
try {
|
||
const res = await api.get('/users')
|
||
users.value = res.data
|
||
} catch (error) {
|
||
message.error(formatApiError(error, '获取用户列表失败'))
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
const fetchRoles = async () => {
|
||
try {
|
||
const res = await api.get('/auth/roles')
|
||
const list = res.data.roles || []
|
||
if (list.length) {
|
||
roleOptions.value = list.map((r) => ({
|
||
value: r.value,
|
||
label: r.label,
|
||
is_admin: !!r.is_admin
|
||
}))
|
||
}
|
||
} catch {
|
||
// keep defaults
|
||
}
|
||
}
|
||
|
||
const fetchDefaultMaxAccounts = async () => {
|
||
try {
|
||
const res = await api.get('/settings')
|
||
defaultRegisterMaxAccounts.value = res.data.default_register_max_accounts ?? 3
|
||
emailVerificationRequired.value = res.data.email_verification_required !== false
|
||
emailBindingRequired.value = !!res.data.email_binding_required
|
||
} catch {
|
||
defaultRegisterMaxAccounts.value = 3
|
||
emailVerificationRequired.value = true
|
||
emailBindingRequired.value = false
|
||
}
|
||
}
|
||
|
||
const openAdd = () => {
|
||
editingId.value = null
|
||
modalTitle.value = '新增用户'
|
||
userForm.value = {
|
||
username: '',
|
||
password: '',
|
||
display_name: '',
|
||
email: '',
|
||
email_verified: true,
|
||
role: 'operator',
|
||
is_active: true,
|
||
max_accounts: defaultRegisterMaxAccounts.value
|
||
}
|
||
modalVisible.value = true
|
||
}
|
||
|
||
const openEdit = (record) => {
|
||
editingId.value = record.id
|
||
modalTitle.value = '编辑用户'
|
||
userForm.value = {
|
||
username: record.username,
|
||
password: '',
|
||
display_name: record.display_name || record.username,
|
||
email: record.email || '',
|
||
email_verified: !!record.email_verified,
|
||
role: record.role,
|
||
is_active: record.is_active,
|
||
max_accounts: isUnlimitedQuota(record)
|
||
? defaultRegisterMaxAccounts.value
|
||
: resolveAccountLimit(record)
|
||
}
|
||
modalVisible.value = true
|
||
}
|
||
|
||
const onEmailChange = () => {
|
||
if (!userForm.value.email?.trim()) {
|
||
userForm.value.email_verified = true
|
||
}
|
||
}
|
||
|
||
const handleSave = async () => {
|
||
const email = userForm.value.email?.trim() || null
|
||
const username = userForm.value.username.trim()
|
||
const password = userForm.value.password || ''
|
||
|
||
if (emailRequiredForRole.value && !email) {
|
||
message.warning('当前系统要求非管理员用户必须绑定邮箱')
|
||
return
|
||
}
|
||
|
||
if (!editingId.value) {
|
||
if (!username || !password) {
|
||
message.warning('请填写用户名和密码')
|
||
return
|
||
}
|
||
if (username.length < 2) {
|
||
message.warning('用户名至少 2 个字符')
|
||
return
|
||
}
|
||
if (password.length < 6) {
|
||
message.warning('密码至少 6 位')
|
||
return
|
||
}
|
||
try {
|
||
await api.post('/users', {
|
||
username,
|
||
password,
|
||
display_name: userForm.value.display_name || username,
|
||
role: userForm.value.role,
|
||
email: email || undefined,
|
||
email_verified: email ? userForm.value.email_verified : true,
|
||
max_accounts: userForm.value.max_accounts
|
||
})
|
||
message.success('用户创建成功')
|
||
modalVisible.value = false
|
||
fetchUsers()
|
||
} catch (error) {
|
||
message.error(formatApiError(error, '创建失败'))
|
||
}
|
||
return
|
||
}
|
||
|
||
if (password && password.length < 6) {
|
||
message.warning('新密码至少 6 位')
|
||
return
|
||
}
|
||
|
||
const payload = {
|
||
display_name: userForm.value.display_name,
|
||
role: userForm.value.role,
|
||
is_active: userForm.value.is_active,
|
||
email: email
|
||
}
|
||
if (email) {
|
||
payload.email_verified = userForm.value.email_verified
|
||
}
|
||
if (password) {
|
||
payload.password = password
|
||
}
|
||
if (!isAdminRole.value) {
|
||
payload.max_accounts = userForm.value.max_accounts
|
||
}
|
||
try {
|
||
await api.put(`/users/${editingId.value}`, payload)
|
||
message.success('用户更新成功')
|
||
modalVisible.value = false
|
||
fetchUsers()
|
||
if (auth.user?.id === editingId.value) {
|
||
await auth.fetchMe()
|
||
}
|
||
} catch (error) {
|
||
message.error(formatApiError(error, '更新失败'))
|
||
}
|
||
}
|
||
|
||
const handleDelete = (record) => {
|
||
Modal.confirm({
|
||
title: `确定删除用户「${record.username}」吗?`,
|
||
okType: 'danger',
|
||
onOk: async () => {
|
||
try {
|
||
await api.delete(`/users/${record.id}`)
|
||
message.success('已删除')
|
||
fetchUsers()
|
||
} catch (error) {
|
||
message.error(formatApiError(error, '删除失败'))
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
const roleTagColor = (role) => {
|
||
if (role === 'admin') return 'purple'
|
||
if (role === 'operator') return 'geekblue'
|
||
return 'default'
|
||
}
|
||
|
||
const emailVerifyLabel = (record) => {
|
||
if (!record.email) {
|
||
if (record.role === 'admin') return '无需验证'
|
||
if (emailBindingRequired.value) return '需绑定邮箱'
|
||
return '无需验证'
|
||
}
|
||
if (!emailVerificationRequired.value) {
|
||
return record.email_verified ? '已验证' : '未验证(可登录)'
|
||
}
|
||
return record.email_verified ? '已验证' : '未验证'
|
||
}
|
||
|
||
const emailVerifyColor = (record) => {
|
||
if (!record.email) {
|
||
if (record.role === 'admin') return 'default'
|
||
if (emailBindingRequired.value) return 'gold'
|
||
return 'default'
|
||
}
|
||
if (!emailVerificationRequired.value && !record.email_verified) return 'geekblue'
|
||
return record.email_verified ? 'green' : 'gold'
|
||
}
|
||
|
||
const isUnlimitedQuota = (record) =>
|
||
!!(record.is_admin || record.role === 'admin' ||
|
||
roleOptions.value.find((r) => r.value === record.role)?.is_admin)
|
||
|
||
const resolveAccountLimit = (record) => {
|
||
if (isUnlimitedQuota(record)) return null
|
||
const raw = record.max_accounts
|
||
if (raw == null || raw < 0) return 3
|
||
return raw
|
||
}
|
||
|
||
const accountQuotaLabel = (record) => {
|
||
const total = record.account_count ?? 0
|
||
const active = record.active_account_count ?? total
|
||
const disabled = record.disabled_account_count ?? Math.max(0, total - active)
|
||
|
||
if (isUnlimitedQuota(record)) {
|
||
return total > 0 ? `${total} 个 / 不限` : '0 个 / 不限'
|
||
}
|
||
|
||
const limit = resolveAccountLimit(record)
|
||
if (disabled > 0) {
|
||
return `${active}/${limit}(停用 ${disabled})`
|
||
}
|
||
return `${total}/${limit}`
|
||
}
|
||
|
||
watch([filteredUsers, pageSize], () => {
|
||
const maxPage = Math.max(1, Math.ceil(filteredUsers.value.length / pageSize.value))
|
||
if (pageCurrent.value > maxPage) {
|
||
pageCurrent.value = maxPage
|
||
}
|
||
})
|
||
|
||
watch(searchKeyword, () => {
|
||
pageCurrent.value = 1
|
||
})
|
||
|
||
onMounted(() => {
|
||
fetchRoles()
|
||
fetchDefaultMaxAccounts()
|
||
fetchUsers()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="users-page">
|
||
<div class="page-header glass-card">
|
||
<div class="page-header-main">
|
||
<h2 class="page-title">
|
||
<TeamOutlined class="page-title-icon" />
|
||
用户管理
|
||
</h2>
|
||
<p class="subtitle">创建与管理后台用户并分配角色;非管理员仅能查看自己创建的用户</p>
|
||
</div>
|
||
<a-button type="primary" class="gradient-btn add-user-btn" @click="openAdd">
|
||
<template #icon><PlusOutlined /></template>
|
||
新增用户
|
||
</a-button>
|
||
</div>
|
||
|
||
<div class="users-toolbar glass-card">
|
||
<a-input
|
||
v-model:value="searchKeyword"
|
||
allow-clear
|
||
placeholder="搜索用户名、显示名、邮箱、角色、ID"
|
||
class="users-search-input"
|
||
>
|
||
<template #prefix>
|
||
<SearchOutlined />
|
||
</template>
|
||
</a-input>
|
||
<span class="users-toolbar-meta">
|
||
{{ searchKeyword.trim() ? `匹配 ${filteredUsers.length} / 共 ${users.length}` : `共 ${users.length}` }} 个用户
|
||
</span>
|
||
</div>
|
||
|
||
<div v-if="!isMobile" class="glass-card table-card">
|
||
<a-table
|
||
:data-source="paginatedUsers"
|
||
:loading="loading"
|
||
row-key="id"
|
||
:pagination="paginationConfig"
|
||
:scroll="{ x: 960 }"
|
||
>
|
||
<a-table-column title="ID" data-index="id" key="id" :width="72" />
|
||
<a-table-column title="用户名" data-index="username" key="username" />
|
||
<a-table-column title="邮箱" key="email" :width="220">
|
||
<template #default="{ record }">
|
||
<span :class="{ 'text-muted': !record.email }">{{ record.email || '未绑定' }}</span>
|
||
</template>
|
||
</a-table-column>
|
||
<a-table-column title="邮箱验证" key="email_verified" :width="110">
|
||
<template #default="{ record }">
|
||
<a-tag :color="emailVerifyColor(record)">
|
||
{{ emailVerifyLabel(record) }}
|
||
</a-tag>
|
||
</template>
|
||
</a-table-column>
|
||
<a-table-column title="显示名" data-index="display_name" key="display_name" />
|
||
<a-table-column title="角色" key="role">
|
||
<template #default="{ record }">
|
||
<a-tag :color="roleTagColor(record.role)">
|
||
{{ getRoleLabel(record.role) }}
|
||
</a-tag>
|
||
</template>
|
||
</a-table-column>
|
||
<a-table-column title="托管额度" key="max_accounts" :width="148">
|
||
<template #default="{ record }">
|
||
{{ accountQuotaLabel(record) }}
|
||
</template>
|
||
</a-table-column>
|
||
<a-table-column title="状态" key="is_active">
|
||
<template #default="{ record }">
|
||
<a-tag :color="record.is_active ? 'green' : 'red'">
|
||
{{ record.is_active ? '启用' : '禁用' }}
|
||
</a-tag>
|
||
</template>
|
||
</a-table-column>
|
||
<a-table-column title="创建时间" key="created_at">
|
||
<template #default="{ record }">
|
||
{{ new Date(record.created_at).toLocaleString() }}
|
||
</template>
|
||
</a-table-column>
|
||
<a-table-column title="操作" key="action" width="180px">
|
||
<template #default="{ record }">
|
||
<a-space>
|
||
<a-button type="text" class="edit-btn" @click="openEdit(record)">
|
||
<template #icon><EditOutlined /></template>
|
||
编辑
|
||
</a-button>
|
||
<a-button
|
||
v-if="record.id !== auth.user?.id"
|
||
type="text"
|
||
danger
|
||
@click="handleDelete(record)"
|
||
>
|
||
<template #icon><DeleteOutlined /></template>
|
||
删除
|
||
</a-button>
|
||
</a-space>
|
||
</template>
|
||
</a-table-column>
|
||
</a-table>
|
||
</div>
|
||
|
||
<div v-else class="users-mobile-list">
|
||
<a-spin :spinning="loading">
|
||
<div v-if="paginatedUsers.length" class="user-card-list">
|
||
<div v-for="record in paginatedUsers" :key="record.id" class="user-card glass-card">
|
||
<div class="user-card-head">
|
||
<div>
|
||
<div class="user-card-name">
|
||
{{ record.username }}
|
||
<span class="user-card-id">#{{ record.id }}</span>
|
||
</div>
|
||
<div class="user-card-display">{{ record.display_name || record.username }}</div>
|
||
</div>
|
||
<a-tag :color="roleTagColor(record.role)">
|
||
{{ getRoleLabel(record.role) }}
|
||
</a-tag>
|
||
</div>
|
||
|
||
<div class="user-card-meta">
|
||
<div class="user-card-row">
|
||
<span class="user-card-label">邮箱</span>
|
||
<span :class="{ 'text-muted': !record.email }" class="user-card-value">
|
||
{{ record.email || '未绑定' }}
|
||
</span>
|
||
</div>
|
||
<div class="user-card-row">
|
||
<span class="user-card-label">邮箱验证</span>
|
||
<a-tag :color="emailVerifyColor(record)">
|
||
{{ emailVerifyLabel(record) }}
|
||
</a-tag>
|
||
</div>
|
||
<div class="user-card-row">
|
||
<span class="user-card-label">托管额度</span>
|
||
<span class="user-card-value">{{ accountQuotaLabel(record) }}</span>
|
||
</div>
|
||
<div class="user-card-row">
|
||
<span class="user-card-label">状态</span>
|
||
<a-tag :color="record.is_active ? 'green' : 'red'">
|
||
{{ record.is_active ? '启用' : '禁用' }}
|
||
</a-tag>
|
||
</div>
|
||
<div class="user-card-row">
|
||
<span class="user-card-label">创建时间</span>
|
||
<span class="user-card-value user-card-time">
|
||
{{ new Date(record.created_at).toLocaleString() }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="user-card-actions">
|
||
<a-button type="text" class="edit-btn" @click="openEdit(record)">
|
||
<template #icon><EditOutlined /></template>
|
||
编辑
|
||
</a-button>
|
||
<a-button
|
||
v-if="record.id !== auth.user?.id"
|
||
type="text"
|
||
danger
|
||
@click="handleDelete(record)"
|
||
>
|
||
<template #icon><DeleteOutlined /></template>
|
||
删除
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<a-empty
|
||
v-else
|
||
:description="searchKeyword.trim() ? '未找到匹配用户' : '暂无用户'"
|
||
/>
|
||
</a-spin>
|
||
|
||
<div v-if="filteredUsers.length" class="users-mobile-pagination">
|
||
<a-pagination
|
||
v-model:current="pageCurrent"
|
||
v-model:page-size="pageSize"
|
||
:total="filteredUsers.length"
|
||
:show-size-changer="false"
|
||
size="small"
|
||
:show-total="(total) => `共 ${total} 条`"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<a-modal
|
||
v-model:visible="modalVisible"
|
||
:title="modalTitle"
|
||
:width="modalWidth"
|
||
@ok="handleSave"
|
||
ok-text="保存"
|
||
cancel-text="取消"
|
||
>
|
||
<a-form layout="vertical" class="user-form" style="margin-top: 16px;">
|
||
<a-form-item v-if="!editingId" label="用户名" required>
|
||
<a-input
|
||
v-model:value="userForm.username"
|
||
placeholder="登录用户名,至少 2 个字符"
|
||
:maxlength="50"
|
||
/>
|
||
</a-form-item>
|
||
<a-form-item :label="editingId ? '新密码(留空不修改)' : '密码'" :required="!editingId">
|
||
<a-input-password
|
||
v-model:value="userForm.password"
|
||
placeholder="至少 6 位"
|
||
:maxlength="128"
|
||
/>
|
||
</a-form-item>
|
||
<a-form-item label="显示名称">
|
||
<a-input v-model:value="userForm.display_name" placeholder="界面展示名称" />
|
||
</a-form-item>
|
||
<a-form-item :label="emailRequiredForRole ? '邮箱(必填)' : '邮箱'">
|
||
<a-input
|
||
v-model:value="userForm.email"
|
||
:placeholder="emailRequiredForRole ? '非管理员用户必须绑定邮箱' : '选填,绑定后可用于邮箱验证登录'"
|
||
@change="onEmailChange"
|
||
/>
|
||
</a-form-item>
|
||
<a-form-item v-if="hasEmail" label="邮箱验证状态">
|
||
<a-switch
|
||
v-model:checked="userForm.email_verified"
|
||
checked-children="已验证"
|
||
un-checked-children="未验证"
|
||
/>
|
||
<div class="field-hint">
|
||
<template v-if="emailVerificationRequired">
|
||
设为「已验证」后,用户可直接登录;设为「未验证」则需完成邮箱验证
|
||
</template>
|
||
<template v-else>
|
||
系统已关闭全局邮箱验证,用户未验证也可登录;此处可手动调整验证状态
|
||
</template>
|
||
</div>
|
||
</a-form-item>
|
||
<a-form-item label="角色">
|
||
<a-select v-model:value="userForm.role" :options="assignableRoleOptions" />
|
||
</a-form-item>
|
||
<a-form-item v-if="!isAdminRole" label="可添加抖音账号数">
|
||
<a-input-number
|
||
v-model:value="userForm.max_accounts"
|
||
:min="0"
|
||
:max="999"
|
||
style="width: 100%;"
|
||
/>
|
||
<div class="field-hint">该用户最多可添加的抖音托管账号数量</div>
|
||
</a-form-item>
|
||
<a-form-item v-else label="可添加抖音账号数">
|
||
<span class="text-muted">管理员不限制</span>
|
||
</a-form-item>
|
||
<a-form-item v-if="editingId" label="账号状态">
|
||
<a-switch v-model:checked="userForm.is_active" checked-children="启用" un-checked-children="禁用" />
|
||
</a-form-item>
|
||
</a-form>
|
||
</a-modal>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.users-page {
|
||
min-width: 0;
|
||
}
|
||
|
||
.page-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 16px;
|
||
padding: 24px;
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.page-header-main {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.page-title {
|
||
margin: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
font-size: 1.25rem;
|
||
}
|
||
|
||
.page-title-icon {
|
||
color: #c084fc;
|
||
}
|
||
|
||
.subtitle {
|
||
margin: 8px 0 0;
|
||
color: var(--text-secondary);
|
||
font-size: 0.9rem;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.users-toolbar:hover,
|
||
.table-card:hover {
|
||
transform: none;
|
||
}
|
||
|
||
.users-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
padding: 16px 20px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.users-search-input {
|
||
flex: 1;
|
||
min-width: 220px;
|
||
max-width: 420px;
|
||
}
|
||
|
||
.users-toolbar-meta {
|
||
color: var(--text-secondary);
|
||
font-size: 0.88rem;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.gradient-btn {
|
||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--accent-pink) 100%) !important;
|
||
border: none !important;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.table-card {
|
||
padding: 0;
|
||
overflow: hidden;
|
||
border-radius: 12px;
|
||
}
|
||
|
||
.table-card :deep(.ant-table-wrapper) {
|
||
overflow-x: auto;
|
||
}
|
||
|
||
.users-mobile-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
|
||
.user-card-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.user-card {
|
||
padding: 16px;
|
||
border-radius: 12px;
|
||
background: hsla(230, 20%, 12%, 0.85);
|
||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||
}
|
||
|
||
.user-card:hover {
|
||
transform: none;
|
||
background: hsla(230, 20%, 14%, 0.9);
|
||
border-color: rgba(192, 132, 252, 0.2);
|
||
}
|
||
|
||
.users-page :deep(.ant-tag) {
|
||
margin: 0;
|
||
border-width: 1px;
|
||
border-style: solid;
|
||
}
|
||
|
||
.users-page :deep(.ant-table) {
|
||
background: transparent !important;
|
||
}
|
||
|
||
.users-page :deep(.ant-table-thead > tr > th) {
|
||
background: rgba(255, 255, 255, 0.05) !important;
|
||
color: #d1d5db !important;
|
||
border-bottom: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.users-page :deep(.ant-table-tbody > tr > td) {
|
||
background: transparent !important;
|
||
border-bottom: 1px solid rgba(255, 255, 255, 0.08) !important;
|
||
color: #f3f4f6;
|
||
}
|
||
|
||
.users-page :deep(.ant-table-tbody > tr:hover > td) {
|
||
background: rgba(170, 59, 255, 0.08) !important;
|
||
}
|
||
|
||
.user-card-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
gap: 12px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.user-card-name {
|
||
font-size: 1rem;
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
word-break: break-all;
|
||
}
|
||
|
||
.user-card-id {
|
||
margin-left: 6px;
|
||
font-size: 0.82rem;
|
||
font-weight: 500;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.user-card-display {
|
||
margin-top: 4px;
|
||
font-size: 0.85rem;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.user-card-meta {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
padding: 12px 0;
|
||
border-top: 1px solid var(--border-light);
|
||
border-bottom: 1px solid var(--border-light);
|
||
}
|
||
|
||
.user-card-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
gap: 12px;
|
||
}
|
||
|
||
.user-card-label {
|
||
flex-shrink: 0;
|
||
font-size: 0.82rem;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.user-card-value {
|
||
text-align: right;
|
||
font-size: 0.88rem;
|
||
color: var(--text-primary);
|
||
word-break: break-all;
|
||
}
|
||
|
||
.user-card-time {
|
||
font-size: 0.8rem;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.user-card-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 4px;
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.edit-btn {
|
||
color: #d8b4fe !important;
|
||
}
|
||
|
||
.edit-btn:hover {
|
||
color: #f3e8ff !important;
|
||
}
|
||
|
||
.users-page :deep(.ant-btn-dangerous.ant-btn-text) {
|
||
color: #fca5a5 !important;
|
||
}
|
||
|
||
.users-page :deep(.ant-btn-dangerous.ant-btn-text:hover) {
|
||
color: #fecaca !important;
|
||
background: rgba(239, 68, 68, 0.12) !important;
|
||
}
|
||
|
||
.users-mobile-pagination {
|
||
display: flex;
|
||
justify-content: center;
|
||
padding-bottom: 8px;
|
||
}
|
||
|
||
.text-muted {
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.field-hint {
|
||
margin-top: 6px;
|
||
font-size: 0.8rem;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.user-form :deep(.ant-form-item-label > label) {
|
||
color: var(--text-secondary) !important;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.page-header {
|
||
padding: 16px;
|
||
margin-bottom: 16px;
|
||
align-items: stretch;
|
||
}
|
||
|
||
.page-title {
|
||
font-size: 1.1rem;
|
||
}
|
||
|
||
.subtitle {
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.add-user-btn {
|
||
width: 100%;
|
||
}
|
||
|
||
.users-toolbar {
|
||
padding: 12px 16px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.users-search-input {
|
||
max-width: none;
|
||
width: 100%;
|
||
}
|
||
}
|
||
</style>
|