feat(web): localize and redesign operations console
This commit is contained in:
@@ -55,8 +55,8 @@ function deferredSource() {
|
||||
describe('Phase 7 Jobs workspace', () => {
|
||||
it('is explicitly runtime-unavailable without an injected source and offers no mutation controls', () => {
|
||||
render(<JobsPage />);
|
||||
expect(screen.getByRole('status', { name: 'Jobs unavailable' }).textContent).toMatch(
|
||||
/no jobs data source was provided/i,
|
||||
expect(screen.getByRole('status', { name: '任务不可用' }).textContent).toMatch(
|
||||
/未提供任务数据源/,
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: /cancel|retry/i })).toBeNull();
|
||||
expect(screen.queryByRole('table')).toBeNull();
|
||||
@@ -65,7 +65,7 @@ describe('Phase 7 Jobs workspace', () => {
|
||||
it('loads through the injected source and renders sanitized, read-only job data', async () => {
|
||||
const pending = deferredSource();
|
||||
render(<JobsPage dataSource={pending.source} />);
|
||||
expect(screen.getByRole('status', { name: 'Jobs loading status' })).toBeTruthy();
|
||||
expect(screen.getByRole('status', { name: '任务加载状态' })).toBeTruthy();
|
||||
expect(pending.load).toHaveBeenCalledWith(
|
||||
{ page: 1, pageSize: 25, sort: 'createdAt', direction: 'desc' },
|
||||
expect.any(AbortSignal),
|
||||
@@ -75,20 +75,20 @@ describe('Phase 7 Jobs workspace', () => {
|
||||
page: { page: 1, pageSize: 25, total: 1 },
|
||||
confirmationToken: 'never-show',
|
||||
});
|
||||
const table = await screen.findByRole('table', { name: 'Jobs' });
|
||||
const table = await screen.findByRole('table', { name: '任务' });
|
||||
const columnHeaders = within(table).getAllByRole('columnheader');
|
||||
expect(columnHeaders).toHaveLength(7);
|
||||
for (const header of columnHeaders) expect(header.getAttribute('scope')).toBe('col');
|
||||
expect(
|
||||
within(table).getByRole('columnheader', { name: 'Created' }).getAttribute('aria-sort'),
|
||||
within(table).getByRole('columnheader', { name: '创建时间' }).getAttribute('aria-sort'),
|
||||
).toBe('descending');
|
||||
expect(
|
||||
within(table).getByRole('columnheader', { name: 'Operation' }).getAttribute('aria-sort'),
|
||||
within(table).getByRole('columnheader', { name: '操作' }).getAttribute('aria-sort'),
|
||||
).toBeNull();
|
||||
for (const text of [
|
||||
'job-1',
|
||||
'message.send',
|
||||
'Failed',
|
||||
'失败',
|
||||
'job-root',
|
||||
'alpha',
|
||||
'Delivery failed',
|
||||
@@ -148,14 +148,14 @@ describe('Phase 7 Jobs workspace', () => {
|
||||
.fn<JobsDataSource['load']>()
|
||||
.mockResolvedValue({ items: [], page: { page: 1, pageSize: 25, total: 80 } });
|
||||
render(<JobsPage dataSource={{ load }} />);
|
||||
await screen.findByText('No jobs match the current query.');
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Status' }), 'failed');
|
||||
await user.type(screen.getByRole('textbox', { name: 'Operation' }), 'call.start');
|
||||
await user.type(screen.getByRole('textbox', { name: 'Root job' }), 'root-1');
|
||||
await user.type(screen.getByRole('textbox', { name: 'Instance' }), 'alpha');
|
||||
await user.click(screen.getByRole('button', { name: /sort by operation/i }));
|
||||
await user.click(screen.getByRole('button', { name: 'Next page' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Refresh jobs' }));
|
||||
await screen.findByText('没有任务符合当前查询。');
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: '状态' }), 'failed');
|
||||
await user.type(screen.getByRole('textbox', { name: '操作' }), 'call.start');
|
||||
await user.type(screen.getByRole('textbox', { name: '根任务' }), 'root-1');
|
||||
await user.type(screen.getByRole('textbox', { name: '实例' }), 'alpha');
|
||||
await user.click(screen.getByRole('button', { name: /按操作排序/ }));
|
||||
await user.click(screen.getByRole('button', { name: '下一页' }));
|
||||
await user.click(screen.getByRole('button', { name: '刷新任务' }));
|
||||
expect(load.mock.calls.at(-1)?.[0] as JobPageQuery).toEqual({
|
||||
status: 'failed',
|
||||
operationId: 'call.start',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { displayStatus } from '../ui/locale.js';
|
||||
|
||||
import {
|
||||
ATTEMPT_STATUSES,
|
||||
@@ -182,10 +183,15 @@ export function sanitizeJobPage(value: unknown): SafeJobPage | null {
|
||||
}
|
||||
|
||||
function statusLabel(value: string): string {
|
||||
return value
|
||||
.split('-')
|
||||
.map((part) => part[0]?.toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
const labels: Readonly<Record<string, string>> = {
|
||||
queued: '排队中',
|
||||
running: '运行中',
|
||||
cancelling: '取消中',
|
||||
cancelled: '已取消',
|
||||
succeeded: '成功',
|
||||
failed: '失败',
|
||||
};
|
||||
return labels[value] ?? displayStatus(value);
|
||||
}
|
||||
function safeLoadError(value: unknown): SafeProblem | null {
|
||||
return parseProblem(value) ?? null;
|
||||
@@ -250,9 +256,9 @@ export function JobsPage({ dataSource, refreshSignal = 0 }: JobsPageProps) {
|
||||
if (!dataSource) {
|
||||
return (
|
||||
<section aria-labelledby="jobs-title">
|
||||
<h1 id="jobs-title">Jobs</h1>
|
||||
<p role="status" aria-label="Jobs unavailable">
|
||||
Jobs are runtime-unavailable because no jobs data source was provided.
|
||||
<h1 id="jobs-title">任务</h1>
|
||||
<p role="status" aria-label="任务不可用">
|
||||
未提供任务数据源,因此任务功能在运行时不可用。
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
@@ -276,20 +282,20 @@ export function JobsPage({ dataSource, refreshSignal = 0 }: JobsPageProps) {
|
||||
return (
|
||||
<section aria-labelledby="jobs-title">
|
||||
<header>
|
||||
<h1 id="jobs-title">Jobs</h1>
|
||||
<p>Read-only operation history.</p>
|
||||
<h1 id="jobs-title">任务</h1>
|
||||
<p>只读操作历史。</p>
|
||||
</header>
|
||||
<div className="jobs-toolbar">
|
||||
<label>
|
||||
Status
|
||||
状态
|
||||
<select
|
||||
aria-label="Status"
|
||||
aria-label="状态"
|
||||
value={status}
|
||||
onChange={(event) =>
|
||||
changeFilter(() => setStatus(event.currentTarget.value as JobStatus | ''))
|
||||
}
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
<option value="">全部状态</option>
|
||||
{JOB_STATUSES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{statusLabel(value)}
|
||||
@@ -298,57 +304,58 @@ export function JobsPage({ dataSource, refreshSignal = 0 }: JobsPageProps) {
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Operation
|
||||
操作
|
||||
<input
|
||||
aria-label="Operation"
|
||||
aria-label="操作"
|
||||
value={operation}
|
||||
onChange={(event) => changeFilter(() => setOperation(event.currentTarget.value))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Root job
|
||||
根任务
|
||||
<input
|
||||
aria-label="Root job"
|
||||
aria-label="根任务"
|
||||
value={rootJob}
|
||||
onChange={(event) => changeFilter(() => setRootJob(event.currentTarget.value))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Instance
|
||||
实例
|
||||
<input
|
||||
aria-label="Instance"
|
||||
aria-label="实例"
|
||||
value={instance}
|
||||
onChange={(event) => changeFilter(() => setInstance(event.currentTarget.value))}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Refresh jobs"
|
||||
aria-label="刷新任务"
|
||||
onClick={() => setManualRefresh((value) => value + 1)}
|
||||
>
|
||||
Refresh
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
{loading ? (
|
||||
<p role="status" aria-label="Jobs loading status">
|
||||
Loading jobs…
|
||||
<p role="status" aria-label="任务加载状态">
|
||||
正在加载任务…
|
||||
</p>
|
||||
) : null}
|
||||
{failed ? (
|
||||
<div role="alert">
|
||||
<p>
|
||||
Jobs could not be loaded.
|
||||
无法加载任务。
|
||||
{failed !== true ? ` ${failed.code}: ${failed.title} (${failed.status})` : ''}
|
||||
</p>
|
||||
<button type="button" onClick={() => setManualRefresh((value) => value + 1)}>
|
||||
重试加载任务
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{!loading && !failed && result?.items.length === 0 ? (
|
||||
<p>No jobs match the current query.</p>
|
||||
) : null}
|
||||
{!loading && !failed && result?.items.length === 0 ? <p>没有任务符合当前查询。</p> : null}
|
||||
{!loading && !failed && result ? (
|
||||
<>
|
||||
<div className="table-scroll" role="region" aria-label="Jobs table" tabIndex={0}>
|
||||
<table className="dense-table" aria-label="Jobs">
|
||||
<div className="table-scroll" role="region" aria-label="任务表格" tabIndex={0}>
|
||||
<table className="dense-table" aria-label="任务">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
@@ -363,13 +370,13 @@ export function JobsPage({ dataSource, refreshSignal = 0 }: JobsPageProps) {
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Sort by created time"
|
||||
aria-label="按创建时间排序"
|
||||
onClick={() => changeSort('createdAt')}
|
||||
>
|
||||
Created
|
||||
创建时间
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col">Job</th>
|
||||
<th scope="col">任务</th>
|
||||
<th
|
||||
scope="col"
|
||||
aria-sort={
|
||||
@@ -382,10 +389,10 @@ export function JobsPage({ dataSource, refreshSignal = 0 }: JobsPageProps) {
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Sort by operation"
|
||||
aria-label="按操作排序"
|
||||
onClick={() => changeSort('operationId')}
|
||||
>
|
||||
Operation
|
||||
操作
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
@@ -400,15 +407,15 @@ export function JobsPage({ dataSource, refreshSignal = 0 }: JobsPageProps) {
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Sort by status"
|
||||
aria-label="按状态排序"
|
||||
onClick={() => changeSort('status')}
|
||||
>
|
||||
Status
|
||||
状态
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col">Root job</th>
|
||||
<th scope="col">Items</th>
|
||||
<th scope="col">Attempts</th>
|
||||
<th scope="col">根任务</th>
|
||||
<th scope="col">项目</th>
|
||||
<th scope="col">尝试</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -425,8 +432,8 @@ export function JobsPage({ dataSource, refreshSignal = 0 }: JobsPageProps) {
|
||||
{item.status === 'queued' ||
|
||||
item.status === 'running' ||
|
||||
item.status === 'cancelling'
|
||||
? 'No terminal item results while this job is active.'
|
||||
: 'No terminal item results.'}
|
||||
? '任务活动期间暂无最终项目结果。'
|
||||
: '暂无最终项目结果。'}
|
||||
</span>
|
||||
) : null}
|
||||
{item.items.map((entry) => (
|
||||
@@ -454,25 +461,25 @@ export function JobsPage({ dataSource, refreshSignal = 0 }: JobsPageProps) {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<nav aria-label="Jobs pagination">
|
||||
<nav aria-label="任务分页">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Previous page"
|
||||
aria-label="上一页"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((value) => value - 1)}
|
||||
>
|
||||
Previous
|
||||
上一页
|
||||
</button>
|
||||
<span>
|
||||
Page {page} of {pageCount}
|
||||
第 {page} / {pageCount} 页
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next page"
|
||||
aria-label="下一页"
|
||||
disabled={page >= pageCount}
|
||||
onClick={() => setPage((value) => value + 1)}
|
||||
>
|
||||
Next
|
||||
下一页
|
||||
</button>
|
||||
</nav>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user