From 52cbed63a87912077d15eefbd50512819e17991a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A9=AC=E5=AE=9D=E9=BE=99?= Date: Tue, 28 Jul 2026 11:35:34 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .../training/controller/UserController.java | 34 ++- .../example/training/service/UserService.java | 11 +- .../service/impl/UserServiceImpl.java | 42 ++- .../example/training/UserControllerTests.java | 69 +++++ frontend/src/App.tsx | 283 ++++++++++++++---- frontend/src/styles.css | 222 ++++++++++---- 7 files changed, 526 insertions(+), 136 deletions(-) create mode 100644 backend/src/test/java/com/example/training/UserControllerTests.java diff --git a/.gitignore b/.gitignore index 558a066..3ca41d6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ .vscode/ .m2/ .m2-local/ +.npm-cache .tmp-db-query/ .tmp-vite/ diff --git a/backend/src/main/java/com/example/training/controller/UserController.java b/backend/src/main/java/com/example/training/controller/UserController.java index cf15034..f7a390a 100644 --- a/backend/src/main/java/com/example/training/controller/UserController.java +++ b/backend/src/main/java/com/example/training/controller/UserController.java @@ -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 resultPage = userService.pageUsers(keyword, safePage, safeSize); + Page 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 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 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 ids, + @NotBlank @Pattern(regexp = "ACTIVE|INACTIVE") String status) { + } + public record UserPage(List 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) { diff --git a/backend/src/main/java/com/example/training/service/UserService.java b/backend/src/main/java/com/example/training/service/UserService.java index 61f58f7..9ec96a8 100644 --- a/backend/src/main/java/com/example/training/service/UserService.java +++ b/backend/src/main/java/com/example/training/service/UserService.java @@ -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 pageUsers(String keyword, int page, int size); + Page pageUsers(String keyword, String role, String status, int page, int size); + + UserSummary summarizeUsers(); + + int updateStatuses(List 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) { + } } diff --git a/backend/src/main/java/com/example/training/service/impl/UserServiceImpl.java b/backend/src/main/java/com/example/training/service/impl/UserServiceImpl.java index 952dc3d..7a69335 100644 --- a/backend/src/main/java/com/example/training/service/impl/UserServiceImpl.java +++ b/backend/src/main/java/com/example/training/service/impl/UserServiceImpl.java @@ -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 pageUsers(String keyword, int page, int size) { + public Page pageUsers(String keyword, String role, String status, int page, int size) { Page requestPage = new Page<>(page + 1L, size); LambdaQueryWrapper 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() + .eq(User::getStatus, "ACTIVE")); + long inactive = userMapper.selectCount(new LambdaQueryWrapper() + .eq(User::getStatus, "INACTIVE")); + long admins = userMapper.selectCount(new LambdaQueryWrapper() + .eq(User::getRole, "Admin")); + return new UserSummary(total, active, inactive, admins); + } + + @Override + @Transactional + public int updateStatuses(List ids, String status) { + if (ids == null || ids.isEmpty()) { + return 0; + } + LambdaUpdateWrapper 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) { diff --git a/backend/src/test/java/com/example/training/UserControllerTests.java b/backend/src/test/java/com/example/training/UserControllerTests.java new file mode 100644 index 0000000..82ab9a9 --- /dev/null +++ b/backend/src/test/java/com/example/training/UserControllerTests.java @@ -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")); + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 81d86a9..e30847a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 +type UserForm = Omit + +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([]) const [total, setTotal] = useState(0) + const [summary, setSummary] = useState({ 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(emptyForm) const [editingId, setEditingId] = useState(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>(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) { 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 (
-
-
U
-
-

Console

-

Operations workspace

+
+
+
U
+
+

云端工作台

+

组织管理中心

+
+
系统运行正常管理员视图
- API connected
+
工作台/用户管理
-

DIRECTORY

-

User management

-

Manage access, roles, and account status from one place.

+

成员目录 / DIRECTORY

+

用户管理

+

集中管理组织成员、角色和访问状态。

- +
-
-
- - setKeyword(event.target.value)} placeholder="Search by name or email" /> - -
- {total} {total === 1 ? 'user' : 'users'} -
+
+ {summaryCards.map((card) => ( +
+
{card.label}
+ {card.value} + {card.detail} +
+ ))} +
- {error &&
{error}
} +

成员列表

查看并更新组织内的账号信息。

共 {total} 位用户
-
- {isLoading ?
Loading users...
: users.length === 0 ? ( -
No users foundTry another search or add a new user.
- ) : ( -
- - - - {users.map((user) => ( - - - - - - - - ))} - -
UserRoleStatusCreatedActions
{user.fullName.charAt(0).toUpperCase()}{user.fullName}{user.email}
{user.role}{user.status === 'ACTIVE' ? 'Active' : 'Inactive'}{new Date(user.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
+
+
+
+ + + setKeyword(event.target.value)} placeholder="搜索姓名或邮箱" /> + +
+
+ + + {activeFilters && }
- )} +
+
+ {[['', '全部'], ['ACTIVE', '活跃'], ['INACTIVE', '未激活']].map(([value, label]) => ( + + ))} +
+ + {selectedIds.size > 0 &&
已选择 {selectedIds.size} 位用户
} + + {error &&
{error}
} + +
+ {isLoading ?
正在加载用户列表
: users.length === 0 ? ( +
没有找到匹配用户试试其他关键词,或直接新增一个用户。{activeFilters && }
+ ) : ( +
+ + + + {users.map((user) => ( + + + + + + + + + + ))} + +
用户角色状态加入时间最近更新操作
toggleSelected(user.id)} />
{initials(user.fullName)}{user.fullName}{user.email}
{roleLabel(user.role)}{statusLabel(user.status)}{formatDate(user.createdAt)}{formatDate(user.updatedAt)}
+
+ )} +
+ {notice &&
{notice}
} + {isFormOpen && ( -
{ if (event.target === event.currentTarget) setIsFormOpen(false) }}> -
-

USER RECORD

{editingId ? 'Edit user' : 'Add user'}

+
{ if (event.target === event.currentTarget) setIsFormOpen(false) }}> +
+
)}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 7b63931..8a8cf98 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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; } } }