优化页面

This commit is contained in:
马宝龙
2026-07-28 11:35:34 +08:00
parent ea5071dc37
commit 52cbed63a8
7 changed files with 526 additions and 136 deletions

View File

@@ -1,48 +1,115 @@
import { FormEvent, useCallback, useEffect, useState } from 'react'
import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react'
type Status = 'ACTIVE' | 'INACTIVE'
type User = {
id: number
fullName: string
email: string
role: string
status: 'ACTIVE' | 'INACTIVE'
status: Status
createdAt: string
updatedAt: string
}
type UserForm = Omit<User, 'id' | 'createdAt'>
type UserForm = Omit<User, 'id' | 'createdAt' | 'updatedAt'>
type UserSummary = {
total: number
active: number
inactive: number
admins: number
}
type UserPage = {
items: User[]
total: number
}
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? '/api'
const roleOptions = ['Admin', 'Manager', 'Member', 'Viewer']
const emptyForm: UserForm = { fullName: '', email: '', role: 'Member', status: 'ACTIVE' }
function roleLabel(role: string) {
return ({ Admin: '管理员', Manager: '经理', Member: '成员', Viewer: '只读成员' }[role] ?? role)
}
function statusLabel(status: Status) {
return status === 'ACTIVE' ? '活跃' : '未激活'
}
function formatDate(value: string) {
return new Date(value).toLocaleDateString('zh-CN', { year: 'numeric', month: 'short', day: 'numeric' })
}
function initials(name: string) {
const parts = name.trim().split(/\s+/).filter(Boolean)
if (parts.length > 1) return `${parts[0].charAt(0)}${parts[parts.length - 1].charAt(0)}`.toUpperCase()
return name.trim().slice(0, 2).toUpperCase()
}
async function readError(response: Response, fallback: string) {
const body = await response.json().catch(() => null) as { message?: string } | null
return body?.message ?? fallback
}
function App() {
const [users, setUsers] = useState<User[]>([])
const [total, setTotal] = useState(0)
const [summary, setSummary] = useState<UserSummary>({ total: 0, active: 0, inactive: 0, admins: 0 })
const [keyword, setKeyword] = useState('')
const [search, setSearch] = useState('')
const [roleFilter, setRoleFilter] = useState('')
const [statusFilter, setStatusFilter] = useState<'' | Status>('')
const [form, setForm] = useState<UserForm>(emptyForm)
const [editingId, setEditingId] = useState<number | null>(null)
const [isFormOpen, setIsFormOpen] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [isBulkUpdating, setIsBulkUpdating] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const loadUsers = useCallback(async (value = search) => {
const loadUsers = useCallback(async () => {
setIsLoading(true)
setError('')
try {
const response = await fetch(`${apiBaseUrl}/users?keyword=${encodeURIComponent(value)}&size=100`)
if (!response.ok) throw new Error('无法加载用户列表')
const data = await response.json() as { items: User[]; total: number }
const params = new URLSearchParams({ keyword: search, role: roleFilter, status: statusFilter, page: '0', size: '50' })
const response = await fetch(`${apiBaseUrl}/users?${params}`)
if (!response.ok) throw new Error(await readError(response, '无法加载用户列表'))
const data = await response.json() as UserPage
setUsers(data.items)
setTotal(data.total)
setSelectedIds(new Set())
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : '请求失败,请稍后重试')
} finally {
setIsLoading(false)
}
}, [search])
}, [roleFilter, search, statusFilter])
useEffect(() => { void loadUsers() }, [loadUsers])
const loadSummary = useCallback(async () => {
try {
const response = await fetch(`${apiBaseUrl}/users/summary`)
if (!response.ok) throw new Error(await readError(response, '无法加载用户概览'))
setSummary(await response.json() as UserSummary)
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : '无法加载用户概览')
}
}, [])
const refreshData = useCallback(async () => {
await Promise.all([loadUsers(), loadSummary()])
}, [loadSummary, loadUsers])
useEffect(() => { void refreshData() }, [refreshData])
useEffect(() => {
if (!notice) return undefined
const timer = window.setTimeout(() => setNotice(''), 3200)
return () => window.clearTimeout(timer)
}, [notice])
function openCreate() {
setEditingId(null)
@@ -68,12 +135,10 @@ function App() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
})
if (!response.ok) {
const body = await response.json().catch(() => null) as { message?: string } | null
throw new Error(body?.message ?? '保存失败,请检查输入')
}
if (!response.ok) throw new Error(await readError(response, '保存失败,请检查输入'))
setIsFormOpen(false)
await loadUsers()
setNotice(editingId ? '用户信息已更新' : '用户已创建')
await refreshData()
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : '保存失败,请稍后重试')
} finally {
@@ -82,91 +147,181 @@ function App() {
}
async function removeUser(user: User) {
if (!window.confirm(`确定删除用户“${user.fullName}”吗?`)) return
if (!window.confirm(`确定删除用户“${user.fullName}”吗?此操作不可撤销。`)) return
setError('')
try {
const response = await fetch(`${apiBaseUrl}/users/${user.id}`, { method: 'DELETE' })
if (!response.ok) throw new Error('删除失败,请稍后重试')
await loadUsers()
if (!response.ok) throw new Error(await readError(response, '删除失败,请稍后重试'))
setNotice('用户已删除')
await refreshData()
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : '删除失败,请稍后重试')
}
}
async function updateSelectedStatus(status: Status) {
if (selectedIds.size === 0) return
const label = statusLabel(status)
if (!window.confirm(`确定将选中的 ${selectedIds.size} 位用户设为“${label}”吗?`)) return
setIsBulkUpdating(true)
setError('')
try {
const response = await fetch(`${apiBaseUrl}/users/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: [...selectedIds], status }),
})
if (!response.ok) throw new Error(await readError(response, '批量更新失败,请稍后重试'))
setNotice(`已将选中的用户设为${label}`)
await refreshData()
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : '批量更新失败,请稍后重试')
} finally {
setIsBulkUpdating(false)
}
}
function submitSearch(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setSearch(keyword.trim())
}
function clearFilters() {
setKeyword('')
setSearch('')
setRoleFilter('')
setStatusFilter('')
}
function toggleSelected(id: number) {
setSelectedIds((current) => {
const next = new Set(current)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
function toggleAll() {
setSelectedIds((current) => current.size === users.length ? new Set() : new Set(users.map((user) => user.id)))
}
const allSelected = users.length > 0 && selectedIds.size === users.length
const activeFilters = Boolean(search || roleFilter || statusFilter)
const summaryCards = useMemo(() => [
{ label: '全部用户', value: summary.total, detail: '当前组织账号', icon: '◎', tone: 'teal' },
{ label: '活跃用户', value: summary.active, detail: '可以正常访问', icon: '✓', tone: 'green' },
{ label: '未激活', value: summary.inactive, detail: '等待重新启用', icon: '—', tone: 'amber' },
{ label: '管理员', value: summary.admins, detail: '拥有管理权限', icon: '◆', tone: 'navy' },
], [summary])
return (
<main className="app-shell">
<header className="topbar">
<div className="brand-lockup">
<div className="brand-mark">U</div>
<div>
<p className="brand-name">Console</p>
<p className="brand-context">Operations workspace</p>
<div className="topbar-inner">
<div className="brand-lockup">
<div className="brand-mark">U</div>
<div>
<p className="brand-name"></p>
<p className="brand-context"></p>
</div>
</div>
<div className="topbar-meta"><span className="status-dot" /><span className="topbar-divider" /></div>
</div>
<span className="connection-status"><span className="status-dot" /> API connected</span>
</header>
<section className="content-wrap" aria-labelledby="page-title">
<div className="breadcrumb"><span></span><span>/</span><strong></strong></div>
<div className="page-heading">
<div>
<p className="eyebrow">DIRECTORY</p>
<h1 id="page-title">User management</h1>
<p className="page-summary">Manage access, roles, and account status from one place.</p>
<p className="eyebrow"> / DIRECTORY</p>
<h1 id="page-title"></h1>
<p className="page-summary">访</p>
</div>
<button className="primary-button" type="button" onClick={openCreate}>+ Add user</button>
<button className="primary-button" type="button" onClick={openCreate}><span className="button-icon">+</span></button>
</div>
<div className="toolbar">
<form className="search-form" onSubmit={submitSearch}>
<label className="sr-only" htmlFor="user-search">Search users</label>
<input id="user-search" value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="Search by name or email" />
<button type="submit" className="secondary-button">Search</button>
</form>
<span className="result-count">{total} {total === 1 ? 'user' : 'users'}</span>
</div>
<section className="summary-grid" aria-label="用户概览">
{summaryCards.map((card) => (
<article className={`summary-card ${card.tone}`} key={card.label}>
<div className="summary-card-top"><span>{card.label}</span><b aria-hidden="true">{card.icon}</b></div>
<strong>{card.value}</strong>
<small>{card.detail}</small>
</article>
))}
</section>
{error && <div className="error-banner" role="alert">{error}</div>}
<div className="section-heading"><div><h2></h2><p></p></div><span className="result-count"> {total} </span></div>
<section className="table-frame" aria-live="polite">
{isLoading ? <div className="empty-state">Loading users...</div> : users.length === 0 ? (
<div className="empty-state"><strong>No users found</strong><span>Try another search or add a new user.</span></div>
) : (
<div className="table-scroll">
<table>
<thead><tr><th>User</th><th>Role</th><th>Status</th><th>Created</th><th><span className="sr-only">Actions</span></th></tr></thead>
<tbody>
{users.map((user) => (
<tr key={user.id}>
<td><div className="user-cell"><span className="avatar">{user.fullName.charAt(0).toUpperCase()}</span><span><strong>{user.fullName}</strong><small>{user.email}</small></span></div></td>
<td><span className="role-label">{user.role}</span></td>
<td><span className={`status-label ${user.status.toLowerCase()}`}>{user.status === 'ACTIVE' ? 'Active' : 'Inactive'}</span></td>
<td className="muted-cell">{new Date(user.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}</td>
<td><div className="row-actions"><button type="button" onClick={() => openEdit(user)}>Edit</button><button type="button" className="danger-action" onClick={() => void removeUser(user)}>Delete</button></div></td>
</tr>
))}
</tbody>
</table>
<section className="directory-section">
<div className="filter-toolbar">
<form className="search-form" onSubmit={submitSearch}>
<label className="sr-only" htmlFor="user-search"></label>
<span className="search-icon" aria-hidden="true"></span>
<input id="user-search" value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜索姓名或邮箱" />
<button type="submit" className="secondary-button"></button>
</form>
<div className="filter-group">
<label className="sr-only" htmlFor="role-filter"></label>
<select id="role-filter" value={roleFilter} onChange={(event) => setRoleFilter(event.target.value)}>
<option value=""></option>
{roleOptions.map((role) => <option value={role} key={role}>{roleLabel(role)}</option>)}
</select>
{activeFilters && <button className="clear-button" type="button" onClick={clearFilters}></button>}
</div>
)}
</div>
<div className="status-tabs" role="group" aria-label="按状态筛选">
{[['', '全部'], ['ACTIVE', '活跃'], ['INACTIVE', '未激活']].map(([value, label]) => (
<button className={statusFilter === value ? 'active' : ''} type="button" key={value} onClick={() => setStatusFilter(value as '' | Status)}>{label}</button>
))}
</div>
{selectedIds.size > 0 && <div className="bulk-toolbar"><span> <strong>{selectedIds.size}</strong> </span><div><button type="button" className="bulk-button" disabled={isBulkUpdating} onClick={() => void updateSelectedStatus('ACTIVE')}></button><button type="button" className="bulk-button" disabled={isBulkUpdating} onClick={() => void updateSelectedStatus('INACTIVE')}></button></div></div>}
{error && <div className="error-banner" role="alert"><span>{error}</span><button type="button" onClick={() => setError('')} aria-label="关闭错误提示">×</button></div>}
<div className="table-frame" aria-live="polite">
{isLoading ? <div className="empty-state"><span className="loading-ring" /></div> : users.length === 0 ? (
<div className="empty-state"><span className="empty-icon"></span><strong></strong><span></span>{activeFilters && <button className="secondary-button" type="button" onClick={clearFilters}></button>}</div>
) : (
<div className="table-scroll">
<table>
<thead><tr><th className="check-column"><input type="checkbox" aria-label="选择全部用户" checked={allSelected} onChange={toggleAll} /></th><th></th><th></th><th></th><th></th><th></th><th><span className="sr-only"></span></th></tr></thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className={selectedIds.has(user.id) ? 'selected-row' : ''}>
<td className="check-column" data-label="选择"><input type="checkbox" aria-label={`选择${user.fullName}`} checked={selectedIds.has(user.id)} onChange={() => toggleSelected(user.id)} /></td>
<td data-label="用户"><div className="user-cell"><span className="avatar">{initials(user.fullName)}</span><span><strong>{user.fullName}</strong><small>{user.email}</small></span></div></td>
<td data-label="角色"><span className="role-label">{roleLabel(user.role)}</span></td>
<td data-label="状态"><span className={`status-label ${user.status.toLowerCase()}`}><span className="status-mini-dot" />{statusLabel(user.status)}</span></td>
<td data-label="加入时间" className="muted-cell">{formatDate(user.createdAt)}</td>
<td data-label="最近更新" className="muted-cell">{formatDate(user.updatedAt)}</td>
<td data-label="操作"><div className="row-actions"><button className="icon-button" type="button" title="编辑用户" aria-label={`编辑${user.fullName}`} onClick={() => openEdit(user)}><span aria-hidden="true"></span></button><button className="icon-button danger-action" type="button" title="删除用户" aria-label={`删除${user.fullName}`} onClick={() => void removeUser(user)}><span aria-hidden="true"></span></button></div></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</section>
</section>
{notice && <div className="toast" role="status"><span className="toast-icon"></span>{notice}</div>}
{isFormOpen && (
<div className="modal-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) setIsFormOpen(false) }}>
<section className="modal" role="dialog" aria-modal="true" aria-labelledby="form-title">
<div className="modal-heading"><div><p className="eyebrow">USER RECORD</p><h2 id="form-title">{editingId ? 'Edit user' : 'Add user'}</h2></div><button className="close-button" type="button" aria-label="Close" onClick={() => setIsFormOpen(false)}>×</button></div>
<div className="drawer-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) setIsFormOpen(false) }}>
<aside className="drawer" role="dialog" aria-modal="true" aria-labelledby="form-title">
<div className="drawer-heading"><div><p className="eyebrow">USER RECORD / </p><h2 id="form-title">{editingId ? '编辑用户' : '新增用户'}</h2><p>访</p></div><button className="close-button" type="button" aria-label="关闭" onClick={() => setIsFormOpen(false)}>×</button></div>
<form onSubmit={submitForm}>
<label>Full name<input required maxLength={80} value={form.fullName} onChange={(event) => setForm({ ...form, fullName: event.target.value })} placeholder="e.g. Alex Morgan" /></label>
<label>Email address<input required type="email" maxLength={160} value={form.email} onChange={(event) => setForm({ ...form, email: event.target.value })} placeholder="alex@company.com" /></label>
<div className="form-grid"><label>Role<select value={form.role} onChange={(event) => setForm({ ...form, role: event.target.value })}><option>Admin</option><option>Manager</option><option>Member</option><option>Viewer</option></select></label><label>Status<select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value as UserForm['status'] })}><option value="ACTIVE">Active</option><option value="INACTIVE">Inactive</option></select></label></div>
<div className="modal-actions"><button type="button" className="secondary-button" onClick={() => setIsFormOpen(false)}>Cancel</button><button disabled={isSaving} type="submit" className="primary-button">{isSaving ? 'Saving...' : editingId ? 'Save changes' : 'Create user'}</button></div>
{error && <div className="drawer-error" role="alert">{error}</div>}
<label><input required maxLength={80} value={form.fullName} onChange={(event) => setForm({ ...form, fullName: event.target.value })} placeholder="例如:林晓晨" /></label>
<label><input required type="email" maxLength={160} value={form.email} onChange={(event) => setForm({ ...form, email: event.target.value })} placeholder="name@company.com" /></label>
<div className="form-grid"><label><select value={form.role} onChange={(event) => setForm({ ...form, role: event.target.value })}>{roleOptions.map((role) => <option key={role}>{role}</option>)}</select></label><label><select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value as Status })}><option value="ACTIVE"></option><option value="INACTIVE"></option></select></label></div>
<div className="form-note"><span>i</span><p>访</p></div>
<div className="drawer-actions"><button type="button" className="secondary-button" onClick={() => setIsFormOpen(false)}></button><button disabled={isSaving} type="submit" className="primary-button">{isSaving ? '保存中…' : editingId ? '保存修改' : '创建用户'}</button></div>
</form>
</section>
</aside>
</div>
)}
</main>

View File

@@ -1,7 +1,7 @@
:root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #16222b;
background: #f5f7f8;
color: #172b36;
background: #f4f7f7;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
@@ -10,74 +10,166 @@
body { margin: 0; min-width: 320px; }
button, input, select { font: inherit; }
button { cursor: pointer; }
button:disabled { cursor: wait; opacity: .55; }
.app-shell { min-height: 100vh; }
.topbar { height: 72px; padding: 0 5vw; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #e1e7ea; background: #fff; }
.brand-lockup, .connection-status, .user-cell, .row-actions { display: flex; align-items: center; }
.brand-lockup { gap: 11px; }
.brand-mark { display: grid; width: 32px; height: 32px; place-items: center; border-radius: 7px; background: #176b73; color: white; font-weight: 800; }
.brand-name, .brand-context { margin: 0; }
.brand-name { font-weight: 750; letter-spacing: .01em; }
.brand-context { margin-top: 2px; color: #86939a; font-size: 11px; }
.connection-status { gap: 7px; color: #6c7c84; font-size: 12px; }
.status-dot { width: 7px; height: 7px; border-radius: 50%; background: #33947c; }
.content-wrap { width: min(1180px, 90vw); margin: 0 auto; padding: 62px 0 80px; }
.page-heading { display: flex; align-items: end; justify-content: space-between; gap: 24px; margin-bottom: 42px; }
.eyebrow { margin: 0 0 10px; color: #176b73; font-size: 11px; font-weight: 800; letter-spacing: .16em; }
h1, h2 { margin: 0; letter-spacing: -.025em; }
h1 { font-size: clamp(30px, 4vw, 43px); line-height: 1.1; }
h2 { font-size: 25px; }
.page-summary { margin: 13px 0 0; color: #728087; font-size: 15px; }
.primary-button, .secondary-button { min-height: 40px; padding: 0 16px; border: 1px solid transparent; border-radius: 5px; font-size: 13px; font-weight: 700; transition: .15s ease; }
.primary-button { background: #176b73; color: white; }
.primary-button:hover { background: #11575e; }
.primary-button:disabled { cursor: wait; opacity: .6; }
.secondary-button { border-color: #d5dfe3; background: #fff; color: #32444d; }
.secondary-button:hover { border-color: #9fb3b8; background: #f8fafb; }
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
.search-form { display: flex; gap: 8px; width: min(490px, 100%); }
input, select { width: 100%; height: 42px; padding: 0 12px; border: 1px solid #d5dfe3; border-radius: 5px; outline: 0; background: white; color: #16222b; }
input:focus, select:focus { border-color: #4b9aa0; box-shadow: 0 0 0 3px rgb(23 107 115 / 10%); }
.result-count { color: #859299; font-size: 13px; }
.error-banner { margin: 16px 0; padding: 12px 14px; border: 1px solid #f0c7c4; border-radius: 5px; background: #fff5f4; color: #ae3f3c; font-size: 13px; }
.table-frame { overflow: hidden; min-height: 220px; border: 1px solid #e0e7e9; border-radius: 7px; background: white; box-shadow: 0 8px 28px rgb(39 59 68 / 4%); }
.topbar { height: 72px; border-bottom: 1px solid #dfe8e8; background: #fff; }
.topbar-inner { width: min(1240px, 91vw); height: 100%; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; }
.brand-lockup, .topbar-meta, .user-cell, .row-actions, .filter-toolbar, .filter-group, .section-heading, .summary-card-top, .bulk-toolbar, .bulk-toolbar > div, .error-banner, .drawer-heading, .drawer-actions, .form-note { display: flex; align-items: center; }
.brand-lockup { gap: 12px; }
.brand-mark { display: grid; width: 34px; height: 34px; place-items: center; border-radius: 8px; background: #136b69; color: #fff; font-size: 14px; font-weight: 850; letter-spacing: .02em; }
.brand-name, .brand-context, .page-summary, .section-heading p, .drawer-heading p, .form-note p { margin: 0; }
.brand-name { color: #17313a; font-size: 14px; font-weight: 800; }
.brand-context { margin-top: 2px; color: #8a9a9d; font-size: 11px; }
.topbar-meta { gap: 9px; color: #75878a; font-size: 12px; }
.topbar-divider { width: 1px; height: 14px; margin: 0 4px; background: #dce5e5; }
.status-dot, .status-mini-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: #43a276; }
.content-wrap { width: min(1240px, 91vw); margin: 0 auto; padding: 35px 0 76px; }
.breadcrumb { display: flex; align-items: center; gap: 9px; color: #91a0a2; font-size: 12px; }
.breadcrumb strong { color: #53676b; font-weight: 700; }
.page-heading { display: flex; align-items: end; justify-content: space-between; gap: 24px; margin: 31px 0 29px; }
.eyebrow { margin: 0 0 9px; color: #14837d; font-size: 10px; font-weight: 850; letter-spacing: .15em; }
h1, h2 { margin: 0; letter-spacing: -.035em; }
h1 { color: #102b35; font-size: clamp(30px, 4vw, 43px); line-height: 1.08; }
h2 { color: #19343d; font-size: 20px; }
.page-summary { margin-top: 12px; color: #738588; font-size: 14px; }
.primary-button, .secondary-button, .bulk-button { min-height: 40px; border-radius: 5px; font-size: 13px; font-weight: 750; transition: background .15s ease, border-color .15s ease, color .15s ease; }
.primary-button { padding: 0 17px; border: 1px solid #136b69; background: #136b69; color: #fff; }
.primary-button:hover { border-color: #0d5554; background: #0d5554; }
.button-icon { margin-right: 7px; font-size: 18px; font-weight: 500; vertical-align: -1px; }
.secondary-button { padding: 0 15px; border: 1px solid #d3dfe0; background: #fff; color: #3f555a; }
.secondary-button:hover { border-color: #94b6b4; background: #f7fbfa; }
.summary-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 13px; }
.summary-card { min-height: 128px; padding: 19px 20px 17px; border: 1px solid #e1e9e9; border-radius: 7px; background: #fff; box-shadow: 0 7px 20px rgb(20 50 54 / 4%); }
.summary-card-top { justify-content: space-between; color: #77898c; font-size: 12px; font-weight: 700; }
.summary-card-top b { display: grid; width: 27px; height: 27px; place-items: center; border-radius: 50%; font-size: 13px; }
.summary-card strong { display: block; margin-top: 10px; color: #17333c; font-size: 28px; line-height: 1; letter-spacing: -.04em; }
.summary-card small { display: block; margin-top: 10px; color: #99a7a8; font-size: 11px; }
.summary-card.teal .summary-card-top b { background: #e1f3f1; color: #16827c; }
.summary-card.green .summary-card-top b { background: #e5f5eb; color: #2d9161; }
.summary-card.amber .summary-card-top b { background: #fff2db; color: #bf7a16; }
.summary-card.navy .summary-card-top b { background: #e8edf3; color: #466581; }
.section-heading { justify-content: space-between; gap: 16px; margin: 39px 0 16px; }
.section-heading p { margin-top: 5px; color: #8b9b9c; font-size: 12px; }
.result-count { color: #8b9b9c; font-size: 12px; white-space: nowrap; }
.directory-section { overflow: hidden; border: 1px solid #e0e9e9; border-radius: 7px; background: #fff; box-shadow: 0 9px 28px rgb(20 50 54 / 4%); }
.filter-toolbar { justify-content: space-between; gap: 18px; padding: 18px 20px 12px; }
.search-form { position: relative; display: flex; gap: 8px; width: min(480px, 100%); }
.search-icon { position: absolute; top: 10px; left: 12px; color: #829597; font-size: 20px; line-height: 1; transform: rotate(-15deg); }
input, select { width: 100%; height: 40px; padding: 0 12px; border: 1px solid #d5e0e0; border-radius: 5px; outline: 0; background: #fff; color: #18323a; font-size: 13px; }
input:focus, select:focus { border-color: #53a9a1; box-shadow: 0 0 0 3px rgb(19 131 125 / 11%); }
.search-form input { padding-left: 36px; }
.filter-group { gap: 12px; min-width: 224px; }
.filter-group select { min-width: 145px; }
.clear-button { padding: 0; border: 0; background: transparent; color: #18827c; font-size: 12px; font-weight: 750; white-space: nowrap; }
.clear-button:hover { color: #0a5d5a; }
.status-tabs { display: flex; gap: 4px; padding: 0 20px; border-bottom: 1px solid #edf2f2; }
.status-tabs button { position: relative; padding: 10px 13px 12px; border: 0; background: transparent; color: #91a0a2; font-size: 12px; font-weight: 700; }
.status-tabs button.active { color: #137a75; }
.status-tabs button.active::after { position: absolute; right: 10px; bottom: -1px; left: 10px; height: 2px; background: #16847e; content: ""; }
.bulk-toolbar { justify-content: space-between; gap: 14px; min-height: 49px; padding: 8px 20px; border-bottom: 1px solid #dcebea; background: #f0f8f7; color: #527070; font-size: 12px; }
.bulk-toolbar strong { color: #116e6a; }
.bulk-toolbar > div { gap: 8px; }
.bulk-button { min-height: 30px; padding: 0 10px; border: 1px solid #b7d6d2; background: #fff; color: #176f6b; font-size: 11px; }
.bulk-button:hover { border-color: #6fb3ac; background: #e7f5f2; }
.error-banner { justify-content: space-between; gap: 12px; margin: 16px 20px 0; padding: 11px 13px; border: 1px solid #f1c8c3; border-radius: 5px; background: #fff7f5; color: #a8463e; font-size: 12px; }
.error-banner button { padding: 0; border: 0; background: transparent; color: inherit; font-size: 19px; line-height: 1; }
.table-frame { min-height: 250px; }
.table-scroll { overflow-x: auto; }
table { width: 100%; min-width: 760px; border-collapse: collapse; }
th, td { padding: 16px 20px; border-bottom: 1px solid #edf1f2; text-align: left; }
th { color: #819097; font-size: 11px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
table { width: 100%; min-width: 920px; border-collapse: collapse; }
th, td { padding: 15px 16px; border-bottom: 1px solid #edf2f2; text-align: left; }
th { color: #849597; font-size: 10px; font-weight: 850; letter-spacing: .09em; text-transform: uppercase; }
td { color: #3b5157; font-size: 13px; }
tbody tr:last-child td { border-bottom: 0; }
tbody tr:hover { background: #fbfcfc; }
.user-cell { gap: 11px; min-width: 240px; }
.avatar { display: grid; width: 33px; height: 33px; flex: 0 0 auto; place-items: center; border-radius: 50%; background: #dcefee; color: #176b73; font-size: 13px; font-weight: 800; }
tbody tr:hover, tbody tr.selected-row { background: #f8fcfb; }
.check-column { width: 48px; padding-right: 0; padding-left: 20px; }
input[type="checkbox"] { width: 16px; height: 16px; accent-color: #16827d; }
.user-cell { gap: 11px; min-width: 245px; }
.avatar { display: grid; width: 36px; height: 36px; flex: 0 0 auto; place-items: center; border-radius: 50%; background: #e1f3f1; color: #137a75; font-size: 11px; font-weight: 850; }
.user-cell strong, .user-cell small { display: block; }
.user-cell strong { font-size: 14px; }
.user-cell small { margin-top: 3px; color: #859299; font-size: 12px; }
.role-label { color: #4b5b63; font-size: 13px; }
.status-label { display: inline-block; padding: 5px 9px; border-radius: 99px; font-size: 11px; font-weight: 800; }
.status-label.active { background: #e3f4ed; color: #21775e; }
.status-label.inactive { background: #edf0f1; color: #6e7b80; }
.muted-cell { color: #819097; font-size: 13px; white-space: nowrap; }
.row-actions { justify-content: end; gap: 14px; }
.row-actions button { padding: 0; border: 0; background: none; color: #176b73; font-size: 13px; font-weight: 700; }
.row-actions .danger-action { color: #b5534d; }
.empty-state { min-height: 220px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; color: #8a989e; font-size: 13px; }
.empty-state strong { color: #46565e; font-size: 15px; }
.modal-backdrop { position: fixed; inset: 0; display: grid; place-items: center; padding: 20px; background: rgb(17 32 39 / 42%); }
.modal { width: min(100%, 480px); padding: 28px; border-radius: 8px; background: white; box-shadow: 0 20px 55px rgb(17 32 39 / 20%); }
.modal-heading { display: flex; justify-content: space-between; align-items: start; margin-bottom: 26px; }
.close-button { padding: 0; border: 0; background: none; color: #87949a; font-size: 26px; line-height: 1; }
.modal form { display: grid; gap: 17px; }
.modal label { display: grid; gap: 7px; color: #53636b; font-size: 12px; font-weight: 750; }
.user-cell strong { color: #223d44; font-size: 13px; }
.user-cell small { margin-top: 4px; color: #8a9a9d; font-size: 11px; }
.role-label { color: #53686c; font-size: 12px; }
.status-label { display: inline-flex; align-items: center; gap: 6px; padding: 5px 9px; border-radius: 99px; font-size: 11px; font-weight: 800; }
.status-label.active { background: #e4f5eb; color: #238259; }
.status-label.inactive { background: #f0f2f2; color: #758487; }
.status-label.inactive .status-mini-dot { background: #a7b2b3; }
.muted-cell { color: #87989b; font-size: 12px; white-space: nowrap; }
.row-actions { justify-content: end; gap: 4px; }
.icon-button { display: grid; width: 31px; height: 31px; place-items: center; padding: 0; border: 1px solid transparent; border-radius: 5px; background: transparent; color: #397d7a; font-size: 17px; }
.icon-button:hover { border-color: #d2e8e5; background: #edf8f6; }
.icon-button.danger-action { color: #bd6658; font-size: 15px; }
.icon-button.danger-action:hover { border-color: #f2d6d1; background: #fff5f3; }
.empty-state { min-height: 250px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; color: #8b9b9d; font-size: 12px; }
.empty-state strong { color: #486066; font-size: 15px; }
.empty-icon { color: #aab9ba; font-size: 27px; transform: rotate(-15deg); }
.empty-state .secondary-button { margin-top: 7px; }
.loading-ring { width: 20px; height: 20px; border: 2px solid #d8ecea; border-top-color: #16827d; border-radius: 50%; animation: spin .7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.toast { position: fixed; right: 24px; bottom: 24px; z-index: 20; display: flex; align-items: center; gap: 9px; min-height: 43px; padding: 0 15px; border: 1px solid #bfe2d4; border-radius: 6px; background: #f1fbf6; color: #267453; box-shadow: 0 12px 30px rgb(27 70 58 / 14%); font-size: 12px; font-weight: 700; }
.toast-icon { display: grid; width: 19px; height: 19px; place-items: center; border-radius: 50%; background: #3caa78; color: #fff; font-size: 12px; }
.drawer-backdrop { position: fixed; inset: 0; z-index: 10; display: flex; justify-content: end; background: rgb(13 35 39 / 38%); }
.drawer { overflow: auto; width: min(100%, 460px); padding: 31px 30px; background: #fff; box-shadow: -20px 0 45px rgb(13 35 39 / 14%); animation: slide-in .2s ease-out; }
@keyframes slide-in { from { transform: translateX(24px); opacity: .4; } to { transform: translateX(0); opacity: 1; } }
.drawer-heading { align-items: start; justify-content: space-between; gap: 20px; margin-bottom: 29px; }
.drawer-heading p:not(.eyebrow) { margin-top: 8px; color: #829395; font-size: 12px; }
.close-button { width: 32px; height: 32px; padding: 0; border: 0; border-radius: 5px; background: #f3f6f6; color: #738588; font-size: 24px; line-height: 1; }
.close-button:hover { background: #e8eeee; color: #35575a; }
.drawer form { display: grid; gap: 18px; }
.drawer-error { padding: 11px 12px; border: 1px solid #f1c8c3; border-radius: 5px; background: #fff7f5; color: #a8463e; font-size: 12px; line-height: 1.5; }
.drawer label { display: grid; gap: 7px; color: #4e656a; font-size: 12px; font-weight: 750; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.modal-actions { display: flex; justify-content: end; gap: 9px; margin-top: 9px; }
.form-note { align-items: start; gap: 9px; padding: 11px 12px; border-radius: 5px; background: #f2f8f7; color: #6f8586; }
.form-note span { display: grid; width: 17px; height: 17px; flex: 0 0 auto; place-items: center; border: 1px solid #9bc6c1; border-radius: 50%; color: #378d86; font-size: 11px; font-weight: 800; }
.form-note p { padding-top: 1px; font-size: 11px; line-height: 1.5; }
.drawer-actions { justify-content: end; gap: 9px; margin-top: 6px; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@media (max-width: 640px) {
.topbar { padding: 0 5vw; }
.connection-status { display: none; }
.content-wrap { width: 90vw; padding-top: 42px; }
.page-heading { align-items: start; flex-direction: column; margin-bottom: 30px; }
.page-heading .primary-button { width: 100%; }
.toolbar { align-items: stretch; flex-direction: column; }
@media (max-width: 840px) {
.summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.filter-toolbar { align-items: stretch; flex-direction: column; }
.search-form { width: 100%; }
.result-count { align-self: end; }
.modal { padding: 22px; }
.filter-group { justify-content: space-between; }
}
@media (max-width: 640px) {
.topbar { height: 64px; }
.topbar-inner, .content-wrap { width: 90vw; }
.topbar-meta { display: none; }
.content-wrap { padding-top: 25px; padding-bottom: 45px; }
.page-heading { align-items: start; flex-direction: column; margin: 25px 0 24px; }
.page-heading .primary-button { width: 100%; }
.summary-card { min-height: 112px; padding: 15px; }
.summary-card strong { font-size: 24px; }
.summary-card small { margin-top: 8px; }
.section-heading { align-items: start; flex-direction: column; margin-top: 31px; }
.result-count { align-self: end; }
.directory-section { border-radius: 6px; }
.filter-toolbar { padding: 14px 14px 10px; }
.search-form .secondary-button { padding: 0 12px; }
.filter-group { min-width: 0; }
.filter-group select { min-width: 0; }
.status-tabs { padding: 0 14px; }
.bulk-toolbar { align-items: start; flex-direction: column; padding: 11px 14px; }
.bulk-toolbar > div { width: 100%; }
.bulk-button { flex: 1; }
.error-banner { margin-right: 14px; margin-left: 14px; }
.table-scroll { overflow: visible; }
table, tbody { display: block; min-width: 0; }
thead { display: none; }
tbody { padding: 5px 14px 14px; }
tbody tr { display: grid; grid-template-columns: 30px 1fr auto; gap: 0 8px; padding: 14px 0; border-bottom: 1px solid #edf2f2; }
tbody tr:last-child { border-bottom: 0; }
td { display: flex; align-items: center; min-height: 26px; padding: 4px 0; border: 0; }
td::before { width: 78px; flex: 0 0 78px; color: #9aaaab; font-size: 10px; font-weight: 700; content: attr(data-label); }
td.check-column { grid-row: span 3; align-items: start; padding-top: 8px; }
td.check-column::before { display: none; }
td[data-label="用户"] { grid-column: 2 / 4; }
td[data-label="用户"]::before { display: none; }
td[data-label="角色"], td[data-label="状态"], td[data-label="加入时间"], td[data-label="最近更新"] { grid-column: 2 / 4; }
td[data-label="操作"] { grid-column: 3; grid-row: 1; align-self: start; }
td[data-label="操作"]::before { display: none; }
.user-cell { min-width: 0; }
.user-cell strong, .user-cell small { max-width: 190px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.row-actions { gap: 1px; }
.toast { right: 14px; bottom: 14px; left: 14px; justify-content: center; }
.drawer { width: 100%; align-self: end; max-height: 92vh; padding: 24px 20px; border-radius: 12px 12px 0 0; animation: rise-in .2s ease-out; }
@keyframes rise-in { from { transform: translateY(24px); opacity: .4; } to { transform: translateY(0); opacity: 1; } }
}