feat(web): make instance overview card-first
This commit is contained in:
+385
-195
@@ -9,6 +9,7 @@ import {
|
||||
type FleetStatus,
|
||||
type SortDirection,
|
||||
} from './fleet-table-view-model.js';
|
||||
import type { InstanceDataSource } from '../instances/instance-crud.js';
|
||||
|
||||
export interface FleetSnapshot {
|
||||
readonly instances: readonly FleetInstance[];
|
||||
@@ -19,6 +20,7 @@ export interface FleetDataSource {
|
||||
}
|
||||
export interface FleetPageProps {
|
||||
readonly dataSource?: FleetDataSource;
|
||||
readonly instanceDataSource?: InstanceDataSource;
|
||||
readonly initialData?: FleetSnapshot;
|
||||
readonly refreshSignal?: number;
|
||||
}
|
||||
@@ -75,7 +77,12 @@ export function canonicalHttpOrigin(value: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetPageProps) {
|
||||
export function FleetPage({
|
||||
dataSource,
|
||||
instanceDataSource,
|
||||
initialData,
|
||||
refreshSignal = 0,
|
||||
}: FleetPageProps) {
|
||||
const [snapshot, setSnapshot] = useState<FleetSnapshot | null>(initialData ?? null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
@@ -94,6 +101,13 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
|
||||
const [shownColumns, setShownColumns] = useState<ReadonlySet<string>>(new Set(ALL_COLUMNS));
|
||||
const [columnsOpen, setColumnsOpen] = useState(false);
|
||||
const [batchOpen, setBatchOpen] = useState(false);
|
||||
const [deleteId, setDeleteId] = useState<string>();
|
||||
const [deleteOwner, setDeleteOwner] = useState<Readonly<{ id: string; revision: number }>>();
|
||||
const [deleteConfirmation, setDeleteConfirmation] = useState('');
|
||||
const [deleteBusy, setDeleteBusy] = useState(false);
|
||||
const deleteRequestRef = useRef(0);
|
||||
const [deleteStatus, setDeleteStatus] =
|
||||
useState<Readonly<{ id: string; kind: 'success' | 'error'; message: string }>>();
|
||||
const selectAllRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -184,6 +198,47 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
|
||||
});
|
||||
}
|
||||
|
||||
async function beginDelete(id: string): Promise<void> {
|
||||
const request = ++deleteRequestRef.current;
|
||||
setDeleteId(id);
|
||||
setDeleteOwner(undefined);
|
||||
setDeleteConfirmation('');
|
||||
setDeleteStatus(undefined);
|
||||
if (!instanceDataSource) {
|
||||
setDeleteStatus({ id, kind: 'error', message: '无法准备删除,请稍后重试。' });
|
||||
return;
|
||||
}
|
||||
setDeleteBusy(true);
|
||||
try {
|
||||
const current = await instanceDataSource.get(id);
|
||||
if (request !== deleteRequestRef.current) return;
|
||||
if (current.id !== id) throw new Error('owner mismatch');
|
||||
setDeleteOwner({ id: current.id, revision: current.revision });
|
||||
} catch {
|
||||
if (request === deleteRequestRef.current) {
|
||||
setDeleteStatus({ id, kind: 'error', message: '无法准备删除,请稍后重试。' });
|
||||
}
|
||||
} finally {
|
||||
if (request === deleteRequestRef.current) setDeleteBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: string): Promise<void> {
|
||||
if (!instanceDataSource || deleteOwner?.id !== id || deleteConfirmation !== id) return;
|
||||
setDeleteBusy(true);
|
||||
setDeleteStatus(undefined);
|
||||
try {
|
||||
await instanceDataSource.delete(id, deleteOwner.revision);
|
||||
setDeleteStatus({ id, kind: 'success', message: '删除请求已提交。' });
|
||||
setDeleteId(undefined);
|
||||
setDeleteConfirmation('');
|
||||
} catch {
|
||||
setDeleteStatus({ id, kind: 'error', message: '删除请求失败,请稍后重试。' });
|
||||
} finally {
|
||||
setDeleteBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const selectFilter = (
|
||||
label: string,
|
||||
value: string,
|
||||
@@ -216,6 +271,9 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
|
||||
<p>查找、对比并管理所有 SimAdmin 实例。</p>
|
||||
</div>
|
||||
<div className="fleet-actions">
|
||||
<a className="primary-action" href="/instances/new">
|
||||
添加实例
|
||||
</a>
|
||||
<span className="selection-summary" aria-live="polite">
|
||||
已选择 {model.selectedIds.length} 项
|
||||
</span>
|
||||
@@ -234,79 +292,86 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
|
||||
</div>
|
||||
) : null}
|
||||
<div className="fleet-toolbar">
|
||||
<label>
|
||||
<span>搜索实例</span>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => resetPage(() => setQuery(event.currentTarget.value))}
|
||||
placeholder="名称、ID、标签、源地址…"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>状态</span>
|
||||
<select
|
||||
value={filter}
|
||||
onChange={(event) =>
|
||||
resetPage(() => setFilter(event.currentTarget.value as FleetFilter))
|
||||
}
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="online">在线</option>
|
||||
<option value="auth">需要认证</option>
|
||||
<option value="offline">离线</option>
|
||||
<option value="unknown">未知</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>认证</span>
|
||||
<select
|
||||
value={auth}
|
||||
onChange={(event) =>
|
||||
resetPage(() => setAuth(event.currentTarget.value as FleetAuthFilter))
|
||||
}
|
||||
>
|
||||
<option value="all">全部认证状态</option>
|
||||
<option value="authenticated">已认证</option>
|
||||
<option value="required">需要认证</option>
|
||||
<option value="unknown">未知</option>
|
||||
</select>
|
||||
</label>
|
||||
{selectFilter(
|
||||
'能力',
|
||||
capability,
|
||||
setCapability,
|
||||
model.facets.capabilities,
|
||||
'全部能力',
|
||||
capabilityLabel,
|
||||
)}
|
||||
{selectFilter('版本', version, setVersion, model.facets.versions, '全部版本')}
|
||||
{selectFilter('标签', tag, setTag, model.facets.tags, '全部标签')}
|
||||
<div className="column-picker">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={columnsOpen}
|
||||
onClick={() => setColumnsOpen((open) => !open)}
|
||||
>
|
||||
选择列
|
||||
</button>
|
||||
{columnsOpen ? (
|
||||
<fieldset>
|
||||
<legend>显示的列</legend>
|
||||
{ALL_COLUMNS.map((column) => (
|
||||
<label key={column}>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`显示${COLUMN_LABELS[column]}列`}
|
||||
checked={shownColumns.has(column)}
|
||||
onChange={() => toggleColumn(column)}
|
||||
/>
|
||||
{COLUMN_LABELS[column]}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
) : null}
|
||||
<div className="fleet-toolbar-primary">
|
||||
<label>
|
||||
<span>搜索实例</span>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => resetPage(() => setQuery(event.currentTarget.value))}
|
||||
placeholder="名称、ID、标签、源地址…"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>状态</span>
|
||||
<select
|
||||
value={filter}
|
||||
onChange={(event) =>
|
||||
resetPage(() => setFilter(event.currentTarget.value as FleetFilter))
|
||||
}
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="online">在线</option>
|
||||
<option value="auth">需要认证</option>
|
||||
<option value="offline">离线</option>
|
||||
<option value="unknown">未知</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<details className="fleet-advanced-filters">
|
||||
<summary>更多筛选与列设置</summary>
|
||||
<div className="fleet-advanced-filter-grid">
|
||||
<label>
|
||||
<span>认证</span>
|
||||
<select
|
||||
value={auth}
|
||||
onChange={(event) =>
|
||||
resetPage(() => setAuth(event.currentTarget.value as FleetAuthFilter))
|
||||
}
|
||||
>
|
||||
<option value="all">全部认证状态</option>
|
||||
<option value="authenticated">已认证</option>
|
||||
<option value="required">需要认证</option>
|
||||
<option value="unknown">未知</option>
|
||||
</select>
|
||||
</label>
|
||||
{selectFilter(
|
||||
'能力',
|
||||
capability,
|
||||
setCapability,
|
||||
model.facets.capabilities,
|
||||
'全部能力',
|
||||
capabilityLabel,
|
||||
)}
|
||||
{selectFilter('版本', version, setVersion, model.facets.versions, '全部版本')}
|
||||
{selectFilter('标签', tag, setTag, model.facets.tags, '全部标签')}
|
||||
<div className="column-picker">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={columnsOpen}
|
||||
onClick={() => setColumnsOpen((open) => !open)}
|
||||
>
|
||||
选择列
|
||||
</button>
|
||||
{columnsOpen ? (
|
||||
<fieldset>
|
||||
<legend>显示的列</legend>
|
||||
{ALL_COLUMNS.map((column) => (
|
||||
<label key={column}>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`显示${COLUMN_LABELS[column]}列`}
|
||||
checked={shownColumns.has(column)}
|
||||
onChange={() => toggleColumn(column)}
|
||||
/>
|
||||
{COLUMN_LABELS[column]}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{!snapshot && !error ? (
|
||||
@@ -324,142 +389,267 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
|
||||
) : null}
|
||||
{snapshot && model.emptyReason ? (
|
||||
<div className="state-panel">
|
||||
{model.emptyReason === 'config' ? <p>尚未配置实例。</p> : null}
|
||||
{model.emptyReason === 'config' ? (
|
||||
<>
|
||||
<p>尚未配置实例。</p>
|
||||
<a className="primary-action" href="/instances/new">
|
||||
添加实例
|
||||
</a>
|
||||
</>
|
||||
) : null}
|
||||
{model.emptyReason === 'search' ? <p>没有实例符合搜索与筛选条件。</p> : null}
|
||||
{model.emptyReason === 'filter' ? <p>没有实例符合当前筛选条件。</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{snapshot && model.rows.length > 0 ? (
|
||||
<>
|
||||
<div className="table-scroll" tabIndex={0}>
|
||||
<table className="dense-table">
|
||||
<caption>实例列表</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" className="select-column">
|
||||
<label className="touch-target">
|
||||
<input
|
||||
ref={selectAllRef}
|
||||
type="checkbox"
|
||||
aria-label="选择当前页全部实例"
|
||||
checked={model.visibleSelection.checked}
|
||||
onChange={(event) => toggleVisible(event.currentTarget.checked)}
|
||||
/>
|
||||
</label>
|
||||
</th>
|
||||
{ALL_COLUMNS.filter((column) => shownColumns.has(column)).map((column) => (
|
||||
<th
|
||||
key={column}
|
||||
scope="col"
|
||||
aria-sort={
|
||||
column !== 'origin' && sort.column === column
|
||||
? sort.direction === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: undefined
|
||||
}
|
||||
<section className="fleet-card-section" aria-label="实例卡片">
|
||||
<div className="fleet-card-grid">
|
||||
{model.rows.map((row) => (
|
||||
<article
|
||||
className="fleet-card"
|
||||
aria-label={`${row.displayName} 实例概览`}
|
||||
key={row.id}
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<h2>{row.displayName}</h2>
|
||||
<code>{row.id}</code>
|
||||
</div>
|
||||
<span className={`status status-${row.statusKind}`}>
|
||||
{STATUS_LABELS[row.statusKind]}
|
||||
</span>
|
||||
</header>
|
||||
<dl className="fleet-card-metrics">
|
||||
<div>
|
||||
<dt>延迟</dt>
|
||||
<dd>{row.latencyMs === undefined ? '—' : `${row.latencyMs} ms`}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>版本</dt>
|
||||
<dd>{row.version ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>新鲜度</dt>
|
||||
<dd>{FRESHNESS_LABELS[row.freshness] ?? row.freshness}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="capability-tags" aria-label="能力">
|
||||
{row.capabilities.length > 0 ? (
|
||||
row.capabilities.map((item) => (
|
||||
<span key={item}>{capabilityLabel(item)}</span>
|
||||
))
|
||||
) : (
|
||||
<span>能力未知</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="fleet-card-actions">
|
||||
<a
|
||||
href={`/instances/${encodeURIComponent(row.id)}/overview`}
|
||||
aria-label={`管理 ${row.displayName}`}
|
||||
>
|
||||
{column === 'origin' ? (
|
||||
COLUMN_LABELS[column]
|
||||
) : (
|
||||
管理
|
||||
</a>
|
||||
<a
|
||||
href={`/settings/instances/${encodeURIComponent(row.id)}`}
|
||||
aria-label={`编辑 ${row.displayName}`}
|
||||
>
|
||||
编辑
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="danger-button"
|
||||
aria-label={`删除 ${row.displayName}`}
|
||||
onClick={() => void beginDelete(row.id)}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
{deleteId === row.id ? (
|
||||
<div className="card-delete-confirmation">
|
||||
<p>删除后无法撤销。</p>
|
||||
<label>
|
||||
输入 {row.id} 以确认删除
|
||||
<input
|
||||
value={deleteConfirmation}
|
||||
onChange={(event) => setDeleteConfirmation(event.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="fleet-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeSort(column)}
|
||||
aria-label={`按${COLUMN_LABELS[column]}排序`}
|
||||
className="danger-button"
|
||||
disabled={
|
||||
deleteBusy ||
|
||||
deleteOwner?.id !== row.id ||
|
||||
deleteConfirmation !== row.id
|
||||
}
|
||||
aria-label={`确认删除 ${row.displayName}`}
|
||||
onClick={() => void remove(row.id)}
|
||||
>
|
||||
{COLUMN_LABELS[column]}
|
||||
确认删除
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDeleteId(undefined);
|
||||
setDeleteConfirmation('');
|
||||
setDeleteStatus(undefined);
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{deleteStatus?.id === row.id ? (
|
||||
<p
|
||||
role={deleteStatus.kind === 'error' ? 'alert' : 'status'}
|
||||
className={
|
||||
deleteStatus.kind === 'error'
|
||||
? 'card-operation-error'
|
||||
: 'card-operation-success'
|
||||
}
|
||||
>
|
||||
{deleteStatus.message}
|
||||
</p>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="fleet-details" aria-labelledby="fleet-details-title">
|
||||
<h2 id="fleet-details-title">高级详细列表</h2>
|
||||
<div className="table-scroll" tabIndex={0}>
|
||||
<table className="dense-table">
|
||||
<caption>实例列表</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" className="select-column">
|
||||
<label className="touch-target">
|
||||
<input
|
||||
ref={selectAllRef}
|
||||
type="checkbox"
|
||||
aria-label="选择当前页全部实例"
|
||||
checked={model.visibleSelection.checked}
|
||||
onChange={(event) => toggleVisible(event.currentTarget.checked)}
|
||||
/>
|
||||
</label>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{model.rows.map((row) => {
|
||||
const origin = canonicalHttpOrigin(row.instance.url);
|
||||
const cells: Readonly<Record<string, React.ReactNode>> = {
|
||||
name: (
|
||||
<th scope="row">
|
||||
<a href={`/instances/${encodeURIComponent(row.id)}/overview`}>
|
||||
{row.displayName}
|
||||
</a>
|
||||
<small>{row.id}</small>
|
||||
</th>
|
||||
),
|
||||
status: (
|
||||
<td>
|
||||
<span className={`status status-${row.statusKind}`}>
|
||||
{STATUS_LABELS[row.statusKind]}
|
||||
</span>
|
||||
</td>
|
||||
),
|
||||
latency: <td>{row.latencyMs === undefined ? '—' : `${row.latencyMs} ms`}</td>,
|
||||
version: <td>{row.version ?? '—'}</td>,
|
||||
capabilities: (
|
||||
<td>{row.capabilities.map(capabilityLabel).join(', ') || '—'}</td>
|
||||
),
|
||||
tags: <td>{row.tags.join(', ') || '—'}</td>,
|
||||
freshness: <td>{FRESHNESS_LABELS[row.freshness] ?? row.freshness}</td>,
|
||||
anomalies: <td>{row.anomalies.join(', ') || '—'}</td>,
|
||||
origin: (
|
||||
<td>
|
||||
{origin ? (
|
||||
<a
|
||||
href={origin}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`打开 ${row.displayName} 的源站`}
|
||||
>
|
||||
{origin}
|
||||
</a>
|
||||
{ALL_COLUMNS.filter((column) => shownColumns.has(column)).map((column) => (
|
||||
<th
|
||||
key={column}
|
||||
scope="col"
|
||||
aria-sort={
|
||||
column !== 'origin' && sort.column === column
|
||||
? sort.direction === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{column === 'origin' ? (
|
||||
COLUMN_LABELS[column]
|
||||
) : (
|
||||
<span>源地址无效</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeSort(column)}
|
||||
aria-label={`按${COLUMN_LABELS[column]}排序`}
|
||||
>
|
||||
{COLUMN_LABELS[column]}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
),
|
||||
};
|
||||
return (
|
||||
<tr key={row.id} aria-selected={row.selected}>
|
||||
<td>
|
||||
<label className="touch-target">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`选择 ${row.displayName}`}
|
||||
checked={row.selected}
|
||||
onChange={(event) => toggleOne(row.id, event.currentTarget.checked)}
|
||||
/>
|
||||
</label>
|
||||
</td>
|
||||
{ALL_COLUMNS.filter((column) => shownColumns.has(column)).map((column) => (
|
||||
<Fragment key={column}>{cells[column]}</Fragment>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<nav className="pagination" aria-label="实例分页">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="上一页"
|
||||
disabled={model.page === 1}
|
||||
onClick={() => setPage((value) => value - 1)}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span>
|
||||
第 {model.page} 页,共 {model.pageCount} 页
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="下一页"
|
||||
disabled={model.page === model.pageCount}
|
||||
onClick={() => setPage((value) => value + 1)}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</nav>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{model.rows.map((row) => {
|
||||
const origin = canonicalHttpOrigin(row.instance.url);
|
||||
const cells: Readonly<Record<string, React.ReactNode>> = {
|
||||
name: (
|
||||
<th scope="row">
|
||||
<a href={`/instances/${encodeURIComponent(row.id)}/overview`}>
|
||||
{row.displayName}
|
||||
</a>
|
||||
<small>{row.id}</small>
|
||||
</th>
|
||||
),
|
||||
status: (
|
||||
<td>
|
||||
<span className={`status status-${row.statusKind}`}>
|
||||
{STATUS_LABELS[row.statusKind]}
|
||||
</span>
|
||||
</td>
|
||||
),
|
||||
latency: <td>{row.latencyMs === undefined ? '—' : `${row.latencyMs} ms`}</td>,
|
||||
version: <td>{row.version ?? '—'}</td>,
|
||||
capabilities: (
|
||||
<td>{row.capabilities.map(capabilityLabel).join(', ') || '—'}</td>
|
||||
),
|
||||
tags: <td>{row.tags.join(', ') || '—'}</td>,
|
||||
freshness: <td>{FRESHNESS_LABELS[row.freshness] ?? row.freshness}</td>,
|
||||
anomalies: <td>{row.anomalies.join(', ') || '—'}</td>,
|
||||
origin: (
|
||||
<td>
|
||||
{origin ? (
|
||||
<a
|
||||
href={origin}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`打开 ${row.displayName} 的源站`}
|
||||
>
|
||||
{origin}
|
||||
</a>
|
||||
) : (
|
||||
<span>源地址无效</span>
|
||||
)}
|
||||
</td>
|
||||
),
|
||||
};
|
||||
return (
|
||||
<tr key={row.id} aria-selected={row.selected}>
|
||||
<td>
|
||||
<label className="touch-target">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`选择 ${row.displayName}`}
|
||||
checked={row.selected}
|
||||
onChange={(event) => toggleOne(row.id, event.currentTarget.checked)}
|
||||
/>
|
||||
</label>
|
||||
</td>
|
||||
{ALL_COLUMNS.filter((column) => shownColumns.has(column)).map((column) => (
|
||||
<Fragment key={column}>{cells[column]}</Fragment>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<nav className="pagination" aria-label="实例分页">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="上一页"
|
||||
disabled={model.page === 1}
|
||||
onClick={() => setPage((value) => value - 1)}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span>
|
||||
第 {model.page} 页,共 {model.pageCount} 页
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="下一页"
|
||||
disabled={model.page === model.pageCount}
|
||||
onClick={() => setPage((value) => value + 1)}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</nav>
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user