优化页面
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,6 +4,7 @@
|
||||
.vscode/
|
||||
.m2/
|
||||
.m2-local/
|
||||
.npm-cache
|
||||
.tmp-db-query/
|
||||
.tmp-vite/
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import com.example.training.service.UserService;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -16,6 +18,7 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
@@ -38,15 +41,29 @@ public class UserController {
|
||||
@GetMapping
|
||||
public UserPage list(
|
||||
@RequestParam(defaultValue = "") String keyword,
|
||||
@RequestParam(defaultValue = "") String role,
|
||||
@RequestParam(defaultValue = "") String status,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "50") int size) {
|
||||
int safePage = Math.max(page, 0);
|
||||
int safeSize = Math.min(Math.max(size, 1), 100);
|
||||
Page<User> resultPage = userService.pageUsers(keyword, safePage, safeSize);
|
||||
Page<User> resultPage = userService.pageUsers(keyword, role, status, safePage, safeSize);
|
||||
return new UserPage(resultPage.getRecords().stream().map(UserResponse::from).toList(),
|
||||
resultPage.getTotal(), (int) resultPage.getCurrent() - 1, (int) resultPage.getSize());
|
||||
}
|
||||
|
||||
@GetMapping("/summary")
|
||||
public UserSummary summary() {
|
||||
UserService.UserSummary summary = userService.summarizeUsers();
|
||||
return new UserSummary(summary.total(), summary.active(), summary.inactive(), summary.admins());
|
||||
}
|
||||
|
||||
@PatchMapping("/status")
|
||||
public BulkStatusResponse updateStatuses(@Valid @RequestBody BulkStatusRequest request) {
|
||||
int updated = userService.updateStatuses(request.ids(), request.status());
|
||||
return new BulkStatusResponse(updated, request.status());
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<UserResponse> create(@Valid @RequestBody UserRequest request) {
|
||||
User user = userService.createUser(request.fullName(), request.email(),
|
||||
@@ -59,7 +76,7 @@ public class UserController {
|
||||
User user = userService.updateUser(id, request.fullName(), request.email(),
|
||||
request.role(), request.status());
|
||||
if (user == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found");
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "用户不存在");
|
||||
}
|
||||
return UserResponse.from(user);
|
||||
}
|
||||
@@ -67,7 +84,7 @@ public class UserController {
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> delete(@PathVariable Long id) {
|
||||
if (!userService.deleteUser(id)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found");
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "用户不存在");
|
||||
}
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
@@ -85,9 +102,20 @@ public class UserController {
|
||||
@NotBlank @Size(max = 20) String status) {
|
||||
}
|
||||
|
||||
public record BulkStatusRequest(
|
||||
@NotEmpty @Size(max = 100) List<Long> ids,
|
||||
@NotBlank @Pattern(regexp = "ACTIVE|INACTIVE") String status) {
|
||||
}
|
||||
|
||||
public record UserPage(List<UserResponse> items, long total, int page, int size) {
|
||||
}
|
||||
|
||||
public record UserSummary(long total, long active, long inactive, long admins) {
|
||||
}
|
||||
|
||||
public record BulkStatusResponse(int updated, String status) {
|
||||
}
|
||||
|
||||
public record UserResponse(Long id, String fullName, String email, String role,
|
||||
String status, LocalDateTime createdAt, LocalDateTime updatedAt) {
|
||||
static UserResponse from(User user) {
|
||||
|
||||
@@ -3,13 +3,22 @@ package com.example.training.service;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.example.training.domain.User;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface UserService {
|
||||
|
||||
Page<User> pageUsers(String keyword, int page, int size);
|
||||
Page<User> pageUsers(String keyword, String role, String status, int page, int size);
|
||||
|
||||
UserSummary summarizeUsers();
|
||||
|
||||
int updateStatuses(List<Long> ids, String status);
|
||||
|
||||
User createUser(String fullName, String email, String role, String status);
|
||||
|
||||
User updateUser(Long id, String fullName, String email, String role, String status);
|
||||
|
||||
boolean deleteUser(Long id);
|
||||
|
||||
record UserSummary(long total, long active, long inactive, long admins) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.example.training.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.example.training.domain.User;
|
||||
import com.example.training.mapper.UserMapper;
|
||||
@@ -8,6 +9,9 @@ import com.example.training.service.UserService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class UserServiceImpl implements UserService {
|
||||
|
||||
@@ -19,19 +23,51 @@ public class UserServiceImpl implements UserService {
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public Page<User> pageUsers(String keyword, int page, int size) {
|
||||
public Page<User> pageUsers(String keyword, String role, String status, int page, int size) {
|
||||
Page<User> requestPage = new Page<>(page + 1L, size);
|
||||
LambdaQueryWrapper<User> query = new LambdaQueryWrapper<>();
|
||||
if (keyword != null && !keyword.isBlank()) {
|
||||
String trimmedKeyword = keyword.trim();
|
||||
query.like(User::getFullName, trimmedKeyword)
|
||||
query.and(search -> search.like(User::getFullName, trimmedKeyword)
|
||||
.or()
|
||||
.like(User::getEmail, trimmedKeyword);
|
||||
.like(User::getEmail, trimmedKeyword));
|
||||
}
|
||||
if (role != null && !role.isBlank()) {
|
||||
query.eq(User::getRole, role.trim());
|
||||
}
|
||||
if (status != null && !status.isBlank()) {
|
||||
query.eq(User::getStatus, status.trim());
|
||||
}
|
||||
query.orderByDesc(User::getCreatedAt);
|
||||
return userMapper.selectPage(requestPage, query);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public UserSummary summarizeUsers() {
|
||||
long total = userMapper.selectCount(new LambdaQueryWrapper<>());
|
||||
long active = userMapper.selectCount(new LambdaQueryWrapper<User>()
|
||||
.eq(User::getStatus, "ACTIVE"));
|
||||
long inactive = userMapper.selectCount(new LambdaQueryWrapper<User>()
|
||||
.eq(User::getStatus, "INACTIVE"));
|
||||
long admins = userMapper.selectCount(new LambdaQueryWrapper<User>()
|
||||
.eq(User::getRole, "Admin"));
|
||||
return new UserSummary(total, active, inactive, admins);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public int updateStatuses(List<Long> ids, String status) {
|
||||
if (ids == null || ids.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
LambdaUpdateWrapper<User> update = new LambdaUpdateWrapper<>();
|
||||
update.in(User::getId, ids)
|
||||
.set(User::getStatus, status)
|
||||
.set(User::getUpdatedAt, LocalDateTime.now());
|
||||
return userMapper.update(null, update);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public User createUser(String fullName, String email, String role, String status) {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.example.training;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.example.training.controller.UserController;
|
||||
import com.example.training.service.UserService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@WebMvcTest(UserController.class)
|
||||
@ActiveProfiles("test")
|
||||
class UserControllerTests {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@MockBean
|
||||
private UserService userService;
|
||||
|
||||
@Test
|
||||
void listPassesRoleAndStatusFilters() throws Exception {
|
||||
when(userService.pageUsers("晓", "Admin", "ACTIVE", 0, 50)).thenReturn(new Page<>(1, 50));
|
||||
|
||||
mockMvc.perform(get("/api/users")
|
||||
.param("keyword", "晓")
|
||||
.param("role", "Admin")
|
||||
.param("status", "ACTIVE"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.total").value(0));
|
||||
|
||||
verify(userService).pageUsers("晓", "Admin", "ACTIVE", 0, 50);
|
||||
}
|
||||
|
||||
@Test
|
||||
void summaryReturnsOverviewCounts() throws Exception {
|
||||
when(userService.summarizeUsers()).thenReturn(new UserService.UserSummary(12, 9, 3, 2));
|
||||
|
||||
mockMvc.perform(get("/api/users/summary"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.total").value(12))
|
||||
.andExpect(jsonPath("$.active").value(9))
|
||||
.andExpect(jsonPath("$.inactive").value(3))
|
||||
.andExpect(jsonPath("$.admins").value(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void bulkStatusUpdatesSelectedUsers() throws Exception {
|
||||
when(userService.updateStatuses(anyList(), eq("INACTIVE"))).thenReturn(2);
|
||||
|
||||
mockMvc.perform(patch("/api/users/status")
|
||||
.contentType("application/json")
|
||||
.content("{\"ids\":[4,7],\"status\":\"INACTIVE\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.updated").value(2))
|
||||
.andExpect(jsonPath("$.status").value("INACTIVE"));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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; } }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user