fix(web): reduce invitation dialog control density

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-10-14 14:18:59 +08:00
parent b67b9553e6
commit 0cfae1799d
4 changed files with 37 additions and 33 deletions

View file

@ -48,3 +48,5 @@
新增历史查询沿用Session认证、禁止缓存及空间OWNER/ADMIN权限;每页数量有上限,按创建时间倒序和独立标识稳定排序。创建/预览/接受现有响应合同保持兼容。审计新增独立邀请标识关联,不包含任何凭证。
邀请历史列表网页每页5条并保持紧凑高度,数据保留规则不变。人员主要展示姓名快照,不以当前账号姓名替换历史;完整稳定账号ID作为悬停/可访问说明,未记录姓名时用短标识兜底,不增加邮箱采集或展示。
弹窗内采用紧凑操作尺寸:主要操作按钮32px、页码28px,收紧按钮与分页间距,日期和数量文字适当缩小;共用分页保留默认样式,仅邀请历史启用紧凑模式。640px弹窗宽度、每页5条及240px列表上限不变。

View file

@ -105,7 +105,7 @@ export function InvitationControls({ slug, compact = false }: { slug: string; co
<div className="rounded-xl border bg-secondary/30 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<span className="flex items-center gap-2 font-medium"><Link2 className="h-4 w-4" aria-hidden="true" />{t('invitation.link')}</span>
<span role="status" className="inline-flex items-center gap-1.5 rounded-full bg-background px-3 py-1 text-xs font-medium">
<span role="status" className="inline-flex items-center gap-1.5 rounded-full bg-background px-2 py-0.5 text-xs font-medium">
{status.isFetching ? <Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" /> : null}
{t(status.isError ? 'invitation.statusError' : status.isFetching ? 'invitation.statusLoading' : status.data?.active ? 'invitation.statusActive' : 'invitation.statusInactive')}
</span>
@ -114,8 +114,8 @@ export function InvitationControls({ slug, compact = false }: { slug: string; co
<div className="mt-4 space-y-2">
<label className="sr-only" htmlFor={linkId}>{t('invitation.link')}</label>
<div className="flex flex-col gap-2 sm:flex-row">
<Input id={linkId} className="min-w-0 flex-1 bg-background font-mono text-xs" readOnly value={link} onFocus={(event) => event.currentTarget.select()} />
<Button className="shrink-0" onClick={() => void copyLink(link)}><Copy className="mr-2 h-4 w-4" aria-hidden="true" />{t('invitation.copy')}</Button>
<Input id={linkId} className="h-8 min-w-0 flex-1 bg-background font-mono text-xs" readOnly value={link} onFocus={(event) => event.currentTarget.select()} />
<Button size="sm" className="shrink-0 gap-1.5 px-2.5" onClick={() => void copyLink(link)}><Copy className="h-3.5 w-3.5" aria-hidden="true" />{t('invitation.copy')}</Button>
</div>
{expiresAt ? <p className="text-xs text-muted-foreground">{t('invitation.expiresAtLabel')}: {expiresAt}</p> : null}
<p className="text-xs leading-relaxed text-muted-foreground">{t(storageUnavailable ? 'invitation.storageUnavailable' : 'invitation.localLinkHint')}</p>
@ -123,10 +123,10 @@ export function InvitationControls({ slug, compact = false }: { slug: string; co
) : null}
{!link && !status.isFetching && !status.isError && status.data?.active ? <p className="mt-3 text-sm leading-relaxed text-muted-foreground">{t('invitation.activeUnavailable')}</p> : null}
{!status.isError && !status.data?.active ? (
<div className="mt-4"><Button disabled={!user || status.isPending || status.isFetching || create.isPending} onClick={handleCreate}><Link2 className="mr-2 h-4 w-4" aria-hidden="true" />{t('invitation.create')}</Button></div>
<div className="mt-4"><Button size="sm" className="gap-1.5 px-2.5" disabled={!user || status.isPending || status.isFetching || create.isPending} onClick={handleCreate}><Link2 className="h-3.5 w-3.5" aria-hidden="true" />{t('invitation.create')}</Button></div>
) : null}
</div>
) : <Button variant="outline" size="sm" disabled={!user || status.isFetching || create.isPending} onClick={handleCreate}>{t('invitation.create')}</Button>}
) : <Button variant="outline" size="sm" className="gap-1.5 px-2.5" disabled={!user || status.isFetching || create.isPending} onClick={handleCreate}>{t('invitation.create')}</Button>}
{!compact ? (
<div className="space-y-3 text-sm">
<p className="font-medium">{t('invitation.detailsTitle')}</p>
@ -137,15 +137,15 @@ export function InvitationControls({ slug, compact = false }: { slug: string; co
{(create.error || revoke.error || status.error) ? (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-destructive/20 bg-destructive/5 p-3">
<p role="alert" className="text-sm text-destructive">{t('invitation.error')}</p>
{status.error ? <Button variant="outline" size="sm" disabled={status.isFetching} onClick={() => void status.refetch()}><RefreshCw className="mr-2 h-4 w-4" aria-hidden="true" />{t('invitation.retry')}</Button> : null}
{status.error ? <Button variant="outline" size="sm" className="gap-1.5 px-2.5" disabled={status.isFetching} onClick={() => void status.refetch()}><RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />{t('invitation.retry')}</Button> : null}
</div>
) : null}
{!compact && !status.isError && status.data?.active ? (
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-4">
<span className="text-xs text-muted-foreground">{t('invitation.managementTitle')}</span>
<div className="flex flex-wrap gap-2">
<Button variant="outline" disabled={!user || status.isFetching || create.isPending || revoke.isPending} onClick={handleCreate}><RefreshCw className="mr-2 h-4 w-4" aria-hidden="true" />{t('invitation.replaceAction')}</Button>
<Button variant="ghost" className="text-destructive hover:bg-destructive/10 hover:text-destructive" disabled={status.isFetching || revoke.isPending || create.isPending} onClick={() => setConfirm('revoke')}><Trash2 className="mr-2 h-4 w-4" aria-hidden="true" />{t('invitation.revoke')}</Button>
<Button variant="outline" size="sm" className="gap-1.5 px-2.5" disabled={!user || status.isFetching || create.isPending || revoke.isPending} onClick={handleCreate}><RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />{t('invitation.replaceAction')}</Button>
<Button variant="ghost" size="sm" className="gap-1.5 px-2.5 text-destructive hover:bg-destructive/10 hover:text-destructive" disabled={status.isFetching || revoke.isPending || create.isPending} onClick={() => setConfirm('revoke')}><Trash2 className="h-3.5 w-3.5" aria-hidden="true" />{t('invitation.revoke')}</Button>
</div>
</div>
) : null}

View file

@ -39,54 +39,54 @@ export function InvitationHistory({ slug }: { slug: string }) {
</span>
}
const status = (item: NamespaceInvitationHistory) => (
<span className="inline-flex flex-wrap items-center gap-1.5">
<span className="rounded-full bg-secondary px-2 py-1 text-xs font-medium">{t(statusKeys[item.status])}</span>
{item.legacy ? <span className="text-xs text-muted-foreground">{t('invitation.historyLegacy')}</span> : null}
<span className="inline-flex flex-wrap items-center gap-1">
<span className="rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium">{t(statusKeys[item.status])}</span>
{item.legacy ? <span className="text-[11px] text-muted-foreground">{t('invitation.historyLegacy')}</span> : null}
</span>
)
const joined = (item: NamespaceInvitationHistory) => item.consumedAt ? (
<>{identity(item.consumedByName, item.consumedBy)}<time className="mt-1 block text-xs text-muted-foreground" dateTime={item.consumedAt}>{formatDate(item.consumedAt)}</time></>
<>{identity(item.consumedByName, item.consumedBy)}<time className="mt-0.5 block text-[11px] text-muted-foreground" dateTime={item.consumedAt}>{formatDate(item.consumedAt)}</time></>
) : <span className="text-muted-foreground">{t('invitation.historyNoJoin')}</span>
return (
<section className="min-w-0 space-y-3 border-t pt-4" aria-label={t('invitation.historyTitle')}>
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold">{t('invitation.historyTitle')}</h3>
<Button variant="ghost" size="sm" disabled={history.isFetching} onClick={() => void history.refetch()}>
<RefreshCw className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />{t('invitation.historyRefresh')}
<Button variant="ghost" size="sm" className="gap-1.5 px-2.5" disabled={history.isFetching} onClick={() => void history.refetch()}>
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />{t('invitation.historyRefresh')}
</Button>
</div>
<p className="text-xs leading-relaxed text-muted-foreground">{t('invitation.historyDescription')}</p>
{history.isPending ? <p role="status" className="flex items-center gap-2 py-4 text-sm text-muted-foreground"><Loader2 aria-hidden="true" className="h-4 w-4 animate-spin" />{t('invitation.historyLoading')}</p> : history.isError ? (
<p role="alert" className="rounded-lg border border-destructive/20 bg-destructive/5 p-3 text-sm text-destructive">{t('invitation.historyError')}</p>
<p role="alert" className="rounded-lg border border-destructive/20 bg-destructive/5 p-2 text-sm text-destructive">{t('invitation.historyError')}</p>
) : history.data?.items.length ? (
<>
<div className="max-h-60 overflow-auto rounded-lg border">
<table className="hidden w-full table-fixed text-left text-xs sm:table">
<caption className="sr-only">{t('invitation.historyTitle')}</caption>
<thead className="sticky top-0 bg-secondary text-muted-foreground"><tr>
<th scope="col" className="w-[26%] p-3 font-medium">{t('invitation.historyCreator')}</th>
<th scope="col" className="w-[27%] p-3 font-medium">{t('invitation.historyCreatedAt')} / {t('invitation.historyExpiresAt')}</th>
<th scope="col" className="w-[20%] p-3 font-medium">{t('invitation.historyStatus')}</th>
<th scope="col" className="p-3 font-medium">{t('invitation.historyJoined')}</th>
<th scope="col" className="w-[26%] p-2 font-medium">{t('invitation.historyCreator')}</th>
<th scope="col" className="w-[27%] p-2 font-medium">{t('invitation.historyCreatedAt')} / {t('invitation.historyExpiresAt')}</th>
<th scope="col" className="w-[20%] p-2 font-medium">{t('invitation.historyStatus')}</th>
<th scope="col" className="p-2 font-medium">{t('invitation.historyJoined')}</th>
</tr></thead>
<tbody>{history.data.items.map((item) => <tr key={item.invitationId} className="border-t align-top">
<td className="p-3">{identity(item.issuedByName, item.issuedBy)}</td>
<td className="space-y-1 p-3"><time className="block" dateTime={item.issuedAt}>{formatDate(item.issuedAt)}</time><time className="block text-muted-foreground" dateTime={item.expiresAt}>{formatDate(item.expiresAt)}</time></td>
<td className="p-3">{status(item)}</td>
<td className="p-3">{joined(item)}</td>
<td className="p-2">{identity(item.issuedByName, item.issuedBy)}</td>
<td className="space-y-0.5 p-2 text-[11px]"><time className="block" dateTime={item.issuedAt}>{formatDate(item.issuedAt)}</time><time className="block text-muted-foreground" dateTime={item.expiresAt}>{formatDate(item.expiresAt)}</time></td>
<td className="p-2">{status(item)}</td>
<td className="p-2">{joined(item)}</td>
</tr>)}</tbody>
</table>
<ul className="divide-y sm:hidden">{history.data.items.map((item) => <li key={item.invitationId} className="space-y-2 p-3 text-xs">
<ul className="divide-y sm:hidden">{history.data.items.map((item) => <li key={item.invitationId} className="space-y-1.5 p-2 text-xs">
<div className="flex items-start justify-between gap-2"><div className="min-w-0 flex-1">{t('invitation.historyCreator')}: {identity(item.issuedByName, item.issuedBy)}</div>{status(item)}</div>
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-2">
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1">
<dt className="text-muted-foreground">{t('invitation.historyCreatedAt')}</dt><dd><time dateTime={item.issuedAt}>{formatDate(item.issuedAt)}</time></dd>
<dt className="text-muted-foreground">{t('invitation.historyExpiresAt')}</dt><dd><time dateTime={item.expiresAt}>{formatDate(item.expiresAt)}</time></dd>
<dt className="text-muted-foreground">{t('invitation.historyJoined')}</dt><dd className="min-w-0">{joined(item)}</dd>
</dl>
</li>)}</ul>
</div>
{history.data.totalPages > 1 ? <Pagination page={page} totalPages={history.data.totalPages} onPageChange={setPage} /> : null}
{history.data.totalPages > 1 ? <Pagination compact page={page} totalPages={history.data.totalPages} onPageChange={setPage} /> : null}
</>
) : <p className="rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">{t('invitation.historyEmpty')}</p>}
</section>

View file

@ -1,10 +1,12 @@
import { useTranslation } from 'react-i18next'
import { cn } from '@/shared/lib/utils'
import { Button } from '@/shared/ui/button'
interface PaginationProps {
page: number
totalPages: number
onPageChange: (page: number) => void
compact?: boolean
}
type PageItem = number | 'ellipsis'
@ -40,28 +42,28 @@ export function buildPageItems(current: number, totalPages: number): PageItem[]
return items
}
export function Pagination({ page, totalPages, onPageChange }: PaginationProps) {
export function Pagination({ page, totalPages, onPageChange, compact = false }: PaginationProps) {
const { t } = useTranslation()
const pageItems = buildPageItems(page, totalPages)
return (
<nav aria-label={t('pagination.label')} className="flex flex-wrap items-center justify-center gap-2 py-4 sm:gap-3">
<nav aria-label={t('pagination.label')} className={cn('flex flex-wrap items-center justify-center', compact ? 'gap-1 py-2' : 'gap-2 py-4 sm:gap-3')}>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(page - 1)}
disabled={page <= 0}
className="min-w-0 flex-1 sm:flex-none sm:min-w-[90px]"
className={compact ? 'shrink-0 gap-1.5 px-2' : 'min-w-0 flex-1 sm:flex-none sm:min-w-[90px]'}
>
{t('pagination.prev')}
</Button>
<div className="order-3 flex w-full items-center justify-center gap-1.5 sm:order-none sm:w-auto">
<div className={cn('flex items-center justify-center', compact ? 'gap-1' : 'order-3 w-full gap-1.5 sm:order-none sm:w-auto')}>
{pageItems.map((item, index) =>
item === 'ellipsis' ? (
<span
key={`ellipsis-${index}`}
className="px-2 text-sm text-muted-foreground select-none"
className={cn('text-muted-foreground select-none', compact ? 'px-1 text-xs' : 'px-2 text-sm')}
aria-hidden="true"
>
…
@ -75,7 +77,7 @@ export function Pagination({ page, totalPages, onPageChange }: PaginationProps)
onClick={() => onPageChange(item)}
aria-label={t('pagination.goToPage', { page: item + 1 })}
aria-current={item === page ? 'page' : undefined}
className="min-w-[2.25rem] h-9 px-2"
className={compact ? 'h-7 min-w-7 px-1.5 text-xs' : 'min-w-[2.25rem] h-9 px-2'}
>
{item + 1}
</Button>
@ -88,7 +90,7 @@ export function Pagination({ page, totalPages, onPageChange }: PaginationProps)
size="sm"
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages - 1}
className="min-w-0 flex-1 sm:flex-none sm:min-w-[90px]"
className={compact ? 'shrink-0 gap-1.5 px-2' : 'min-w-0 flex-1 sm:flex-none sm:min-w-[90px]'}
>
{t('pagination.next')}
</Button>