You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1466 lines
34 KiB
1466 lines
34 KiB
<script setup lang="ts">
|
|
interface UserRow {
|
|
id: number
|
|
username: string
|
|
email: string | null
|
|
nickname: string | null
|
|
avatar: string | null
|
|
role: string
|
|
status: string
|
|
createdAt: string | null
|
|
}
|
|
|
|
// Filter state
|
|
const statusFilter = ref<'' | 'active' | 'disabled'>('')
|
|
const roleFilter = ref<'' | 'admin' | 'user'>('')
|
|
const searchQuery = ref('')
|
|
const currentPage = ref(1)
|
|
const pageSize = ref(10)
|
|
const sortBy = ref<'createdAt' | 'username'>('createdAt')
|
|
const sortOrder = ref<'asc' | 'desc'>('desc')
|
|
|
|
// Fetch users
|
|
const { data, refresh, pending } = await useHttpFetch('/api/users', {
|
|
query: computed(() => ({
|
|
page: currentPage.value,
|
|
pageSize: pageSize.value,
|
|
search: searchQuery.value || undefined,
|
|
status: statusFilter.value || undefined,
|
|
role: roleFilter.value || undefined,
|
|
sortBy: sortBy.value,
|
|
sortOrder: sortOrder.value,
|
|
})),
|
|
watch: [currentPage, pageSize, searchQuery, statusFilter, roleFilter, sortBy, sortOrder],
|
|
})
|
|
|
|
const userList = computed<UserRow[]>(() => (data.value as any)?.list ?? [])
|
|
const totalUsers = computed(() => (data.value as any)?.total ?? 0)
|
|
const totalPages = computed(() => (data.value as any)?.totalPages ?? 1)
|
|
|
|
// Selection
|
|
const selectedIds = ref<Set<number>>(new Set())
|
|
const isAllSelected = computed(() => userList.value.length > 0 && userList.value.every(u => selectedIds.value.has(u.id)))
|
|
|
|
function toggleSelectAll() {
|
|
if (isAllSelected.value) {
|
|
selectedIds.value = new Set()
|
|
} else {
|
|
selectedIds.value = new Set(userList.value.map(u => u.id))
|
|
}
|
|
}
|
|
|
|
function toggleSelect(id: number) {
|
|
const newSet = new Set(selectedIds.value)
|
|
if (newSet.has(id)) {
|
|
newSet.delete(id)
|
|
} else {
|
|
newSet.add(id)
|
|
}
|
|
selectedIds.value = newSet
|
|
}
|
|
|
|
// Sorting
|
|
function toggleSort(column: 'createdAt' | 'username') {
|
|
if (sortBy.value === column) {
|
|
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
|
|
} else {
|
|
sortBy.value = column
|
|
sortOrder.value = 'asc'
|
|
}
|
|
currentPage.value = 1
|
|
}
|
|
|
|
// Create modal
|
|
const showCreateModal = ref(false)
|
|
const createForm = ref({
|
|
username: '',
|
|
password: '',
|
|
email: '',
|
|
role: 'user' as 'user' | 'admin',
|
|
})
|
|
const createLoading = ref(false)
|
|
const createError = ref('')
|
|
|
|
// Detail drawer
|
|
const showDrawer = ref(false)
|
|
const editingUser = ref<UserRow | null>(null)
|
|
const drawerForm = ref({
|
|
nickname: '',
|
|
email: '',
|
|
role: 'user' as 'user' | 'admin',
|
|
status: 'active' as 'active' | 'disabled',
|
|
})
|
|
const drawerLoading = ref(false)
|
|
const drawerError = ref('')
|
|
|
|
// Confirm dialog
|
|
const showConfirmDialog = ref(false)
|
|
const confirmLoading = ref(false)
|
|
|
|
// Toast helper
|
|
function showToast(message: string, type: 'success' | 'error' = 'success') {
|
|
if (import.meta.client) {
|
|
const event = new CustomEvent('toast', { detail: { message, type } })
|
|
window.dispatchEvent(event)
|
|
}
|
|
}
|
|
|
|
// Actions
|
|
async function handleBatchEnable() {
|
|
if (selectedIds.value.size === 0) return
|
|
confirmLoading.value = true
|
|
try {
|
|
await $fetch('/api/users/batch', {
|
|
method: 'POST',
|
|
body: { action: 'enable', ids: Array.from(selectedIds.value) },
|
|
})
|
|
selectedIds.value = new Set()
|
|
await refresh()
|
|
showToast('已启用成功')
|
|
} catch (err: any) {
|
|
showToast(err?.data?.statusMessage || '启用失败', 'error')
|
|
} finally {
|
|
confirmLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function handleBatchDisable() {
|
|
if (selectedIds.value.size === 0) return
|
|
confirmLoading.value = true
|
|
try {
|
|
await $fetch('/api/users/batch', {
|
|
method: 'POST',
|
|
body: { action: 'disable', ids: Array.from(selectedIds.value) },
|
|
})
|
|
selectedIds.value = new Set()
|
|
await refresh()
|
|
showToast('已禁用成功')
|
|
} catch (err: any) {
|
|
showToast(err?.data?.statusMessage || '禁用失败', 'error')
|
|
} finally {
|
|
confirmLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function handleBatchDelete() {
|
|
showConfirmDialog.value = true
|
|
}
|
|
|
|
async function confirmBatchDelete() {
|
|
confirmLoading.value = true
|
|
try {
|
|
await $fetch('/api/users/batch', {
|
|
method: 'POST',
|
|
body: { action: 'delete', ids: Array.from(selectedIds.value) },
|
|
})
|
|
selectedIds.value = new Set()
|
|
showConfirmDialog.value = false
|
|
await refresh()
|
|
showToast('已删除成功')
|
|
} catch (err: any) {
|
|
showToast(err?.data?.statusMessage || '删除失败', 'error')
|
|
} finally {
|
|
confirmLoading.value = false
|
|
}
|
|
}
|
|
|
|
function openDrawer(user: UserRow) {
|
|
editingUser.value = user
|
|
drawerForm.value = {
|
|
nickname: user.nickname || '',
|
|
email: user.email || '',
|
|
role: user.role as 'user' | 'admin',
|
|
status: user.status as 'active' | 'disabled',
|
|
}
|
|
drawerError.value = ''
|
|
showDrawer.value = true
|
|
}
|
|
|
|
function closeDrawer() {
|
|
showDrawer.value = false
|
|
editingUser.value = null
|
|
}
|
|
|
|
async function handleDrawerSave() {
|
|
if (!editingUser.value) return
|
|
drawerLoading.value = true
|
|
drawerError.value = ''
|
|
try {
|
|
await $fetch(`/api/users/${editingUser.value.id}`, {
|
|
method: 'PUT',
|
|
body: {
|
|
nickname: drawerForm.value.nickname,
|
|
email: drawerForm.value.email || undefined,
|
|
role: drawerForm.value.role,
|
|
status: drawerForm.value.status,
|
|
},
|
|
})
|
|
closeDrawer()
|
|
await refresh()
|
|
showToast('保存成功')
|
|
} catch (err: any) {
|
|
drawerError.value = err?.data?.statusMessage || '保存失败'
|
|
} finally {
|
|
drawerLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function handleDelete(id: number) {
|
|
try {
|
|
await $fetch(`/api/users/${id}`, { method: 'DELETE' })
|
|
selectedIds.value = new Set([...selectedIds.value].filter(i => i !== id))
|
|
await refresh()
|
|
showToast('删除成功')
|
|
} catch (err: any) {
|
|
showToast(err?.data?.statusMessage || '删除失败', 'error')
|
|
}
|
|
}
|
|
|
|
function openCreate() {
|
|
createError.value = ''
|
|
createForm.value = { username: '', password: '', email: '', role: 'user' }
|
|
showCreateModal.value = true
|
|
}
|
|
|
|
function closeModal() {
|
|
showCreateModal.value = false
|
|
}
|
|
|
|
async function handleCreate() {
|
|
createError.value = ''
|
|
if (!createForm.value.username || !createForm.value.password) {
|
|
createError.value = '用户名和密码不能为空'
|
|
return
|
|
}
|
|
createLoading.value = true
|
|
try {
|
|
await $fetch('/api/users', {
|
|
method: 'POST',
|
|
body: createForm.value,
|
|
})
|
|
closeModal()
|
|
currentPage.value = 1
|
|
await refresh()
|
|
showToast('创建成功')
|
|
} catch (err: any) {
|
|
createError.value = err?.data?.statusMessage || '创建失败'
|
|
} finally {
|
|
createLoading.value = false
|
|
}
|
|
}
|
|
|
|
function goToPage(page: number) {
|
|
if (page < 1 || page > totalPages.value) return
|
|
currentPage.value = page
|
|
}
|
|
|
|
function formatDate(date: string | null) {
|
|
if (!date) return '-'
|
|
return new Intl.DateTimeFormat('zh-CN', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
}).format(new Date(date))
|
|
}
|
|
|
|
function roleBadge(role: string) {
|
|
const map: Record<string, { label: string; class: string }> = {
|
|
admin: { label: '管理员', class: 'role-admin' },
|
|
user: { label: '用户', class: 'role-user' },
|
|
}
|
|
return map[role] ?? { label: role, class: 'role-user' }
|
|
}
|
|
|
|
function statusBadge(status: string) {
|
|
const map: Record<string, { label: string; class: string }> = {
|
|
active: { label: '正常', class: 'status-active' },
|
|
disabled: { label: '禁用', class: 'status-disabled' },
|
|
}
|
|
return map[status] ?? { label: status, class: 'status-disabled' }
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="users-page">
|
|
<!-- Header with stats -->
|
|
<header class="page-header">
|
|
<div class="header-left">
|
|
<h1 class="page-title">用户管理</h1>
|
|
<div class="stats-grid">
|
|
<div class="stat-card">
|
|
<div class="stat-value">{{ totalUsers }}</div>
|
|
<div class="stat-label">用户总数</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Filter bar -->
|
|
<div class="filter-bar">
|
|
<div class="filter-left">
|
|
<div class="status-tabs">
|
|
<button
|
|
class="status-tab"
|
|
:class="{ active: statusFilter === '' }"
|
|
@click="statusFilter = ''; currentPage = 1"
|
|
>
|
|
全部
|
|
</button>
|
|
<button
|
|
class="status-tab"
|
|
:class="{ active: statusFilter === 'active' }"
|
|
@click="statusFilter = 'active'; currentPage = 1"
|
|
>
|
|
正常
|
|
</button>
|
|
<button
|
|
class="status-tab"
|
|
:class="{ active: statusFilter === 'disabled' }"
|
|
@click="statusFilter = 'disabled'; currentPage = 1"
|
|
>
|
|
禁用
|
|
</button>
|
|
</div>
|
|
|
|
<select
|
|
v-model="roleFilter"
|
|
class="role-select"
|
|
@change="currentPage = 1"
|
|
>
|
|
<option value="">全部角色</option>
|
|
<option value="admin">管理员</option>
|
|
<option value="user">普通用户</option>
|
|
</select>
|
|
|
|
<div class="search-box">
|
|
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
<circle cx="11" cy="11" r="8" />
|
|
<path d="M21 21l-4.35-4.35" stroke-linecap="round" />
|
|
</svg>
|
|
<input
|
|
v-model="searchQuery"
|
|
type="text"
|
|
placeholder="搜索用户名、邮箱"
|
|
class="search-input"
|
|
@keyup.enter="currentPage = 1"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<button class="btn-primary" @click="openCreate">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<line x1="12" y1="5" x2="12" y2="19" stroke-linecap="round" />
|
|
<line x1="5" y1="12" x2="19" y2="12" stroke-linecap="round" />
|
|
</svg>
|
|
新增用户
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Table -->
|
|
<div class="table-card">
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th class="th-checkbox">
|
|
<input
|
|
type="checkbox"
|
|
:checked="isAllSelected"
|
|
class="checkbox"
|
|
@change="toggleSelectAll"
|
|
/>
|
|
</th>
|
|
<th class="th-sortable" @click="toggleSort('username')">
|
|
<span>用户</span>
|
|
<svg v-if="sortBy === 'username'" class="sort-icon" :class="{ desc: sortOrder === 'desc' }" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M12 5v14M5 12l7-7 7 7" stroke-linecap="round" stroke-linejoin="round"/>
|
|
</svg>
|
|
</th>
|
|
<th class="text-left">邮箱</th>
|
|
<th class="text-left">角色</th>
|
|
<th class="text-left">状态</th>
|
|
<th class="th-sortable" @click="toggleSort('createdAt')">
|
|
<span>注册时间</span>
|
|
<svg v-if="sortBy === 'createdAt'" class="sort-icon" :class="{ desc: sortOrder === 'desc' }" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M12 5v14M5 12l7-7 7 7" stroke-linecap="round" stroke-linejoin="round"/>
|
|
</svg>
|
|
</th>
|
|
<th class="text-right">操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-if="pending">
|
|
<td colspan="7" class="text-center">加载中...</td>
|
|
</tr>
|
|
<tr v-else-if="userList.length === 0">
|
|
<td colspan="7" class="text-center">暂无用户</td>
|
|
</tr>
|
|
<tr v-for="u in userList" :key="u.id" class="table-row" :class="{ selected: selectedIds.has(u.id) }">
|
|
<td class="td-checkbox">
|
|
<input
|
|
type="checkbox"
|
|
:checked="selectedIds.has(u.id)"
|
|
class="checkbox"
|
|
@change="toggleSelect(u.id)"
|
|
/>
|
|
</td>
|
|
<td class="user-cell">
|
|
<div class="user-info">
|
|
<div class="user-avatar">
|
|
<img v-if="u.avatar" :src="u.avatar" :alt="u.username" />
|
|
<span v-else class="avatar-placeholder">
|
|
{{ u.username.charAt(0).toUpperCase() }}
|
|
</span>
|
|
</div>
|
|
<div class="user-details">
|
|
<span class="user-name">{{ u.nickname || u.username }}</span>
|
|
<span class="user-username">@{{ u.username }}</span>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td class="email-cell">{{ u.email || '-' }}</td>
|
|
<td class="role-cell">
|
|
<span :class="['role-badge', roleBadge(u.role).class]">
|
|
{{ roleBadge(u.role).label }}
|
|
</span>
|
|
</td>
|
|
<td class="status-cell">
|
|
<span :class="['status-badge', statusBadge(u.status).class]">
|
|
{{ statusBadge(u.status).label }}
|
|
</span>
|
|
</td>
|
|
<td class="date-cell">{{ formatDate(u.createdAt) }}</td>
|
|
<td class="actions-cell">
|
|
<button class="action-btn" @click="openDrawer(u)">编辑</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<!-- Pagination -->
|
|
<div class="pagination">
|
|
<div class="page-info">
|
|
共 {{ totalUsers }} 条,第 {{ currentPage }}/{{ totalPages }} 页
|
|
</div>
|
|
<div class="page-buttons">
|
|
<button
|
|
class="page-btn"
|
|
:disabled="currentPage === 1"
|
|
@click="goToPage(currentPage - 1)"
|
|
>
|
|
上一页
|
|
</button>
|
|
<button
|
|
class="page-btn"
|
|
:disabled="currentPage === totalPages"
|
|
@click="goToPage(currentPage + 1)"
|
|
>
|
|
下一页
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Batch action bar -->
|
|
<Transition name="slide-up">
|
|
<div v-if="selectedIds.size > 0" class="batch-bar">
|
|
<div class="batch-info">已选中 {{ selectedIds.size }} 项</div>
|
|
<div class="batch-actions">
|
|
<button class="batch-btn batch-btn-enable" @click="handleBatchEnable">启用</button>
|
|
<button class="batch-btn batch-btn-disable" @click="handleBatchDisable">禁用</button>
|
|
<button class="batch-btn batch-btn-delete" @click="handleBatchDelete">删除</button>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
|
|
<!-- Detail drawer -->
|
|
<Teleport to="body">
|
|
<Transition name="slide-right">
|
|
<div v-if="showDrawer" class="drawer-overlay" @click.self="closeDrawer">
|
|
<div class="drawer-panel">
|
|
<div class="drawer-header">
|
|
<h3 class="drawer-title">编辑用户</h3>
|
|
<div v-if="editingUser" class="drawer-subtitle">@{{ editingUser.username }}</div>
|
|
<button class="drawer-close" @click="closeDrawer">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<line x1="18" y1="6" x2="6" y2="18" stroke-linecap="round" />
|
|
<line x1="6" y1="6" x2="18" y2="18" stroke-linecap="round" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<form class="drawer-body" @submit.prevent="handleDrawerSave">
|
|
<div v-if="drawerError" class="form-error">{{ drawerError }}</div>
|
|
|
|
<div class="form-group">
|
|
<label class="form-label">用户名</label>
|
|
<input
|
|
type="text"
|
|
class="form-input"
|
|
:value="editingUser?.username"
|
|
readonly
|
|
/>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="form-label">昵称</label>
|
|
<input
|
|
v-model="drawerForm.nickname"
|
|
type="text"
|
|
class="form-input"
|
|
placeholder="输入昵称"
|
|
/>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="form-label">邮箱</label>
|
|
<input
|
|
v-model="drawerForm.email"
|
|
type="email"
|
|
class="form-input"
|
|
placeholder="example@domain.com"
|
|
/>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="form-label">角色</label>
|
|
<select v-model="drawerForm.role" class="form-select">
|
|
<option value="user">普通用户</option>
|
|
<option value="admin">管理员</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="form-label">状态</label>
|
|
<select v-model="drawerForm.status" class="form-select">
|
|
<option value="active">正常</option>
|
|
<option value="disabled">禁用</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="drawer-footer">
|
|
<button type="button" class="btn-secondary" @click="closeDrawer">取消</button>
|
|
<button type="submit" class="btn-primary" :disabled="drawerLoading">
|
|
{{ drawerLoading ? '保存中...' : '保存' }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
|
|
<!-- Create modal -->
|
|
<Teleport to="body">
|
|
<div v-if="showCreateModal" class="modal-overlay" @click.self="closeModal">
|
|
<div class="modal-card">
|
|
<div class="modal-header">
|
|
<h3 class="modal-title">新增用户</h3>
|
|
<button class="modal-close" @click="closeModal">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<line x1="18" y1="6" x2="6" y2="18" stroke-linecap="round" />
|
|
<line x1="6" y1="6" x2="18" y2="18" stroke-linecap="round" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<form class="modal-body" @submit.prevent="handleCreate">
|
|
<div v-if="createError" class="form-error">{{ createError }}</div>
|
|
<div class="form-group">
|
|
<label class="form-label">用户名</label>
|
|
<input
|
|
v-model="createForm.username"
|
|
type="text"
|
|
class="form-input"
|
|
placeholder="3-20个字符,字母数字下划线"
|
|
/>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">密码</label>
|
|
<input
|
|
v-model="createForm.password"
|
|
type="password"
|
|
class="form-input"
|
|
placeholder="至少6位"
|
|
/>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">邮箱(选填)</label>
|
|
<input
|
|
v-model="createForm.email"
|
|
type="email"
|
|
class="form-input"
|
|
placeholder="example@domain.com"
|
|
/>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">角色</label>
|
|
<select v-model="createForm.role" class="form-select">
|
|
<option value="user">普通用户</option>
|
|
<option value="admin">管理员</option>
|
|
</select>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn-secondary" @click="closeModal">取消</button>
|
|
<button type="submit" class="btn-primary" :disabled="createLoading">
|
|
{{ createLoading ? '创建中...' : '创建' }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</Teleport>
|
|
|
|
<!-- Confirm dialog -->
|
|
<Teleport to="body">
|
|
<div v-if="showConfirmDialog" class="modal-overlay" @click.self="showConfirmDialog = false">
|
|
<div class="confirm-dialog">
|
|
<div class="confirm-header">
|
|
<h3 class="confirm-title">确认删除</h3>
|
|
</div>
|
|
<div class="confirm-body">
|
|
<p>确定删除选中的 {{ selectedIds.size }} 个用户?此操作不可撤销。</p>
|
|
</div>
|
|
<div class="confirm-footer">
|
|
<button class="btn-secondary" @click="showConfirmDialog = false">取消</button>
|
|
<button class="btn-error" :disabled="confirmLoading" @click="confirmBatchDelete">
|
|
{{ confirmLoading ? '删除中...' : '确认删除' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Teleport>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.users-page {
|
|
padding: 40px;
|
|
}
|
|
|
|
.page-header {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
margin-bottom: 32px;
|
|
}
|
|
|
|
.header-left {
|
|
flex: 1;
|
|
}
|
|
|
|
.page-title {
|
|
font-family: var(--font-display);
|
|
font-size: 32px;
|
|
font-weight: 400;
|
|
color: var(--color-ink);
|
|
letter-spacing: -0.3px;
|
|
margin: 0 0 20px 0;
|
|
}
|
|
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 16px;
|
|
max-width: 600px;
|
|
}
|
|
|
|
.stat-card {
|
|
background: var(--color-surface-card);
|
|
border-radius: 12px;
|
|
padding: 16px 20px;
|
|
}
|
|
|
|
.stat-value {
|
|
font-family: var(--font-display);
|
|
font-size: 28px;
|
|
font-weight: 400;
|
|
color: var(--color-ink);
|
|
line-height: 1;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: 13px;
|
|
color: var(--color-muted);
|
|
font-weight: 500;
|
|
}
|
|
|
|
/* Filter bar */
|
|
.filter-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.filter-left {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.status-tabs {
|
|
display: flex;
|
|
gap: 4px;
|
|
background: var(--color-surface-soft);
|
|
padding: 4px;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.status-tab {
|
|
padding: 6px 14px;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
color: var(--color-muted);
|
|
background: transparent;
|
|
border: none;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.status-tab:hover {
|
|
color: var(--color-ink);
|
|
}
|
|
|
|
.status-tab.active {
|
|
background: var(--color-surface-card);
|
|
color: var(--color-ink);
|
|
}
|
|
|
|
.role-select {
|
|
padding: 8px 12px;
|
|
background: var(--color-surface-card);
|
|
border: 1px solid var(--color-hairline);
|
|
border-radius: 8px;
|
|
font-size: 13px;
|
|
color: var(--color-ink);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.role-select:focus {
|
|
outline: none;
|
|
border-color: var(--color-primary);
|
|
}
|
|
|
|
.search-box {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.search-icon {
|
|
position: absolute;
|
|
left: 12px;
|
|
width: 16px;
|
|
height: 16px;
|
|
color: var(--color-muted);
|
|
pointer-events: none;
|
|
}
|
|
|
|
.search-input {
|
|
padding: 8px 12px 8px 36px;
|
|
background: var(--color-surface-card);
|
|
border: 1px solid var(--color-hairline);
|
|
border-radius: 8px;
|
|
font-size: 13px;
|
|
color: var(--color-ink);
|
|
width: 200px;
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.search-input::placeholder {
|
|
color: var(--color-muted);
|
|
}
|
|
|
|
.search-input:focus {
|
|
outline: none;
|
|
border-color: var(--color-primary);
|
|
box-shadow: 0 0 0 3px rgba(204, 120, 92, 0.15);
|
|
}
|
|
|
|
.btn-primary {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 10px 16px;
|
|
background: var(--color-primary);
|
|
color: var(--color-on-primary);
|
|
border: none;
|
|
border-radius: 8px;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: background 0.15s ease;
|
|
}
|
|
|
|
.btn-primary:hover {
|
|
background: var(--color-primary-active);
|
|
}
|
|
|
|
.btn-primary:disabled {
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.btn-primary svg {
|
|
width: 14px;
|
|
height: 14px;
|
|
}
|
|
|
|
/* Table */
|
|
.table-card {
|
|
background: var(--color-surface-card);
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.data-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
}
|
|
|
|
.data-table th {
|
|
padding: 12px 16px;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
color: var(--color-muted);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
background: var(--color-surface-soft);
|
|
border-bottom: 1px solid var(--color-hairline);
|
|
text-align: left;
|
|
}
|
|
|
|
.data-table th.th-checkbox {
|
|
width: 48px;
|
|
padding: 12px 0 12px 16px;
|
|
}
|
|
|
|
.data-table th.th-sortable {
|
|
cursor: pointer;
|
|
user-select: none;
|
|
}
|
|
|
|
.data-table th.th-sortable:hover {
|
|
color: var(--color-ink);
|
|
}
|
|
|
|
.data-table th.th-sortable span {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
.sort-icon {
|
|
width: 14px;
|
|
height: 14px;
|
|
transition: transform 0.15s ease;
|
|
}
|
|
|
|
.sort-icon.desc {
|
|
transform: rotate(180deg);
|
|
}
|
|
|
|
.data-table td {
|
|
padding: 14px 16px;
|
|
font-size: 14px;
|
|
color: var(--color-body-strong);
|
|
border-bottom: 1px solid var(--color-hairline);
|
|
}
|
|
|
|
.data-table td.td-checkbox {
|
|
width: 48px;
|
|
padding: 14px 0 14px 16px;
|
|
}
|
|
|
|
.table-row:last-child td {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.table-row:hover td {
|
|
background: var(--color-surface-soft);
|
|
}
|
|
|
|
.table-row.selected td {
|
|
background: rgba(204, 120, 92, 0.05);
|
|
}
|
|
|
|
.checkbox {
|
|
width: 16px;
|
|
height: 16px;
|
|
cursor: pointer;
|
|
accent-color: var(--color-primary);
|
|
}
|
|
|
|
.user-cell {
|
|
min-width: 200px;
|
|
}
|
|
|
|
.user-info {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
|
|
.user-avatar {
|
|
width: 36px;
|
|
height: 36px;
|
|
border-radius: 50%;
|
|
overflow: hidden;
|
|
flex-shrink: 0;
|
|
background: var(--color-surface-soft);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.user-avatar img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.avatar-placeholder {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
color: var(--color-muted);
|
|
}
|
|
|
|
.user-details {
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-width: 0;
|
|
}
|
|
|
|
.user-name {
|
|
font-weight: 500;
|
|
color: var(--color-ink);
|
|
font-size: 14px;
|
|
}
|
|
|
|
.user-username {
|
|
font-size: 12px;
|
|
color: var(--color-muted);
|
|
}
|
|
|
|
.email-cell {
|
|
color: var(--color-muted);
|
|
font-size: 13px;
|
|
}
|
|
|
|
.date-cell {
|
|
font-size: 13px;
|
|
color: var(--color-muted);
|
|
}
|
|
|
|
.role-badge,
|
|
.status-badge {
|
|
display: inline-block;
|
|
padding: 3px 10px;
|
|
border-radius: 9999px;
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.role-admin {
|
|
background: rgba(204, 120, 92, 0.15);
|
|
color: var(--color-primary);
|
|
}
|
|
|
|
.role-user {
|
|
background: rgba(93, 184, 166, 0.15);
|
|
color: var(--color-accent-teal);
|
|
}
|
|
|
|
.status-active {
|
|
background: rgba(93, 184, 166, 0.15);
|
|
color: var(--color-accent-teal);
|
|
}
|
|
|
|
.status-disabled {
|
|
background: var(--color-hairline);
|
|
color: var(--color-muted);
|
|
}
|
|
|
|
.actions-cell {
|
|
text-align: right;
|
|
}
|
|
|
|
.action-btn {
|
|
padding: 6px 12px;
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
color: var(--color-muted);
|
|
background: transparent;
|
|
border: none;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.action-btn:hover {
|
|
background: var(--color-hairline);
|
|
color: var(--color-ink);
|
|
}
|
|
|
|
/* Pagination */
|
|
.pagination {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
padding: 16px 20px;
|
|
border-top: 1px solid var(--color-hairline);
|
|
}
|
|
|
|
.page-info {
|
|
font-size: 13px;
|
|
color: var(--color-muted);
|
|
}
|
|
|
|
.page-buttons {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
|
|
.page-btn {
|
|
padding: 8px 14px;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
color: var(--color-ink);
|
|
background: var(--color-surface-soft);
|
|
border: 1px solid var(--color-hairline);
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.page-btn:hover:not(:disabled) {
|
|
background: var(--color-hairline);
|
|
}
|
|
|
|
.page-btn:disabled {
|
|
opacity: 0.4;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
/* Batch bar */
|
|
.batch-bar {
|
|
position: fixed;
|
|
bottom: 24px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 20px;
|
|
padding: 12px 20px;
|
|
background: var(--color-surface-dark);
|
|
border-radius: 12px;
|
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
|
z-index: 100;
|
|
}
|
|
|
|
.batch-info {
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
color: var(--color-on-dark);
|
|
}
|
|
|
|
.batch-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
|
|
.batch-btn {
|
|
padding: 8px 14px;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
border: none;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.batch-btn-enable {
|
|
background: var(--color-accent-teal);
|
|
color: white;
|
|
}
|
|
|
|
.batch-btn-enable:hover {
|
|
background: #4ca894;
|
|
}
|
|
|
|
.batch-btn-disable {
|
|
background: var(--color-muted-soft);
|
|
color: white;
|
|
}
|
|
|
|
.batch-btn-disable:hover {
|
|
background: var(--color-muted);
|
|
}
|
|
|
|
.batch-btn-delete {
|
|
background: var(--color-error);
|
|
color: white;
|
|
}
|
|
|
|
.batch-btn-delete:hover {
|
|
background: #b33d3d;
|
|
}
|
|
|
|
/* Drawer */
|
|
.drawer-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.4);
|
|
z-index: 200;
|
|
}
|
|
|
|
.drawer-panel {
|
|
position: fixed;
|
|
top: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
width: 480px;
|
|
max-width: 100%;
|
|
background: var(--color-canvas);
|
|
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.15);
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.drawer-header {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
padding: 20px 24px;
|
|
border-bottom: 1px solid var(--color-hairline);
|
|
}
|
|
|
|
.drawer-title {
|
|
font-size: 18px;
|
|
font-weight: 500;
|
|
color: var(--color-ink);
|
|
margin: 0;
|
|
}
|
|
|
|
.drawer-subtitle {
|
|
font-size: 13px;
|
|
color: var(--color-muted);
|
|
}
|
|
|
|
.drawer-close {
|
|
position: absolute;
|
|
top: 16px;
|
|
right: 16px;
|
|
width: 32px;
|
|
height: 32px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: transparent;
|
|
border: none;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
color: var(--color-muted);
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.drawer-close:hover {
|
|
background: var(--color-surface-soft);
|
|
color: var(--color-ink);
|
|
}
|
|
|
|
.drawer-close svg {
|
|
width: 18px;
|
|
height: 18px;
|
|
}
|
|
|
|
.drawer-body {
|
|
flex: 1;
|
|
padding: 24px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.drawer-footer {
|
|
display: flex;
|
|
gap: 12px;
|
|
justify-content: flex-end;
|
|
padding-top: 16px;
|
|
border-top: 1px solid var(--color-hairline);
|
|
margin-top: auto;
|
|
}
|
|
|
|
/* Modal */
|
|
.modal-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 300;
|
|
}
|
|
|
|
.modal-card {
|
|
background: var(--color-canvas);
|
|
border-radius: 12px;
|
|
width: 100%;
|
|
max-width: 440px;
|
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
|
}
|
|
|
|
.modal-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 20px 24px;
|
|
border-bottom: 1px solid var(--color-hairline);
|
|
}
|
|
|
|
.modal-title {
|
|
font-size: 18px;
|
|
font-weight: 500;
|
|
color: var(--color-ink);
|
|
margin: 0;
|
|
}
|
|
|
|
.modal-close {
|
|
width: 32px;
|
|
height: 32px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: transparent;
|
|
border: none;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
color: var(--color-muted);
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.modal-close:hover {
|
|
background: var(--color-surface-soft);
|
|
color: var(--color-ink);
|
|
}
|
|
|
|
.modal-close svg {
|
|
width: 18px;
|
|
height: 18px;
|
|
}
|
|
|
|
.modal-body {
|
|
padding: 24px;
|
|
}
|
|
|
|
.form-error {
|
|
padding: 10px 14px;
|
|
background: rgba(198, 69, 69, 0.1);
|
|
border: 1px solid rgba(198, 69, 69, 0.2);
|
|
border-radius: 6px;
|
|
color: var(--color-error);
|
|
font-size: 13px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.form-group {
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.form-label {
|
|
display: block;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
color: var(--color-body-strong);
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.form-input,
|
|
.form-select {
|
|
width: 100%;
|
|
padding: 10px 14px;
|
|
background: var(--color-canvas);
|
|
border: 1px solid var(--color-hairline);
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
color: var(--color-ink);
|
|
transition: all 0.15s ease;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.form-input:focus,
|
|
.form-select:focus {
|
|
outline: none;
|
|
border-color: var(--color-primary);
|
|
box-shadow: 0 0 0 3px rgba(204, 120, 92, 0.15);
|
|
}
|
|
|
|
.form-input::placeholder {
|
|
color: var(--color-muted);
|
|
}
|
|
|
|
.form-input[readonly] {
|
|
background: var(--color-surface-soft);
|
|
color: var(--color-muted);
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.modal-footer {
|
|
display: flex;
|
|
gap: 12px;
|
|
justify-content: flex-end;
|
|
padding-top: 8px;
|
|
}
|
|
|
|
.btn-secondary {
|
|
padding: 10px 18px;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
color: var(--color-body-strong);
|
|
background: var(--color-surface-soft);
|
|
border: 1px solid var(--color-hairline);
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.btn-secondary:hover {
|
|
background: var(--color-hairline);
|
|
}
|
|
|
|
.btn-error {
|
|
padding: 10px 18px;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
color: white;
|
|
background: var(--color-error);
|
|
border: none;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.btn-error:hover {
|
|
background: #b33d3d;
|
|
}
|
|
|
|
.btn-error:disabled {
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
/* Confirm dialog */
|
|
.confirm-dialog {
|
|
background: var(--color-canvas);
|
|
border-radius: 12px;
|
|
width: 100%;
|
|
max-width: 400px;
|
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
|
}
|
|
|
|
.confirm-header {
|
|
padding: 20px 24px;
|
|
border-bottom: 1px solid var(--color-hairline);
|
|
}
|
|
|
|
.confirm-title {
|
|
font-size: 18px;
|
|
font-weight: 500;
|
|
color: var(--color-ink);
|
|
margin: 0;
|
|
}
|
|
|
|
.confirm-body {
|
|
padding: 24px;
|
|
}
|
|
|
|
.confirm-body p {
|
|
margin: 0;
|
|
font-size: 14px;
|
|
color: var(--color-body-strong);
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.confirm-footer {
|
|
display: flex;
|
|
gap: 12px;
|
|
justify-content: flex-end;
|
|
padding: 16px 24px;
|
|
border-top: 1px solid var(--color-hairline);
|
|
}
|
|
|
|
/* Transitions */
|
|
.slide-up-enter-active,
|
|
.slide-up-leave-active {
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.slide-up-enter-from,
|
|
.slide-up-leave-to {
|
|
opacity: 0;
|
|
transform: translateX(-50%) translateY(20px);
|
|
}
|
|
|
|
.slide-right-enter-active,
|
|
.slide-right-leave-active {
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.slide-right-enter-from .drawer-panel,
|
|
.slide-right-leave-to .drawer-panel {
|
|
transform: translateX(100%);
|
|
}
|
|
|
|
.slide-right-enter-from,
|
|
.slide-right-leave-to {
|
|
opacity: 0;
|
|
}
|
|
|
|
@media (max-width: 1024px) {
|
|
.stats-grid {
|
|
grid-template-columns: repeat(2, 1fr);
|
|
}
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.users-page {
|
|
padding: 24px;
|
|
}
|
|
|
|
.page-header {
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
}
|
|
|
|
.filter-bar {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
}
|
|
|
|
.filter-left {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
}
|
|
|
|
.search-input {
|
|
width: 100%;
|
|
}
|
|
|
|
.stats-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.drawer-panel {
|
|
width: 100%;
|
|
}
|
|
|
|
.batch-bar {
|
|
left: 16px;
|
|
right: 16px;
|
|
transform: none;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
}
|
|
</style>
|