113 lines
3.6 KiB
TypeScript
113 lines
3.6 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|