调整结构
This commit is contained in:
10
frontend/src/routes/index.tsx
Normal file
10
frontend/src/routes/index.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createBrowserRouter } from 'react-router-dom'
|
||||
|
||||
import UserManagementLoader from './user-management/Loader'
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
element: <UserManagementLoader />,
|
||||
},
|
||||
])
|
||||
11
frontend/src/routes/user-management/Loader.tsx
Normal file
11
frontend/src/routes/user-management/Loader.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { lazy, Suspense } from 'react'
|
||||
|
||||
const UserManagementPage = lazy(() => import('./Page'))
|
||||
|
||||
export default function UserManagementLoader() {
|
||||
return (
|
||||
<Suspense fallback={<main className="app-shell"><div className="empty-state">正在加载页面</div></main>}>
|
||||
<UserManagementPage />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
113
frontend/src/routes/user-management/Page.tsx
Normal file
113
frontend/src/routes/user-management/Page.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { FormEvent } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Check, Info, Plus, X } from 'lucide-react'
|
||||
|
||||
import { statusLabel } from '../../constants/user'
|
||||
import type { User, UserForm, Status } from '../../interfaces/user/model'
|
||||
import { useUserStore } from '../../stores/userStore'
|
||||
import styles from './index.module.scss'
|
||||
import DirectoryToolbar from './components/DirectoryToolbar'
|
||||
import SummaryCards from './components/SummaryCards'
|
||||
import UserDrawer from './components/UserDrawer'
|
||||
import UserTable from './components/UserTable'
|
||||
|
||||
export default function UserManagementPage() {
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null)
|
||||
const [isFormOpen, setIsFormOpen] = useState(false)
|
||||
const [notice, setNotice] = useState('')
|
||||
const {
|
||||
users, total, summary, search, roleFilter, statusFilter, selectedIds,
|
||||
isLoading, isSaving, isBulkUpdating, error,
|
||||
setSearch, setRoleFilter, setStatusFilter, clearFilters,
|
||||
loadUsers, loadSummary, createUser, updateUser, deleteUser, updateSelectedStatus,
|
||||
toggleSelected, toggleAll, clearError,
|
||||
} = useUserStore()
|
||||
|
||||
const refreshData = useCallback(async () => {
|
||||
await Promise.all([loadUsers(), loadSummary()])
|
||||
}, [loadSummary, loadUsers])
|
||||
|
||||
useEffect(() => { void refreshData() }, [refreshData, search, roleFilter, statusFilter])
|
||||
|
||||
useEffect(() => {
|
||||
if (!notice) return undefined
|
||||
const timer = window.setTimeout(() => setNotice(''), 3200)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [notice])
|
||||
|
||||
function openCreate() {
|
||||
setEditingUser(null)
|
||||
clearError()
|
||||
setIsFormOpen(true)
|
||||
}
|
||||
|
||||
function openEdit(user: User) {
|
||||
setEditingUser(user)
|
||||
clearError()
|
||||
setIsFormOpen(true)
|
||||
}
|
||||
|
||||
async function submitForm(form: UserForm) {
|
||||
const success = editingUser ? await updateUser(editingUser.id, form) : await createUser(form)
|
||||
if (!success) return
|
||||
setIsFormOpen(false)
|
||||
setNotice(editingUser ? '用户信息已更新' : '用户已创建')
|
||||
await refreshData()
|
||||
}
|
||||
|
||||
async function removeUser(user: User) {
|
||||
if (!window.confirm(`确定删除用户“${user.fullName}”吗?此操作不可撤销。`)) return
|
||||
const success = await deleteUser(user.id)
|
||||
if (success) {
|
||||
setNotice('用户已删除')
|
||||
await refreshData()
|
||||
}
|
||||
}
|
||||
|
||||
async function updateStatus(status: Status) {
|
||||
if (selectedIds.size === 0) return
|
||||
if (!window.confirm(`确定将选中的 ${selectedIds.size} 位用户设为“${statusLabel(status)}”吗?`)) return
|
||||
const success = await updateSelectedStatus(status)
|
||||
if (success) {
|
||||
setNotice(`已将选中的用户设为${statusLabel(status)}`)
|
||||
await refreshData()
|
||||
}
|
||||
}
|
||||
|
||||
function submitSearch(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
setSearch(keyword.trim())
|
||||
}
|
||||
|
||||
function clearAllFilters() {
|
||||
setKeyword('')
|
||||
clearFilters()
|
||||
}
|
||||
|
||||
const allSelected = users.length > 0 && selectedIds.size === users.length
|
||||
const activeFilters = Boolean(search || roleFilter || statusFilter)
|
||||
const combinedError = error
|
||||
|
||||
return (
|
||||
<main className={`app-shell ${styles.page}`}>
|
||||
<header className="topbar"><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></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">用户管理</h1><p className="page-summary">集中管理组织成员、角色和访问状态。</p></div><button className="primary-button" type="button" onClick={openCreate}><Plus className="button-icon" size={17} />新增用户</button></div>
|
||||
<SummaryCards summary={summary} />
|
||||
<div className="section-heading"><div><h2>成员列表</h2><p>查看并更新组织内的账号信息。</p></div><span className="result-count">共 {total} 位用户</span></div>
|
||||
<section className="directory-section">
|
||||
<DirectoryToolbar keyword={keyword} roleFilter={roleFilter} statusFilter={statusFilter} hasFilters={activeFilters} onKeywordChange={setKeyword} onSearch={submitSearch} onRoleChange={setRoleFilter} onStatusChange={setStatusFilter} onClear={clearAllFilters} />
|
||||
{selectedIds.size > 0 && <div className="bulk-toolbar"><span>已选择 <strong>{selectedIds.size}</strong> 位用户</span><div><button type="button" className="bulk-button" disabled={isBulkUpdating} onClick={() => void updateStatus('ACTIVE')}>设为活跃</button><button type="button" className="bulk-button" disabled={isBulkUpdating} onClick={() => void updateStatus('INACTIVE')}>设为未激活</button></div></div>}
|
||||
{combinedError && <div className="error-banner" role="alert"><span>{combinedError}</span><button type="button" onClick={clearError} aria-label="关闭错误提示"><X size={17} /></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"><Info className="empty-icon" size={27} /><strong>没有找到匹配用户</strong><span>试试其他关键词,或直接新增一个用户。</span>{activeFilters && <button className="secondary-button" type="button" onClick={clearAllFilters}>清除筛选</button>}</div> : <UserTable users={users} selectedIds={selectedIds} allSelected={allSelected} onToggleAll={toggleAll} onToggleSelected={toggleSelected} onEdit={openEdit} onDelete={(user) => void removeUser(user)} />}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
{notice && <div className="toast" role="status"><span className="toast-icon"><Check size={12} /></span>{notice}</div>}
|
||||
<UserDrawer user={editingUser} isOpen={isFormOpen} isSaving={isSaving} error={error} onClose={() => setIsFormOpen(false)} onSubmit={(form) => void submitForm(form)} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.root {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { FormEvent } from 'react'
|
||||
import { Search } from 'lucide-react'
|
||||
|
||||
import { ROLE_OPTIONS, roleLabel, STATUS_OPTIONS } from '../../../../constants/user'
|
||||
import type { Role, Status } from '../../../../interfaces/user/model'
|
||||
import styles from './index.module.scss'
|
||||
|
||||
interface DirectoryToolbarProps {
|
||||
keyword: string
|
||||
roleFilter: Role | ''
|
||||
statusFilter: Status | ''
|
||||
hasFilters: boolean
|
||||
onKeywordChange: (value: string) => void
|
||||
onSearch: (event: FormEvent<HTMLFormElement>) => void
|
||||
onRoleChange: (value: Role | '') => void
|
||||
onStatusChange: (value: Status | '') => void
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
export default function DirectoryToolbar(props: DirectoryToolbarProps) {
|
||||
return (
|
||||
<>
|
||||
<div className={`filter-toolbar ${styles.root}`}>
|
||||
<form className="search-form" onSubmit={props.onSearch}>
|
||||
<label className="sr-only" htmlFor="user-search">搜索用户</label>
|
||||
<Search className="search-icon" aria-hidden="true" size={18} />
|
||||
<input id="user-search" value={props.keyword} onChange={(event) => props.onKeywordChange(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={props.roleFilter} onChange={(event) => props.onRoleChange(event.target.value as Role | '')}>
|
||||
<option value="">全部角色</option>
|
||||
{ROLE_OPTIONS.map((role) => <option value={role} key={role}>{roleLabel(role)}</option>)}
|
||||
</select>
|
||||
{props.hasFilters && <button className="clear-button" type="button" onClick={props.onClear}>清除筛选</button>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="status-tabs" role="group" aria-label="按状态筛选">
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<button className={props.statusFilter === option.value ? 'active' : ''} type="button" key={option.value} onClick={() => props.onStatusChange(option.value)}>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.root {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ShieldCheck, UserCheck, Users, UserX } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
import type { UserSummary } from '../../../../interfaces/user/model'
|
||||
import styles from './index.module.scss'
|
||||
|
||||
interface SummaryCardsProps {
|
||||
summary: UserSummary
|
||||
}
|
||||
|
||||
interface SummaryCard {
|
||||
label: string
|
||||
value: number
|
||||
detail: string
|
||||
icon: LucideIcon
|
||||
tone: string
|
||||
}
|
||||
|
||||
export default function SummaryCards({ summary }: SummaryCardsProps) {
|
||||
const cards: SummaryCard[] = [
|
||||
{ label: '全部用户', value: summary.total, detail: '当前组织账号', icon: Users, tone: 'teal' },
|
||||
{ label: '活跃用户', value: summary.active, detail: '可以正常访问', icon: UserCheck, tone: 'green' },
|
||||
{ label: '未激活', value: summary.inactive, detail: '等待重新启用', icon: UserX, tone: 'amber' },
|
||||
{ label: '管理员', value: summary.admins, detail: '拥有管理权限', icon: ShieldCheck, tone: 'navy' },
|
||||
]
|
||||
|
||||
return (
|
||||
<section className={`summary-grid ${styles.root}`} aria-label="用户概览">
|
||||
{cards.map((card) => {
|
||||
const Icon = card.icon
|
||||
return (
|
||||
<article className={`summary-card ${card.tone}`} key={card.label}>
|
||||
<div className="summary-card-top"><span>{card.label}</span><b aria-hidden="true"><Icon size={15} /></b></div>
|
||||
<strong>{card.value}</strong>
|
||||
<small>{card.detail}</small>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.root {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Info, X } from 'lucide-react'
|
||||
|
||||
import { EMPTY_USER_FORM, ROLE_OPTIONS, roleLabel } from '../../../../constants/user'
|
||||
import type { User, UserForm, Status } from '../../../../interfaces/user/model'
|
||||
import styles from './index.module.scss'
|
||||
|
||||
interface UserDrawerProps {
|
||||
user: User | null
|
||||
isOpen: boolean
|
||||
isSaving: boolean
|
||||
error: string
|
||||
onClose: () => void
|
||||
onSubmit: (form: UserForm) => void
|
||||
}
|
||||
|
||||
export default function UserDrawer(props: UserDrawerProps) {
|
||||
const [form, setForm] = useState<UserForm>(EMPTY_USER_FORM)
|
||||
|
||||
useEffect(() => {
|
||||
setForm(props.user ? {
|
||||
fullName: props.user.fullName,
|
||||
email: props.user.email,
|
||||
role: props.user.role,
|
||||
status: props.user.status,
|
||||
} : EMPTY_USER_FORM)
|
||||
}, [props.user, props.isOpen])
|
||||
|
||||
if (!props.isOpen) return null
|
||||
|
||||
return (
|
||||
<div className={`drawer-backdrop ${styles.root}`} role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) props.onClose() }}>
|
||||
<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">{props.user ? '编辑用户' : '新增用户'}</h2><p>填写账号资料并设置访问状态。</p></div><button className="close-button" type="button" aria-label="关闭" onClick={props.onClose}><X size={20} /></button></div>
|
||||
<form onSubmit={(event) => { event.preventDefault(); props.onSubmit(form) }}>
|
||||
{props.error && <div className="drawer-error" role="alert">{props.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 as UserForm['role'] })}>{ROLE_OPTIONS.map((role) => <option value={role} key={role}>{roleLabel(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"><Info size={17} /><p>停用账号后,用户将无法访问组织工作台。</p></div>
|
||||
<div className="drawer-actions"><button type="button" className="secondary-button" onClick={props.onClose}>取消</button><button disabled={props.isSaving} type="submit" className="primary-button">{props.isSaving ? '保存中...' : props.user ? '保存修改' : '创建用户'}</button></div>
|
||||
</form>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.root {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import classNames from 'classnames'
|
||||
import { Pencil, Trash2 } from 'lucide-react'
|
||||
|
||||
import { roleLabel, statusLabel } from '../../../../constants/user'
|
||||
import type { User } from '../../../../interfaces/user/model'
|
||||
import { formatDate, initials } from '../../../../utils/user'
|
||||
import styles from './index.module.scss'
|
||||
|
||||
interface UserTableProps {
|
||||
users: User[]
|
||||
selectedIds: Set<number>
|
||||
allSelected: boolean
|
||||
onToggleAll: () => void
|
||||
onToggleSelected: (id: number) => void
|
||||
onEdit: (user: User) => void
|
||||
onDelete: (user: User) => void
|
||||
}
|
||||
|
||||
export default function UserTable(props: UserTableProps) {
|
||||
return (
|
||||
<div className={`table-scroll ${styles.root}`}>
|
||||
<table>
|
||||
<thead><tr><th className="check-column"><input type="checkbox" aria-label="选择全部用户" checked={props.allSelected} onChange={props.onToggleAll} /></th><th>用户</th><th>角色</th><th>状态</th><th>加入时间</th><th>最近更新</th><th><span className="sr-only">操作</span></th></tr></thead>
|
||||
<tbody>
|
||||
{props.users.map((user) => (
|
||||
<tr key={user.id} className={classNames({ 'selected-row': props.selectedIds.has(user.id) })}>
|
||||
<td className="check-column" data-label="选择"><input type="checkbox" aria-label={`选择${user.fullName}`} checked={props.selectedIds.has(user.id)} onChange={() => props.onToggleSelected(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={() => props.onEdit(user)}><Pencil size={16} /></button><button className="icon-button danger-action" type="button" title="删除用户" aria-label={`删除${user.fullName}`} onClick={() => props.onDelete(user)}><Trash2 size={16} /></button></div></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
3
frontend/src/routes/user-management/index.module.scss
Normal file
3
frontend/src/routes/user-management/index.module.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
}
|
||||
Reference in New Issue
Block a user