feat(web): localize and redesign operations console

This commit is contained in:
chick
2026-07-18 22:22:41 +08:00
parent d575ab4d9d
commit abfd07f8a3
34 changed files with 1536 additions and 1058 deletions
+15 -15
View File
@@ -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',
+53 -46
View File
@@ -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>
</>