thread-master/apps/web/src/pages/JobsPage.tsx

113 lines
3.6 KiB
TypeScript
Raw Normal View History

2026-07-10 05:10:31 +00:00
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { PageHeader } from "../components/layout/PageHeader";
import { Badge, Button, Card, EmptyState } from "../components/ui";
import { useData, useRepos } from "../data/DataContext";
import type { Job } from "../domain/types";
import { useI18n } from "../i18n/I18nContext";
import { formatLocalDateTime } from "../lib/time";
/** 首屏顯示筆數;太多時用「載入更多」而不是分頁 */
const INITIAL = 15;
const STEP = 15;
export function JobsPage() {
const repos = useRepos();
const { refresh, tick } = useData();
const { t } = useI18n();
const [jobs, setJobs] = useState<Job[]>([]);
const [visible, setVisible] = useState(INITIAL);
const [busy, setBusy] = useState(false);
useEffect(() => {
void repos.jobs.list().then(setJobs);
}, [repos.jobs, tick]);
useEffect(() => {
const onStore = () => refresh();
window.addEventListener("harbor:store", onStore);
return () => window.removeEventListener("harbor:store", onStore);
}, [refresh]);
const sorted = useMemo(
() => jobs.slice().sort((a, b) => b.updated_at - a.updated_at),
[jobs],
);
// 列表變短時收回可見量,避免空白
useEffect(() => {
setVisible((v) => Math.min(Math.max(INITIAL, v), Math.max(INITIAL, sorted.length)));
}, [sorted.length]);
const shown = sorted.slice(0, visible);
const hasMore = visible < sorted.length;
async function startDemo() {
setBusy(true);
try {
await repos.jobs.startDemo();
setVisible(INITIAL);
refresh();
} finally {
setBusy(false);
}
}
return (
<>
<PageHeader title={t("jobs.title")} />
<Button type="button" onClick={() => void startDemo()} disabled={busy}>
{t("jobs.startDemo")}
</Button>
{sorted.length === 0 ? (
<EmptyState title={t("jobs.empty")} />
) : (
<div className="hb-stack">
<p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
{t("jobs.total", { n: sorted.length })}
{hasMore ? t("jobs.showing", { n: shown.length }) : ""}
</p>
{shown.map((job) => (
<Card key={job.id}>
<div className="hb-list-row">
<div>
<strong>{job.template_type}</strong>
<p className="text-muted">{job.progress_summary}</p>
<p className="text-muted" style={{ fontSize: "var(--hb-text-sm)" }}>
{job.progress_percent}% · {formatLocalDateTime(job.updated_at)}
</p>
</div>
<div className="hb-list-row__actions">
<Badge
tone={
job.status === "succeeded"
? "success"
: job.status === "failed"
? "danger"
: "brand"
}
>
{job.status}
</Badge>
<Link to={`/app/jobs/${job.id}`}>
<Button variant="ghost" type="button">
{t("jobs.detail")}
</Button>
</Link>
</div>
</div>
</Card>
))}
{hasMore ? (
<div className="hb-wizard-actions">
<Button type="button" variant="secondary" onClick={() => setVisible((v) => v + STEP)}>
{t("jobs.loadMore", { n: sorted.length - visible })}
</Button>
</div>
) : null}
</div>
)}
</>
);
}