调整结构

This commit is contained in:
马宝龙
2026-07-28 12:52:36 +08:00
parent f9dcf890b2
commit 94dd560331
61 changed files with 4243 additions and 906 deletions

View File

@@ -0,0 +1,10 @@
import { createBrowserRouter } from 'react-router-dom'
import UserManagementLoader from './user-management/Loader'
export const router = createBrowserRouter([
{
path: '/',
element: <UserManagementLoader />,
},
])

View 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>
)
}

View 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>
)
}

View File

@@ -0,0 +1,3 @@
.root {
min-width: 0;
}

View File

@@ -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>
</>
)
}

View File

@@ -0,0 +1,3 @@
.root {
min-width: 0;
}

View File

@@ -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>
)
}

View File

@@ -0,0 +1,3 @@
.root {
min-width: 0;
}

View File

@@ -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>
)
}

View File

@@ -0,0 +1,3 @@
.root {
min-width: 0;
}

View File

@@ -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>
)
}

View File

@@ -0,0 +1,3 @@
.page {
min-height: 100vh;
}