mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-15 04:18:20 +00:00
fix(web): reduce invitation dialog control density
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
b67b9553e6
commit
0cfae1799d
4 changed files with 37 additions and 33 deletions
|
|
@ -48,3 +48,5 @@
|
|||
新增历史查询沿用Session认证、禁止缓存及空间OWNER/ADMIN权限;每页数量有上限,按创建时间倒序和独立标识稳定排序。创建/预览/接受现有响应合同保持兼容。审计新增独立邀请标识关联,不包含任何凭证。
|
||||
|
||||
邀请历史列表网页每页5条并保持紧凑高度,数据保留规则不变。人员主要展示姓名快照,不以当前账号姓名替换历史;完整稳定账号ID作为悬停/可访问说明,未记录姓名时用短标识兜底,不增加邮箱采集或展示。
|
||||
|
||||
弹窗内采用紧凑操作尺寸:主要操作按钮32px、页码28px,收紧按钮与分页间距,日期和数量文字适当缩小;共用分页保留默认样式,仅邀请历史启用紧凑模式。640px弹窗宽度、每页5条及240px列表上限不变。
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue