使用hallmark优化用户管理页面

This commit is contained in:
马宝龙
2026-07-28 18:01:51 +08:00
parent 124a9e1cab
commit 398436fb80
12 changed files with 321 additions and 54 deletions

View File

@@ -1,6 +1,6 @@
import type { FormEvent } from 'react' import type { FormEvent } from 'react'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Check, Info, Plus, X } from 'lucide-react' import { ArrowUpRight, Check, Info, Plus, X } from 'lucide-react'
import { statusLabel } from '../../constants/user' import { statusLabel } from '../../constants/user'
import type { User, UserForm, Status } from '../../interfaces/user/model' import type { User, UserForm, Status } from '../../interfaces/user/model'
@@ -9,11 +9,13 @@ import styles from './index.module.scss'
import DirectoryToolbar from './components/DirectoryToolbar' import DirectoryToolbar from './components/DirectoryToolbar'
import SummaryCards from './components/SummaryCards' import SummaryCards from './components/SummaryCards'
import UserDrawer from './components/UserDrawer' import UserDrawer from './components/UserDrawer'
import UserProfilePanel from './components/UserProfilePanel'
import UserTable from './components/UserTable' import UserTable from './components/UserTable'
export default function UserManagementPage() { export default function UserManagementPage() {
const [keyword, setKeyword] = useState('') const [keyword, setKeyword] = useState('')
const [editingUser, setEditingUser] = useState<User | null>(null) const [editingUser, setEditingUser] = useState<User | null>(null)
const [selectedUserId, setSelectedUserId] = useState<number | null>(null)
const [isFormOpen, setIsFormOpen] = useState(false) const [isFormOpen, setIsFormOpen] = useState(false)
const [notice, setNotice] = useState('') const [notice, setNotice] = useState('')
const { const {
@@ -30,6 +32,16 @@ export default function UserManagementPage() {
useEffect(() => { void refreshData() }, [refreshData, search, roleFilter, statusFilter]) useEffect(() => { void refreshData() }, [refreshData, search, roleFilter, statusFilter])
useEffect(() => {
if (users.length === 0) {
setSelectedUserId(null)
return
}
if (!selectedUserId || !users.some((user) => user.id === selectedUserId)) {
setSelectedUserId(users[0].id)
}
}, [selectedUserId, users])
useEffect(() => { useEffect(() => {
if (!notice) return undefined if (!notice) return undefined
const timer = window.setTimeout(() => setNotice(''), 3200) const timer = window.setTimeout(() => setNotice(''), 3200)
@@ -88,25 +100,29 @@ export default function UserManagementPage() {
const allSelected = users.length > 0 && selectedIds.size === users.length const allSelected = users.length > 0 && selectedIds.size === users.length
const activeFilters = Boolean(search || roleFilter || statusFilter) const activeFilters = Boolean(search || roleFilter || statusFilter)
const combinedError = error const combinedError = error
const selectedUser = users.find((user) => user.id === selectedUserId) ?? users[0] ?? null
return ( return (
<main className={`app-shell ${styles.page}`}> <main className={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> <header className={styles.topbar}><div className={styles.topbarInner}><div className={styles.brandLockup}><span className={styles.brandMark}>U</span><span><strong></strong><small></small></span></div><div className={styles.topbarMeta}><span className={styles.statusDot} /><span className={styles.topbarDivider} /></div></div></header>
<section className="content-wrap" aria-labelledby="page-title"> <section className={styles.content} aria-labelledby="page-title">
<div className="breadcrumb"><span></span><span>/</span><strong></strong></div> <div className={styles.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> <div className={styles.pageHeading}><div><p className={styles.eyebrow}>ROSTER / MEMBER RECORDS</p><h1 id="page-title"></h1><p className={styles.pageSummary}>访</p></div><button className={styles.primaryButton} type="button" onClick={openCreate}><Plus size={17} /></button></div>
<SummaryCards summary={summary} /> <SummaryCards summary={summary} />
<div className="section-heading"><div><h2></h2><p></p></div><span className="result-count"> {total} </span></div> <div className={styles.workspace}>
<section className="directory-section"> <section className={styles.directoryPanel} aria-labelledby="member-list-title">
<DirectoryToolbar keyword={keyword} roleFilter={roleFilter} statusFilter={statusFilter} hasFilters={activeFilters} onKeywordChange={setKeyword} onSearch={submitSearch} onRoleChange={setRoleFilter} onStatusChange={setStatusFilter} onClear={clearAllFilters} /> <div className={styles.panelHeading}><div><p className={styles.eyebrow}>DIRECTORY / </p><h2 id="member-list-title"></h2></div><span> {total} </span></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 updateStatus('ACTIVE')}></button><button type="button" className="bulk-button" disabled={isBulkUpdating} onClick={() => void updateStatus('INACTIVE')}></button></div></div>} <DirectoryToolbar keyword={keyword} roleFilter={roleFilter} statusFilter={statusFilter} hasFilters={activeFilters} onKeywordChange={setKeyword} onSearch={submitSearch} onRoleChange={setRoleFilter} onStatusChange={setStatusFilter} onClear={clearAllFilters} />
{combinedError && <div className="error-banner" role="alert"><span>{combinedError}</span><button type="button" onClick={clearError} aria-label="关闭错误提示"><X size={17} /></button></div>} {selectedIds.size > 0 && <div className={styles.bulkToolbar}><span> <strong>{selectedIds.size}</strong> </span><div><button type="button" className={styles.bulkButton} disabled={isBulkUpdating} onClick={() => void updateStatus('ACTIVE')}></button><button type="button" className={styles.bulkButton} disabled={isBulkUpdating} onClick={() => void updateStatus('INACTIVE')}></button></div></div>}
<div className="table-frame" aria-live="polite"> {combinedError && <div className={styles.errorBanner} role="alert"><span>{combinedError}</span><button type="button" onClick={clearError} aria-label="关闭错误提示"><X size={17} /></button></div>}
{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 className={styles.tableFrame} aria-live="polite">
</div> {isLoading ? <div className={styles.emptyState}><span className={styles.loadingRing} /></div> : users.length === 0 ? <div className={styles.emptyState}><Info className={styles.emptyIcon} size={27} /><strong></strong><span></span>{activeFilters && <button className={styles.secondaryButton} type="button" onClick={clearAllFilters}></button>}</div> : <UserTable users={users} selectedIds={selectedIds} selectedUserId={selectedUser?.id ?? null} allSelected={allSelected} onToggleAll={toggleAll} onToggleSelected={toggleSelected} onSelect={setSelectedUserId} onEdit={openEdit} onDelete={(user) => void removeUser(user)} />}
</section> </div>
</section>
<UserProfilePanel user={selectedUser} onEdit={openEdit} onCreate={openCreate} />
</div>
</section> </section>
{notice && <div className="toast" role="status"><span className="toast-icon"><Check size={12} /></span>{notice}</div>} {notice && <div className={styles.toast} role="status"><span><Check size={12} /></span>{notice}</div>}
<UserDrawer user={editingUser} isOpen={isFormOpen} isSaving={isSaving} error={error} onClose={() => setIsFormOpen(false)} onSubmit={(form) => void submitForm(form)} /> <UserDrawer user={editingUser} isOpen={isFormOpen} isSaving={isSaving} error={error} onClose={() => setIsFormOpen(false)} onSubmit={(form) => void submitForm(form)} />
</main> </main>
) )

View File

@@ -1,3 +1,22 @@
.root { .root { min-width: 0; }
min-width: 0; .searchForm { position: relative; display: flex; align-items: center; gap: 8px; padding: 14px 0 12px; border-bottom: 1px solid var(--page-rule); }
.searchIcon { flex: 0 0 auto; color: var(--page-muted); }
.searchForm input { width: min(280px, 100%); height: 36px; padding: 0; border: 0; outline: 0; background: transparent; color: var(--page-ink); font-size: 12px; }
.searchForm input::placeholder { color: var(--page-muted); }
.searchButton { min-height: 30px; padding: 0 10px; border: 1px solid var(--page-rule); background: transparent; color: var(--page-ink); font-size: 11px; font-weight: 700; }
.searchButton:hover { border-color: var(--page-accent); color: var(--page-accent); }
.filterGroup { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 0 7px; }
.filterGroup select { width: 150px; height: 34px; padding: 0 10px; border: 1px solid var(--page-rule); outline: 0; background: var(--page-surface); color: var(--page-ink); font-size: 11px; }
.filterGroup select:focus { border-color: var(--page-accent); box-shadow: 0 0 0 3px rgb(43 118 128 / 11%); }
.clearButton { padding: 0; border: 0; background: transparent; color: var(--page-accent); font-size: 11px; font-weight: 700; }
.clearButton:hover { text-decoration: underline; }
.statusTabs { display: flex; gap: 3px; border-bottom: 1px solid var(--page-rule); }
.statusTabs button { position: relative; min-height: 35px; padding: 0 12px; border: 0; background: transparent; color: var(--page-muted); font-size: 11px; font-weight: 700; }
.statusTabs button:hover, .statusTabs .active { color: var(--page-ink); }
.statusTabs .active::after { position: absolute; right: 9px; bottom: -1px; left: 9px; height: 2px; background: var(--page-accent); content: ""; }
.srOnly { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@media (max-width: 760px) {
.searchForm input { flex: 1; width: auto; }
.filterGroup { padding-top: 10px; }
} }

View File

@@ -20,25 +20,25 @@ interface DirectoryToolbarProps {
export default function DirectoryToolbar(props: DirectoryToolbarProps) { export default function DirectoryToolbar(props: DirectoryToolbarProps) {
return ( return (
<> <>
<div className={`filter-toolbar ${styles.root}`}> <div className={styles.root}>
<form className="search-form" onSubmit={props.onSearch}> <form className={styles.searchForm} onSubmit={props.onSearch}>
<label className="sr-only" htmlFor="user-search"></label> <label className={styles.srOnly} htmlFor="user-search"></label>
<Search className="search-icon" aria-hidden="true" size={18} /> <Search className={styles.searchIcon} aria-hidden="true" size={17} />
<input id="user-search" value={props.keyword} onChange={(event) => props.onKeywordChange(event.target.value)} placeholder="搜索姓名或邮箱" /> <input id="user-search" value={props.keyword} onChange={(event) => props.onKeywordChange(event.target.value)} placeholder="搜索姓名或邮箱" />
<button type="submit" className="secondary-button"></button> <button type="submit" className={styles.searchButton}></button>
</form> </form>
<div className="filter-group"> <div className={styles.filterGroup}>
<label className="sr-only" htmlFor="role-filter"></label> <label className={styles.srOnly} htmlFor="role-filter"></label>
<select id="role-filter" value={props.roleFilter} onChange={(event) => props.onRoleChange(event.target.value as Role | '')}> <select id="role-filter" value={props.roleFilter} onChange={(event) => props.onRoleChange(event.target.value as Role | '')}>
<option value=""></option> <option value=""></option>
{ROLE_OPTIONS.map((role) => <option value={role} key={role}>{roleLabel(role)}</option>)} {ROLE_OPTIONS.map((role) => <option value={role} key={role}>{roleLabel(role)}</option>)}
</select> </select>
{props.hasFilters && <button className="clear-button" type="button" onClick={props.onClear}></button>} {props.hasFilters && <button className={styles.clearButton} type="button" onClick={props.onClear}></button>}
</div> </div>
</div> </div>
<div className="status-tabs" role="group" aria-label="按状态筛选"> <div className={styles.statusTabs} role="group" aria-label="按状态筛选">
{STATUS_OPTIONS.map((option) => ( {STATUS_OPTIONS.map((option) => (
<button className={props.statusFilter === option.value ? 'active' : ''} type="button" key={option.value} onClick={() => props.onStatusChange(option.value)}> <button className={props.statusFilter === option.value ? styles.active : ''} type="button" key={option.value} onClick={() => props.onStatusChange(option.value)}>
{option.label} {option.label}
</button> </button>
))} ))}

View File

@@ -1,3 +1,16 @@
.root { .root { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border: 1px solid var(--page-rule); background: var(--page-surface); }
min-width: 0; .card { min-width: 0; padding: 17px 19px 15px; border-right: 1px solid var(--page-rule); }
.card:last-child { border-right: 0; }
.cardTop { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--page-muted); font-size: 11px; font-weight: 700; }
.cardTop b { display: grid; width: 28px; height: 28px; place-items: center; background: var(--page-surface-2); color: var(--page-accent); }
.card strong { display: block; margin-top: 10px; color: var(--page-ink); font-family: Georgia, serif; font-size: 27px; font-weight: 400; line-height: 1; }
.card small { display: block; margin-top: 8px; color: var(--page-muted); font-size: 10px; }
.green .cardTop b { color: #3f8a61; }
.amber .cardTop b { color: #ae7a2a; }
.navy .cardTop b { color: #456d80; }
@media (max-width: 760px) {
.root { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.card:nth-child(2) { border-right: 0; }
.card:nth-child(-n + 2) { border-bottom: 1px solid var(--page-rule); }
} }

View File

@@ -25,12 +25,12 @@ export default function SummaryCards({ summary }: SummaryCardsProps) {
] ]
return ( return (
<section className={`summary-grid ${styles.root}`} aria-label="用户概览"> <section className={styles.root} aria-label="用户概览">
{cards.map((card) => { {cards.map((card) => {
const Icon = card.icon const Icon = card.icon
return ( return (
<article className={`summary-card ${card.tone}`} key={card.label}> <article className={`${styles.card} ${styles[card.tone]}`} key={card.label}>
<div className="summary-card-top"><span>{card.label}</span><b aria-hidden="true"><Icon size={15} /></b></div> <div className={styles.cardTop}><span>{card.label}</span><b aria-hidden="true"><Icon size={15} /></b></div>
<strong>{card.value}</strong> <strong>{card.value}</strong>
<small>{card.detail}</small> <small>{card.detail}</small>
</article> </article>

View File

@@ -1,3 +1,30 @@
.root { .backdrop { position: fixed; inset: 0; z-index: 400; display: flex; justify-content: end; background: rgb(16 33 38 / 42%); }
min-width: 0; .drawer { overflow: auto; width: min(100%, 430px); min-height: 100%; padding: 30px; background: var(--page-surface); color: var(--page-ink); box-shadow: -18px 0 35px rgb(16 33 38 / 16%); animation: slideIn .2s ease-out; }
.heading { display: flex; align-items: start; justify-content: space-between; gap: 20px; margin-bottom: 28px; }
.eyebrow { margin: 0; color: var(--page-muted); font-size: 10px; font-weight: 800; letter-spacing: .14em; }
.heading h2 { margin: 11px 0 0; color: var(--page-ink); font-family: Georgia, serif; font-size: 29px; font-weight: 400; letter-spacing: -.04em; }
.heading p:last-child { margin: 9px 0 0; color: var(--page-muted); font-size: 12px; }
.closeButton { display: grid; width: 34px; height: 34px; place-items: center; padding: 0; border: 1px solid var(--page-rule); background: transparent; color: var(--page-ink); }
.closeButton:hover { border-color: var(--page-accent); color: var(--page-accent); }
.drawer form { display: grid; gap: 18px; }
.drawer label { display: grid; gap: 7px; color: var(--page-ink); font-size: 11px; font-weight: 750; }
.drawer input, .drawer select { width: 100%; height: 42px; padding: 0 11px; border: 1px solid var(--page-rule); outline: 2px solid transparent; outline-offset: 1px; background: transparent; color: var(--page-ink); font-size: 12px; }
.drawer input:focus, .drawer select:focus { border-color: var(--page-accent); outline-color: var(--page-accent); }
.error { padding: 11px 12px; border: 1px solid #d7a39a; background: #fff5f2; color: #9d4639; font-size: 11px; line-height: 1.5; }
.formGrid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.note { display: flex; align-items: start; gap: 9px; padding: 11px 12px; background: var(--page-surface-2); color: var(--page-muted); }
.note svg { flex: 0 0 auto; color: var(--page-accent); }
.note p { margin: 0; font-size: 11px; line-height: 1.5; }
.actions { display: flex; justify-content: end; gap: 9px; margin-top: 7px; }
.secondaryButton, .primaryButton { min-height: 42px; padding: 0 15px; border: 1px solid var(--page-rule); font-size: 12px; font-weight: 750; }
.secondaryButton { background: transparent; color: var(--page-ink); }
.secondaryButton:hover { border-color: var(--page-accent); color: var(--page-accent); }
.primaryButton { border-color: var(--page-accent); background: var(--page-accent); color: var(--page-accent-ink); }
.primaryButton:hover { filter: brightness(.95); }
.primaryButton:disabled { cursor: wait; opacity: .6; }
@keyframes slideIn { from { opacity: .35; transform: translateX(20px); } to { opacity: 1; transform: translateX(0); } }
@media (max-width: 640px) {
.drawer { align-self: end; max-height: 94vh; padding: 24px 20px; border-radius: 14px 14px 0 0; animation: riseIn .2s ease-out; }
@keyframes riseIn { from { opacity: .35; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
} }

View File

@@ -29,16 +29,16 @@ export default function UserDrawer(props: UserDrawerProps) {
if (!props.isOpen) return null if (!props.isOpen) return null
return ( return (
<div className={`drawer-backdrop ${styles.root}`} role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) props.onClose() }}> <div className={styles.backdrop} role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) props.onClose() }}>
<aside className="drawer" role="dialog" aria-modal="true" aria-labelledby="form-title"> <aside className={styles.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> <div className={styles.heading}><div><p className={styles.eyebrow}>USER RECORD / </p><h2 id="form-title">{props.user ? '编辑用户' : '新增用户'}</h2><p>访</p></div><button className={styles.closeButton} type="button" aria-label="关闭" onClick={props.onClose}><X size={20} /></button></div>
<form onSubmit={(event) => { event.preventDefault(); props.onSubmit(form) }}> <form onSubmit={(event) => { event.preventDefault(); props.onSubmit(form) }}>
{props.error && <div className="drawer-error" role="alert">{props.error}</div>} {props.error && <div className={styles.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 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> <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={styles.formGrid}><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={styles.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> <div className={styles.actions}><button type="button" className={styles.secondaryButton} onClick={props.onClose}></button><button disabled={props.isSaving} type="submit" className={styles.primaryButton}>{props.isSaving ? '保存中...' : props.user ? '保存修改' : '创建用户'}</button></div>
</form> </form>
</aside> </aside>
</div> </div>

View File

@@ -0,0 +1,39 @@
.panel {
min-width: 0;
padding: 26px 28px 28px;
border-left: 1px solid var(--page-rule);
background: var(--page-surface-2);
}
.panelTop { display: flex; align-items: center; justify-content: space-between; }
.eyebrow { margin: 0; color: var(--page-muted); font-size: 10px; font-weight: 800; letter-spacing: .14em; }
.iconButton { display: grid; width: 32px; height: 32px; place-items: center; padding: 0; border: 1px solid var(--page-rule); background: transparent; color: var(--page-muted); }
.iconButton:hover { border-color: var(--page-accent); color: var(--page-accent); }
.identity { display: grid; justify-items: start; gap: 8px; padding: 33px 0 25px; border-bottom: 1px solid var(--page-rule); }
.avatar { display: grid; width: 66px; height: 66px; place-items: center; border: 1px solid var(--page-accent); border-radius: 50%; color: var(--page-accent); font-size: 16px; font-weight: 850; }
.identity h2 { margin: 4px 0 0; color: var(--page-ink); font-size: 25px; letter-spacing: -.04em; }
.identity p { overflow: hidden; width: 100%; margin: -2px 0 3px; color: var(--page-muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.status { display: inline-flex; align-items: center; gap: 7px; color: var(--page-ink); font-size: 11px; font-weight: 750; }
.status > span { width: 7px; height: 7px; border-radius: 50%; background: var(--page-accent); }
.status.inactive > span { background: var(--page-muted); }
.details { display: grid; gap: 0; margin: 0; padding: 9px 0; border-bottom: 1px solid var(--page-rule); }
.details div { display: flex; justify-content: space-between; gap: 12px; padding: 11px 0; }
.details dt { color: var(--page-muted); font-size: 11px; }
.details dd { margin: 0; color: var(--page-ink); font-size: 11px; text-align: right; }
.access { display: grid; gap: 14px; padding: 23px 0 26px; }
.access .eyebrow { margin-bottom: 2px; }
.access div { display: grid; grid-template-columns: 20px 1fr auto; align-items: center; gap: 4px; color: var(--page-ink); font-size: 11px; }
.access svg { color: var(--page-accent); }
.access small { color: var(--page-muted); font-size: 10px; }
.actions { display: flex; }
.outlineButton { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 42px; width: 100%; border: 1px solid var(--page-rule); background: transparent; color: var(--page-ink); font-size: 11px; font-weight: 750; }
.outlineButton:hover { border-color: var(--page-accent); color: var(--page-accent); }
.emptyProfile { display: grid; justify-items: center; gap: 9px; min-height: 370px; place-content: center; color: var(--page-muted); text-align: center; }
.emptyProfile strong { color: var(--page-ink); font-size: 15px; }
.emptyProfile span { max-width: 180px; font-size: 11px; line-height: 1.5; }
.emptyProfile .outlineButton { width: auto; margin-top: 10px; padding: 0 13px; }
@media (max-width: 760px) {
.panel { border-top: 1px solid var(--page-rule); border-left: 0; }
.identity { padding-top: 25px; }
}

View File

@@ -0,0 +1,34 @@
import { ArrowRight, CheckCircle2, MoreHorizontal, ShieldCheck, UserRound } 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 UserProfilePanelProps {
user: User | null
onEdit: (user: User) => void
onCreate: () => void
}
export default function UserProfilePanel({ user, onEdit, onCreate }: UserProfilePanelProps) {
if (!user) {
return (
<aside className={styles.panel} aria-label="成员档案">
<div className={styles.emptyProfile}><UserRound size={24} /><strong></strong><span></span><button type="button" className={styles.outlineButton} onClick={onCreate}> <ArrowRight size={15} /></button></div>
</aside>
)
}
const isActive = user.status === 'ACTIVE'
return (
<aside className={styles.panel} aria-labelledby="profile-title">
<div className={styles.panelTop}><p className={styles.eyebrow}>MEMBER PROFILE</p><button className={styles.iconButton} type="button" title="更多操作" aria-label="成员更多操作"><MoreHorizontal size={18} /></button></div>
<div className={styles.identity}><span className={styles.avatar}>{initials(user.fullName)}</span><h2 id="profile-title">{user.fullName}</h2><p>{user.email}</p><span className={`${styles.status} ${isActive ? styles.active : styles.inactive}`}><span />{statusLabel(user.status)}</span></div>
<dl className={styles.details}><div><dt></dt><dd>{roleLabel(user.role)}</dd></div><div><dt></dt><dd>{formatDate(user.createdAt)}</dd></div><div><dt></dt><dd>{formatDate(user.updatedAt)}</dd></div></dl>
<section className={styles.access} aria-labelledby="access-title"><p className={styles.eyebrow} id="access-title">ACCESS / 访</p><div><CheckCircle2 size={16} /><span></span><small>{isActive ? '可访问' : '已暂停'}</small></div><div><CheckCircle2 size={16} /><span></span><small>{roleLabel(user.role) === '管理员' ? '可管理' : '可查看'}</small></div><div><ShieldCheck size={16} /><span></span><small>{roleLabel(user.role) === '管理员' ? '已授予' : '未授予'}</small></div></section>
<div className={styles.actions}><button type="button" className={styles.outlineButton} onClick={() => onEdit(user)}> <ArrowRight size={15} /></button></div>
</aside>
)
}

View File

@@ -1,3 +1,42 @@
.root { .root { min-width: 0; overflow-x: auto; }
min-width: 0; .root table { width: 100%; min-width: 700px; border-collapse: collapse; }
.root th, .root td { padding: 13px 12px; border-bottom: 1px solid var(--page-rule); text-align: left; }
.root th { color: var(--page-muted); font-size: 9px; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; }
.root td { color: var(--page-ink); font-size: 12px; }
.root tbody tr { cursor: pointer; }
.root tbody tr:hover, .root tbody .selectedRow { background: var(--page-surface-2); }
.root tbody tr:focus-visible { outline: 2px solid var(--page-accent); outline-offset: -2px; }
.root tbody tr:last-child td { border-bottom: 0; }
.checkColumn { width: 40px; padding-right: 0 !important; padding-left: 0 !important; text-align: center !important; }
.checkColumn input { width: 15px; height: 15px; accent-color: var(--page-accent); }
.userCell { display: flex; align-items: center; gap: 10px; min-width: 205px; }
.avatar { display: grid; width: 34px; height: 34px; flex: 0 0 auto; place-items: center; border: 1px solid var(--page-accent); border-radius: 50%; color: var(--page-accent); font-size: 10px; font-weight: 850; }
.userCell strong, .userCell small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.userCell strong { color: var(--page-ink); font-size: 12px; }
.userCell small { max-width: 180px; margin-top: 4px; color: var(--page-muted); font-size: 10px; }
.roleLabel, .mutedCell { color: var(--page-muted); font-size: 11px; white-space: nowrap; }
.statusLabel { display: inline-flex; align-items: center; gap: 6px; color: var(--page-ink); font-size: 10px; font-weight: 750; white-space: nowrap; }
.statusDot { width: 6px; height: 6px; border-radius: 50%; background: var(--page-accent); }
.statusLabel.inactive .statusDot { background: var(--page-muted); }
.rowActions { display: flex; justify-content: end; gap: 3px; }
.iconButton { display: grid; width: 30px; height: 30px; place-items: center; padding: 0; border: 1px solid transparent; background: transparent; color: var(--page-muted); }
.iconButton:hover { border-color: var(--page-rule); background: var(--page-surface); color: var(--page-accent); }
.dangerAction:hover { color: #b15a4c; }
.srOnly { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@media (max-width: 760px) {
.root { overflow-x: visible; }
.root table, .root tbody { display: block; min-width: 0; }
.root thead { display: none; }
.root tbody { padding: 4px 0; }
.root tbody tr { display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; gap: 0 8px; padding: 12px 0; border-bottom: 1px solid var(--page-rule); }
.root tbody td { display: flex; align-items: center; min-height: 25px; padding: 3px 0; border: 0; }
.root tbody td::before { width: 65px; flex: 0 0 65px; color: var(--page-muted); font-size: 9px; font-weight: 700; content: attr(data-label); }
.root tbody .checkColumn { grid-row: span 3; align-items: start; padding-top: 8px; }
.root tbody .checkColumn::before, .root tbody td[data-label="用户"]::before, .root tbody td[data-label="操作"]::before { display: none; }
.root tbody td[data-label="用户"] { grid-column: 2 / 4; }
.root tbody td[data-label="角色"], .root tbody td[data-label="状态"], .root tbody td[data-label="加入时间"], .root tbody td[data-label="最近更新"] { grid-column: 2 / 4; }
.root tbody td[data-label="操作"] { grid-column: 3; grid-row: 1; align-self: start; }
.userCell { min-width: 0; }
.userCell small { max-width: 190px; }
} }

View File

@@ -9,28 +9,30 @@ import styles from './index.module.scss'
interface UserTableProps { interface UserTableProps {
users: User[] users: User[]
selectedIds: Set<number> selectedIds: Set<number>
selectedUserId: number | null
allSelected: boolean allSelected: boolean
onToggleAll: () => void onToggleAll: () => void
onToggleSelected: (id: number) => void onToggleSelected: (id: number) => void
onSelect: (id: number) => void
onEdit: (user: User) => void onEdit: (user: User) => void
onDelete: (user: User) => void onDelete: (user: User) => void
} }
export default function UserTable(props: UserTableProps) { export default function UserTable(props: UserTableProps) {
return ( return (
<div className={`table-scroll ${styles.root}`}> <div className={styles.root}>
<table> <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> <thead><tr><th className={styles.checkColumn}><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={styles.srOnly}></span></th></tr></thead>
<tbody> <tbody>
{props.users.map((user) => ( {props.users.map((user) => (
<tr key={user.id} className={classNames({ 'selected-row': props.selectedIds.has(user.id) })}> <tr key={user.id} className={classNames(styles.row, { [styles.selectedRow]: props.selectedUserId === user.id })} onClick={() => props.onSelect(user.id)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); props.onSelect(user.id) } }} tabIndex={0} aria-selected={props.selectedUserId === 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 className={styles.checkColumn} data-label="选择"><input type="checkbox" aria-label={`选择${user.fullName}`} checked={props.selectedIds.has(user.id)} onClick={(event) => event.stopPropagation()} 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="用户"><div className={styles.userCell}><span className={styles.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={styles.roleLabel}>{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="状态"><span className={`${styles.statusLabel} ${user.status === 'ACTIVE' ? styles.active : styles.inactive}`}><span className={styles.statusDot} />{statusLabel(user.status)}</span></td>
<td data-label="加入时间" className="muted-cell">{formatDate(user.createdAt)}</td> <td data-label="加入时间" className={styles.mutedCell}>{formatDate(user.createdAt)}</td>
<td data-label="最近更新" className="muted-cell">{formatDate(user.updatedAt)}</td> <td data-label="最近更新" className={styles.mutedCell}>{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> <td data-label="操作"><div className={styles.rowActions}><button className={styles.iconButton} type="button" title="编辑用户" aria-label={`编辑${user.fullName}`} onClick={(event) => { event.stopPropagation(); props.onEdit(user) }}><Pencil size={16} /></button><button className={`${styles.iconButton} ${styles.dangerAction}`} type="button" title="删除用户" aria-label={`删除${user.fullName}`} onClick={(event) => { event.stopPropagation(); void props.onDelete(user) }}><Trash2 size={16} /></button></div></td>
</tr> </tr>
))} ))}
</tbody> </tbody>

View File

@@ -1,3 +1,81 @@
.page { .page {
--page-paper: #e1e8eb;
--page-surface: #f8faf9;
--page-surface-2: #edf2f2;
--page-ink: #23373e;
--page-muted: #73878d;
--page-rule: #cbd8db;
--page-accent: #2b7680;
--page-accent-ink: #fff;
min-height: 100vh; min-height: 100vh;
overflow-x: clip;
background: var(--page-paper);
color: var(--page-ink);
}
.topbar { min-height: 72px; border-bottom: 1px solid var(--page-rule); background: var(--page-surface); }
.topbarInner { display: flex; align-items: center; justify-content: space-between; width: min(1240px, 91vw); min-height: 72px; margin: 0 auto; }
.brandLockup { display: flex; align-items: center; gap: 10px; }
.brandMark { display: grid; width: 30px; height: 30px; place-items: center; background: var(--page-accent); color: var(--page-accent-ink); font-size: 12px; font-weight: 900; }
.brandLockup > span:last-child { display: grid; gap: 3px; }
.brandLockup strong { color: var(--page-ink); font-size: 13px; }
.brandLockup small { color: var(--page-muted); font-size: 10px; }
.topbarMeta { display: flex; align-items: center; gap: 9px; color: var(--page-muted); font-size: 11px; }
.statusDot { width: 7px; height: 7px; border-radius: 50%; background: #43a276; }
.topbarDivider { width: 1px; height: 14px; margin: 0 4px; background: var(--page-rule); }
.content { width: min(1240px, 91vw); margin: 0 auto; padding: 32px 0 70px; }
.breadcrumb { display: flex; align-items: center; gap: 8px; color: var(--page-muted); font-size: 11px; }
.breadcrumb strong { color: var(--page-ink); font-weight: 750; }
.pageHeading { display: flex; align-items: end; justify-content: space-between; gap: 24px; padding: 32px 0 28px; }
.eyebrow { margin: 0 0 9px; color: var(--page-muted); font-size: 10px; font-weight: 800; letter-spacing: .14em; }
.pageHeading h1 { margin: 0; color: var(--page-ink); font-family: Georgia, 'Times New Roman', serif; font-size: clamp(36px, 4.2vw, 55px); font-weight: 400; letter-spacing: -.06em; line-height: 1; }
.pageSummary { margin: 12px 0 0; color: var(--page-muted); font-size: 13px; }
.primaryButton { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 42px; padding: 0 16px; border: 1px solid var(--page-accent); background: var(--page-accent); color: var(--page-accent-ink); font-size: 12px; font-weight: 750; }
.primaryButton:hover { filter: brightness(.95); }
.workspace { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(290px, .55fr); margin-top: 28px; border: 1px solid var(--page-rule); background: var(--page-surface); }
.directoryPanel { min-width: 0; padding: 26px 28px 24px; }
.panelHeading { display: flex; align-items: end; justify-content: space-between; gap: 16px; }
.panelHeading .eyebrow { margin-bottom: 7px; }
.panelHeading h2 { margin: 0; color: var(--page-ink); font-size: 20px; letter-spacing: -.04em; }
.panelHeading > span { color: var(--page-muted); font-size: 11px; white-space: nowrap; }
.tableFrame { min-height: 300px; }
.bulkToolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 45px; padding: 0 11px; background: var(--page-surface-2); color: var(--page-muted); font-size: 11px; }
.bulkToolbar strong { color: var(--page-ink); }
.bulkToolbar > div { display: flex; gap: 6px; }
.bulkButton { min-height: 29px; padding: 0 9px; border: 1px solid var(--page-rule); background: var(--page-surface); color: var(--page-ink); font-size: 10px; font-weight: 700; }
.bulkButton:hover:not(:disabled) { border-color: var(--page-accent); color: var(--page-accent); }
.bulkButton:disabled { cursor: wait; opacity: .55; }
.errorBanner { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 14px 0 0; padding: 10px 11px; border: 1px solid #d7a39a; background: #fff5f2; color: #9d4639; font-size: 11px; }
.errorBanner button { display: grid; width: 24px; height: 24px; place-items: center; padding: 0; border: 0; background: transparent; color: inherit; }
.emptyState { display: flex; min-height: 300px; flex-direction: column; align-items: center; justify-content: center; gap: 8px; color: var(--page-muted); font-size: 11px; text-align: center; }
.emptyState strong { color: var(--page-ink); font-size: 15px; }
.emptyIcon { color: var(--page-accent); }
.secondaryButton { min-height: 36px; margin-top: 7px; padding: 0 12px; border: 1px solid var(--page-rule); background: transparent; color: var(--page-ink); font-size: 11px; }
.secondaryButton:hover { border-color: var(--page-accent); color: var(--page-accent); }
.loadingRing { width: 20px; height: 20px; border: 2px solid var(--page-rule); border-top-color: var(--page-accent); border-radius: 50%; animation: spin .7s linear infinite; }
.toast { position: fixed; right: 22px; bottom: 22px; z-index: 500; display: flex; align-items: center; gap: 8px; min-height: 42px; padding: 0 14px; border: 1px solid #b8d7c8; background: #f0faf4; color: #28724f; box-shadow: 0 10px 25px rgb(21 67 51 / 13%); font-size: 11px; font-weight: 700; }
.toast > span { display: grid; width: 18px; height: 18px; place-items: center; border-radius: 50%; background: #41a275; color: #fff; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 960px) {
.workspace { grid-template-columns: minmax(0, 1fr) minmax(250px, .55fr); }
.directoryPanel { padding-inline: 20px; }
}
@media (max-width: 760px) {
.topbarInner, .content { width: 90vw; }
.topbar { min-height: 64px; }
.topbarInner { min-height: 64px; }
.topbarMeta { display: none; }
.content { padding-top: 24px; padding-bottom: 45px; }
.pageHeading { align-items: start; flex-direction: column; gap: 19px; padding-block: 25px 23px; }
.pageHeading .primaryButton { width: 100%; }
.workspace { grid-template-columns: 1fr; margin-top: 22px; }
.directoryPanel { padding: 20px 15px 18px; }
.panelHeading { align-items: start; flex-direction: column; gap: 7px; }
.panelHeading > span { align-self: end; }
.bulkToolbar { align-items: start; flex-direction: column; padding-block: 9px; }
.bulkToolbar > div { width: 100%; }
.bulkButton { flex: 1; }
.toast { right: 14px; bottom: 14px; left: 14px; justify-content: center; }
} }