feat(web): 首页只回答三件事、计划页巡检置顶与分组折叠、任务列表去噪(DEAI-2)
首页:顶部一行状态(自动化开关 + 只在异常时出现的服务 / 下载盘 / 站点账号 / 下载器问题), 下面只有「在忙什么」「要我处理」「最近入库」三块;去掉运行状态、计划、缓存、来源健康、 满足率与 0 值铺陈;0 订阅时写「还没有订阅。去搜索添加」。只读对账移出首页,设置里放进 自动化 →「高级」(与存储预算、流水保留、缓存有效期一起默认收起)。 计划页:「智能体巡检」移到最上面——巡检数量少、由用户自己建、每次会花钱,是这页最需要 看的;自动任务任务多、多数不用管,放在下面。自动任务按分类分组、每组可折叠:有失败或 运行中的组默认展开,其余收起为「N 个任务 · 下次 xx」一行,折叠状态记在 localStorage; 维护类整组叫「数据清理」,未接入调度的备份不列。巡检卡只留名称、开关、间隔、上次结果与 费用,目标 / 能力 / 预算 / 模型 / 最近会话收进「详情」。去掉顶部 6 个计数卡。定时任务说明 去掉表名、字段名与实现备注。 任务页:默认隐藏系统任务(无业务对象的引擎步骤、定时任务的成功运行),可一键显示; 标题不再用 ID;耗时移入技术信息;< 1 秒写「< 1 秒」;费用两位小数,不足一分写「< $0.01」。 下载盘相关文案改用「暂停线 / 恢复线」。 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
f0d4935fa7
commit
260f3ab27f
23 files changed
+467
-724
No files matched your search
@@ -106,7 +106,7 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
|
||||
key: "downloads.sync",
|
||||
defaults: {
|
||||
name: "qB 同步",
|
||||
description: "同步下载器任务状态,按缓存阈值暂停或恢复",
|
||||
description: "同步下载器任务状态;下载盘到暂停线时暂停,降到恢复线后恢复",
|
||||
group: "download",
|
||||
schedule: every(syncMs),
|
||||
},
|
||||
@@ -125,8 +125,8 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
|
||||
registerScheduledJob(ctx, {
|
||||
key: "cache.threshold",
|
||||
defaults: {
|
||||
name: "缓存阈值检查",
|
||||
description: "下载缓存超过硬阈值时入队清理",
|
||||
name: "下载盘检查",
|
||||
description: "下载盘超过暂停线时清理已入库的下载",
|
||||
group: "maintain",
|
||||
schedule: every(planMs),
|
||||
},
|
||||
@@ -141,7 +141,7 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
|
||||
key: "downloads.orchestrate",
|
||||
defaults: {
|
||||
name: "补提交与补入库",
|
||||
description: "补投已预留未提交的下载、已完成未入库的任务",
|
||||
description: "补交没提交成功的下载,补整理下载完但没入库的任务",
|
||||
group: "download",
|
||||
schedule: every(planMs),
|
||||
},
|
||||
@@ -155,7 +155,7 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
|
||||
key: "slots.reevaluate",
|
||||
defaults: {
|
||||
name: "到期重评",
|
||||
description: "到了重评时间的收录位重新评估(忙时延后的发布、定时)",
|
||||
description: "重新检查等待中的资源",
|
||||
group: "download",
|
||||
schedule: every(planMs),
|
||||
},
|
||||
@@ -165,7 +165,7 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
|
||||
key: "subscriptions.search",
|
||||
defaults: {
|
||||
name: "订阅周期搜索",
|
||||
description: "到期的订阅主动搜索站点",
|
||||
description: "到期的订阅去站点搜索",
|
||||
group: "collect",
|
||||
schedule: every(planMs),
|
||||
},
|
||||
|
||||
@@ -52,7 +52,7 @@ export function createAuthSettingsModule(opts: AuthSettingsModuleOptions = {}):
|
||||
key: "auth.cleanup",
|
||||
defaults: {
|
||||
name: "登录会话清理",
|
||||
description: "删除 7 天前的登录尝试和 30 天前过期或吊销的会话",
|
||||
description: "删除 7 天前的登录尝试和 30 天前失效的会话",
|
||||
group: "maintain",
|
||||
schedule: { type: "interval", everyMs },
|
||||
},
|
||||
|
||||
+3
-3
@@ -183,7 +183,7 @@ export function createCacheModule(opts: CacheModuleOptions = {}): ServerModule &
|
||||
key: "cache.sweep",
|
||||
defaults: {
|
||||
name: "缓存清理",
|
||||
description: "删除 PG 缓存里已过期的条目(TMDB 请求与识别缓存)",
|
||||
description: "删除过期的 TMDB 与识别缓存",
|
||||
group: "maintain",
|
||||
schedule: { type: "interval", everyMs: 10 * 60_000 },
|
||||
firstRun: "after_interval",
|
||||
@@ -194,7 +194,7 @@ export function createCacheModule(opts: CacheModuleOptions = {}): ServerModule &
|
||||
key: "cache.images_clean",
|
||||
defaults: {
|
||||
name: "图片缓存清理",
|
||||
description: "删除超过保留天数的图片代理缓存文件(对照 MP 启动时的 clear_temp)",
|
||||
description: "删除过期的海报缓存",
|
||||
group: "maintain",
|
||||
schedule: { type: "interval", everyMs: 24 * 3_600_000 },
|
||||
},
|
||||
@@ -208,7 +208,7 @@ export function createCacheModule(opts: CacheModuleOptions = {}): ServerModule &
|
||||
key: "cache.images_lru",
|
||||
defaults: {
|
||||
name: "图片缓存容量控制",
|
||||
description: "图片代理缓存超过容量预算(存储设置,缺省 20 GB)时,按最久未访问删除到预算的 90%",
|
||||
description: "海报缓存超过容量上限时,删掉最久没用的",
|
||||
group: "maintain",
|
||||
schedule: { type: "interval", everyMs: 30 * 60_000 },
|
||||
firstRun: "after_interval",
|
||||
|
||||
@@ -74,7 +74,7 @@ export function createPiModule(
|
||||
key: "pi.token_refresh",
|
||||
defaults: {
|
||||
name: "模型服务登录刷新",
|
||||
description: "订阅登录即将过期的模型服务,提前刷新 token",
|
||||
description: "订阅登录快过期时提前续期",
|
||||
group: "maintain",
|
||||
schedule: { type: "interval", everyMs: interval },
|
||||
},
|
||||
|
||||
@@ -37,7 +37,7 @@ export function createSchedulesModule(opts: SchedulesModuleOptions = {}): Server
|
||||
key: "maintain.backup",
|
||||
defaults: {
|
||||
name: "备份",
|
||||
description: "数据库与数据目录备份;目前用 umt backup(宿主机 cron 调用),未接入调度",
|
||||
description: "数据库与数据目录备份,目前在服务器上用命令执行",
|
||||
group: "maintain",
|
||||
schedule: { type: "cron", cron: "30 3 * * *" },
|
||||
enabled: false,
|
||||
|
||||
@@ -61,7 +61,7 @@ export function createSourcesIdentityModule(overrides: RuntimeOverrides = {}): S
|
||||
return { summary: `${await fn(rt)} 项` };
|
||||
},
|
||||
});
|
||||
job("feeds.poll", "Feed 轮询", "扫描到期的 Feed 并入队抓取(每个 Feed 仍按自己的轮询间隔)", tickFeeds);
|
||||
job("feeds.poll", "RSS 轮询", "按各 RSS 自己的间隔抓取新资源", tickFeeds);
|
||||
job("sites.snapshot", "站点快照", "扫描到周期的站点并入队快照", tickSnapshots);
|
||||
job("identity.review", "未识别复查", "到期的未识别条目重新识别", tickReviews);
|
||||
// W9-A 站点中心:用户数据每 6 小时、健康检测每小时(可在定时任务页关闭或改周期)
|
||||
@@ -69,7 +69,7 @@ export function createSourcesIdentityModule(overrides: RuntimeOverrides = {}): S
|
||||
key: "sites.user_data",
|
||||
defaults: {
|
||||
name: "站点数据刷新",
|
||||
description: "登录状态正常的站点账号刷新上传 / 下载 / 分享率等用户数据;会话失效的账密账号自动重登一次",
|
||||
description: "刷新站点账号的上传、下载和分享率;登录失效的账密账号自动重登一次",
|
||||
group: "collect",
|
||||
schedule: { type: "interval", everyMs: 6 * 3_600_000 },
|
||||
firstRun: "after_interval",
|
||||
@@ -83,7 +83,7 @@ export function createSourcesIdentityModule(overrides: RuntimeOverrides = {}): S
|
||||
key: "sites.health_check",
|
||||
defaults: {
|
||||
name: "站点健康检测",
|
||||
description: "已启用站点逐个检测首页连通与登录态,结果显示在站点页",
|
||||
description: "检查到期的站点能否打开、是否还在登录(每个站点按自己的检测间隔),结果在站点页",
|
||||
group: "collect",
|
||||
schedule: { type: "interval", everyMs: 3_600_000 },
|
||||
firstRun: "after_interval",
|
||||
@@ -95,8 +95,7 @@ export function createSourcesIdentityModule(overrides: RuntimeOverrides = {}): S
|
||||
key: BACKFILL_LANGUAGES_JOB,
|
||||
defaults: {
|
||||
name: "元数据语言回刷",
|
||||
description:
|
||||
"对已确认 TMDB 身份的作品按语言回退链完整刷新一次(标题、简介、每集信息),TMDB 每秒最多 2 次请求;跑完自动停用,可手动再执行",
|
||||
description: "按语言设置重新拉一遍作品的标题、简介和每集信息;跑完自动停用,可手动再执行",
|
||||
group: "maintain",
|
||||
schedule: { type: "interval", everyMs: 24 * 3_600_000 },
|
||||
firstRun: "now",
|
||||
|
||||
@@ -210,7 +210,7 @@ export function createSystemModule(opts: SystemModuleOptions = {}): ServerModule
|
||||
key: "system.heartbeat_cleanup",
|
||||
defaults: {
|
||||
name: "进程心跳清理",
|
||||
description: "删除 24 小时没有心跳的进程行和超过保留期(存储设置,缺省 24 小时)的心跳采样",
|
||||
description: "删除过期的进程状态记录",
|
||||
group: "maintain",
|
||||
schedule: { type: "interval", everyMs },
|
||||
},
|
||||
|
||||
@@ -105,8 +105,8 @@ export const RETENTION_JOBS: RetentionJob[] = [
|
||||
{
|
||||
target: "observation",
|
||||
jobId: "storage.retention.observation",
|
||||
name: "观察记录清理",
|
||||
description: "删除超过保留期的 Feed 观察记录(observation,按 created_at)",
|
||||
name: "RSS 抓取记录清理",
|
||||
description: "删除过期的 RSS 抓取记录",
|
||||
everyMs: 6 * HOUR,
|
||||
retention: (s) => `${s.retentionDays.observation} 天`,
|
||||
async run(c) {
|
||||
@@ -118,8 +118,8 @@ export const RETENTION_JOBS: RetentionJob[] = [
|
||||
{
|
||||
target: "sse_event",
|
||||
jobId: "storage.retention.sse_event",
|
||||
name: "SSE 事件清理",
|
||||
description: "删除超过保留期的 SSE 补读事件(sse_event,按 at;发布方另按条数裁剪)",
|
||||
name: "实时事件清理",
|
||||
description: "删除过期的页面实时推送记录",
|
||||
everyMs: HOUR,
|
||||
retention: (s) => `${s.retentionDays.sseEvent} 天`,
|
||||
async run(c) {
|
||||
@@ -132,7 +132,7 @@ export const RETENTION_JOBS: RetentionJob[] = [
|
||||
target: "task_run",
|
||||
jobId: "storage.retention.task_run",
|
||||
name: "任务运行清理",
|
||||
description: "删除超过保留期、已结束的业务任务(task)与引擎 run(含步骤、运行元数据)",
|
||||
description: "删除过期的已结束任务",
|
||||
everyMs: 6 * HOUR,
|
||||
retention: (s) => `${s.retentionDays.taskRun} 天`,
|
||||
async run(c) {
|
||||
@@ -168,7 +168,7 @@ export const RETENTION_JOBS: RetentionJob[] = [
|
||||
target: "agent_event",
|
||||
jobId: "storage.retention.agent_event",
|
||||
name: "智能体事件清理",
|
||||
description: "删除超过保留期、会话已结束的智能体事件流与消息(agent_run_event / agent_run_message;会话本身保留)",
|
||||
description: "删除已结束会话的过期消息,会话本身保留",
|
||||
everyMs: 6 * HOUR,
|
||||
retention: (s) => `${s.retentionDays.agentEvent} 天`,
|
||||
async run(c) {
|
||||
@@ -189,7 +189,7 @@ export const RETENTION_JOBS: RetentionJob[] = [
|
||||
target: "audit",
|
||||
jobId: "storage.retention.audit",
|
||||
name: "审计清理",
|
||||
description: "删除超过保留期的审计事件(audit_event,能力调用审计按能力统计的保留期另算)",
|
||||
description: "删除过期的审计日志",
|
||||
everyMs: 24 * HOUR,
|
||||
retention: (s) => `${s.retentionDays.audit} 天`,
|
||||
async run(c) {
|
||||
@@ -207,7 +207,7 @@ export const RETENTION_JOBS: RetentionJob[] = [
|
||||
target: "capability_stats",
|
||||
jobId: "storage.retention.capability_stats",
|
||||
name: "能力统计清理",
|
||||
description: "删除超过保留期的能力调用审计(audit_event target_type=capability)与已结束的能力调用记录",
|
||||
description: "删除过期的能力调用记录",
|
||||
everyMs: 24 * HOUR,
|
||||
retention: (s) => `${s.retentionDays.capabilityStats} 天`,
|
||||
async run(c) {
|
||||
@@ -231,7 +231,7 @@ export const RETENTION_JOBS: RetentionJob[] = [
|
||||
target: "identity_pool",
|
||||
jobId: "storage.retention.identity_pool",
|
||||
name: "未识别资源归档",
|
||||
description: "未识别资源里 unmatched 条目超过保留期没有新证据就归档(不删除,仍可按状态筛出)",
|
||||
description: "长期没有新线索的未识别资源归档,仍可手动指定",
|
||||
everyMs: 6 * HOUR,
|
||||
retention: (s) => `${s.retentionDays.identityPoolArchive} 天`,
|
||||
async run(c) {
|
||||
@@ -252,8 +252,8 @@ export const RETENTION_JOBS: RetentionJob[] = [
|
||||
{
|
||||
target: "analyze",
|
||||
jobId: "storage.analyze",
|
||||
name: "统计信息更新",
|
||||
description: "对全库执行 ANALYZE,刷新查询规划用的统计信息(每周)",
|
||||
name: "数据库维护",
|
||||
description: "每周刷新数据库统计信息",
|
||||
everyMs: 7 * 24 * HOUR,
|
||||
retention: () => null,
|
||||
async run(c) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useDeleteSchedule } from "~/api/schedules.ts";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { TechInfo } from "~/components/console/tech-info.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
@@ -16,7 +17,8 @@ import { NextRun, useJobActions } from "./auto-task-list.tsx";
|
||||
import { IntervalSelect } from "./interval-select.tsx";
|
||||
import { CHECK_TRIGGER_LABEL, formatUsd, RUN_STATUS } from "./labels.ts";
|
||||
|
||||
// 计划页「Agent 巡检」:到点按目标模板发起一次 Agent 会话(kind=agent_check),一个巡检一张卡片。
|
||||
// 计划页「智能体巡检」:到点按目标模板发起一次智能体会话(kind=agent_check),一个巡检一张卡片。
|
||||
// 卡片默认只显示名称、开关、间隔、上次结果与费用;目标、能力、预算、模型、最近会话收进「详情」。
|
||||
|
||||
/** 卡片上能力白名单最多列几个,其余折成"+N" */
|
||||
const MAX_CAPS = 6;
|
||||
@@ -113,13 +115,46 @@ export function AgentCheckCard({
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div className="rounded-tight border border-border/70 px-2.5 py-2">
|
||||
<p className="line-clamp-3 text-caption text-foreground" title={c.goalTemplate}>
|
||||
{c.goalTemplate ?? "—"}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-meta text-muted-foreground">
|
||||
<IntervalSelect
|
||||
label={`${job.name}间隔`}
|
||||
schedule={job.schedule}
|
||||
disabled={!job.available}
|
||||
pending={a.update.isPending}
|
||||
onSave={a.setSchedule}
|
||||
/>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
下次 <NextRun job={job} now={now} />
|
||||
</span>
|
||||
</div>
|
||||
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-meta text-muted-foreground">
|
||||
{last ? (
|
||||
job.lastStatus === "ok" ? (
|
||||
<span>上次成功</span>
|
||||
) : (
|
||||
<StatusBadge tone={last.tone}>{last.label}</StatusBadge>
|
||||
)
|
||||
) : (
|
||||
<span>还没运行过</span>
|
||||
)}
|
||||
{job.lastRunAt ? <span title={job.lastRunAt}>{formatRelative(job.lastRunAt)}</span> : null}
|
||||
{stats?.count ? <span className="tabular-nums">· 每次约 {formatUsd(stats.avgCostUsd)}</span> : null}
|
||||
</p>
|
||||
|
||||
{job.lastStatus === "failed" && job.lastError ? (
|
||||
<details className="group rounded-tight border border-destructive/30 bg-destructive/10 px-2.5 py-1.5 text-meta text-destructive">
|
||||
<summary className="cursor-pointer truncate group-open:whitespace-normal">失败原因:{job.lastError}</summary>
|
||||
<p className="mt-1 break-words whitespace-pre-wrap">{job.lastError}</p>
|
||||
</details>
|
||||
) : null}
|
||||
|
||||
<TechInfo title="详情" columns={1}>
|
||||
<dl className="grid grid-cols-[4.5rem_minmax(0,1fr)] items-baseline gap-x-3 gap-y-1.5">
|
||||
<Field label="目标">
|
||||
<span className="line-clamp-4 break-words" title={c.goalTemplate}>
|
||||
{c.goalTemplate ?? "—"}
|
||||
</span>
|
||||
</Field>
|
||||
<Field label="能力">
|
||||
{allow.length ? (
|
||||
<span className="flex flex-wrap gap-1">
|
||||
@@ -146,8 +181,12 @@ export function AgentCheckCard({
|
||||
)}
|
||||
{c.allowRecover ? <span className="ml-1 text-meta text-muted-foreground">· 允许授权内恢复</span> : null}
|
||||
</Field>
|
||||
<Field label="触发条件">
|
||||
{CHECK_TRIGGER_LABEL[c.triggerCondition ?? "always"]}
|
||||
{c.reportAsDecision ? <span className="ml-1 text-meta text-muted-foreground">· 结果写入待处理</span> : null}
|
||||
</Field>
|
||||
<Field label="预算">{budgetParts.length ? budgetParts.join(" · ") : "默认"}</Field>
|
||||
<Field label="模型服务">
|
||||
<Field label="模型">
|
||||
{stats?.lastModel ? (
|
||||
<span className="break-words" title={modelsTitle(stats.models)}>
|
||||
{stats.lastProvider ? `${stats.lastProvider} · ` : ""}
|
||||
@@ -157,84 +196,37 @@ export function AgentCheckCard({
|
||||
) : null}
|
||||
</span>
|
||||
) : upstream ? (
|
||||
<span
|
||||
className="break-words"
|
||||
title={
|
||||
upstream.modelFromCatalog
|
||||
? "模型服务没有指定默认模型,运行时使用模型目录里的第一个"
|
||||
: "还没有运行记录,按默认模型服务显示"
|
||||
}
|
||||
>
|
||||
默认模型服务 {upstream.name} · {upstream.model ?? "模型未指定"}
|
||||
{upstream.modelFromCatalog ? <span className="text-muted-foreground">(目录首个)</span> : null}
|
||||
<span className="break-words">
|
||||
{upstream.name} · {upstream.model ?? "模型未指定"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">未配置模型服务</span>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="触发条件">
|
||||
{CHECK_TRIGGER_LABEL[c.triggerCondition ?? "always"]}
|
||||
{c.reportAsDecision ? <span className="ml-1 text-meta text-muted-foreground">· 结果写入待处理</span> : null}
|
||||
</Field>
|
||||
<Field label="间隔">
|
||||
<IntervalSelect
|
||||
label={`${job.name}间隔`}
|
||||
schedule={job.schedule}
|
||||
disabled={!job.available}
|
||||
pending={a.update.isPending}
|
||||
onSave={a.setSchedule}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="下次">
|
||||
<span className="text-meta">
|
||||
<NextRun job={job} now={now} />
|
||||
</span>
|
||||
</Field>
|
||||
<Field label="每次费用">
|
||||
{stats?.count ? (
|
||||
<Field label="费用">
|
||||
<span className="tabular-nums">
|
||||
上次 {formatUsd(stats.lastCostUsd)}
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
· 近 {stats.count} 次平均 {formatUsd(stats.avgCostUsd)}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">还没有会话</span>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="最近结果">
|
||||
<span className="flex flex-wrap items-center gap-x-2 gap-y-1 text-meta text-muted-foreground">
|
||||
{last ? <StatusBadge tone={last.tone}>{last.label}</StatusBadge> : <span>未运行</span>}
|
||||
{job.lastRunAt ? <span title={job.lastRunAt}>{formatRelative(job.lastRunAt)}</span> : null}
|
||||
上次 {formatUsd(stats.lastCostUsd)} · 近 {stats.count} 次平均 {formatUsd(stats.avgCostUsd)}
|
||||
</span>
|
||||
</Field>
|
||||
</dl>
|
||||
|
||||
{job.lastStatus === "failed" && job.lastError ? (
|
||||
<details className="group rounded-tight border border-destructive/30 bg-destructive/10 px-2.5 py-1.5 text-meta text-destructive">
|
||||
<summary className="cursor-pointer truncate group-open:whitespace-normal">失败原因:{job.lastError}</summary>
|
||||
<p className="mt-1 break-words whitespace-pre-wrap">{job.lastError}</p>
|
||||
</details>
|
||||
) : null}
|
||||
|
||||
</dl>
|
||||
{runs.length ? (
|
||||
<section aria-label="最近会话" className="grid min-w-0 gap-1 border-t border-border/70 pt-2.5">
|
||||
<section aria-label="最近会话" className="grid min-w-0 gap-1">
|
||||
<h4 className="text-meta text-muted-foreground">最近会话</h4>
|
||||
<ul className="grid min-w-0 grid-cols-[minmax(0,1fr)] gap-1">
|
||||
{runs.slice(0, 3).map((r) => {
|
||||
const s = agentRunLabel(r.status);
|
||||
const st = agentRunLabel(r.status);
|
||||
return (
|
||||
<li key={r.id} className="min-w-0">
|
||||
<Link
|
||||
to={consolePath("agent", r.id)}
|
||||
className="flex min-w-0 items-center gap-2 rounded-tight px-1 py-0.5 text-meta text-muted-foreground hover:bg-foreground/[0.04] hover:text-foreground"
|
||||
>
|
||||
<StatusBadge tone={s.tone}>{s.label}</StatusBadge>
|
||||
{r.status === "succeeded" ? null : <StatusBadge tone={st.tone}>{st.label}</StatusBadge>}
|
||||
<span className="min-w-0 flex-1 truncate" title={r.summary ?? undefined}>
|
||||
{plainSummary(r.summary ?? r.goal)}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums">{formatUsd(r.costUsd)}</span>
|
||||
<span className="shrink-0" title={r.createdAt}>
|
||||
{formatRelative(r.finishedAt ?? r.createdAt)}
|
||||
</span>
|
||||
@@ -245,6 +237,7 @@ export function AgentCheckCard({
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
</TechInfo>
|
||||
|
||||
<footer className="flex flex-wrap items-center gap-1.5">
|
||||
<Button size="sm" disabled={!a.canRun} onClick={a.run}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ScheduledJob, ScheduleGroup, ScheduleSpecInput } from "@umt/contracts";
|
||||
import { History, LoaderCircle, Play, RotateCcw, SkipForward } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { ChevronRight, History, LoaderCircle, Play, RotateCcw, SkipForward } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useRunScheduleNow, useSkipSchedule, useUpdateSchedule } from "~/api/schedules.ts";
|
||||
@@ -9,11 +9,12 @@ import { Button } from "~/components/ui/button.tsx";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { formatRelative } from "~/lib/labels.ts";
|
||||
import { readCollapseState, writeCollapseState } from "~/lib/ui-prefs.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { IntervalSelect } from "./interval-select.tsx";
|
||||
import { countdown, formatMs, GROUP_LABEL, RUN_STATUS, scheduleStreamPath, TASK_GROUPS } from "./labels.ts";
|
||||
|
||||
// 计划页「自动任务」:程序按固定周期执行的定时任务(kind=task),按组的紧凑列表。
|
||||
// 计划页「自动任务」:程序按固定周期执行的定时任务(kind=task),按组折叠的紧凑列表;维护类一组叫「数据清理」。
|
||||
|
||||
/** 下次运行:运行中 > 排队中 > 未配置 > 已停用 > 倒计时 */
|
||||
export function NextRun({ job, now }: { job: ScheduledJob; now: number }) {
|
||||
@@ -121,9 +122,22 @@ function AutoTaskRow({ job, now, highlight }: { job: ScheduledJob; now: number;
|
||||
/>
|
||||
</div>
|
||||
<div className="col-start-1 row-start-3 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-meta text-muted-foreground lg:col-start-auto lg:row-start-auto">
|
||||
{last ? <StatusBadge tone={last.tone}>{last.label}</StatusBadge> : <span>未运行</span>}
|
||||
{job.lastRunAt ? <span title={job.lastRunAt}>{formatRelative(job.lastRunAt)}</span> : null}
|
||||
{job.lastDurationMs !== null ? <span className="tabular-nums">{formatMs(job.lastDurationMs)}</span> : null}
|
||||
{last ? (
|
||||
job.lastStatus === "ok" ? (
|
||||
<span>成功</span>
|
||||
) : (
|
||||
<StatusBadge tone={last.tone}>{last.label}</StatusBadge>
|
||||
)
|
||||
) : (
|
||||
<span>未运行</span>
|
||||
)}
|
||||
{job.lastRunAt ? (
|
||||
<span
|
||||
title={`${job.lastRunAt}${job.lastDurationMs !== null ? ` · 用时 ${formatMs(job.lastDurationMs)}` : ""}`}
|
||||
>
|
||||
{formatRelative(job.lastRunAt)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="col-start-2 row-start-3 flex min-w-0 items-center justify-end gap-1 text-meta text-muted-foreground lg:col-start-auto lg:row-start-auto lg:justify-start">
|
||||
<span className="shrink-0 lg:hidden">下次</span>
|
||||
@@ -178,6 +192,25 @@ function AutoTaskRow({ job, now, highlight }: { job: ScheduledJob; now: number;
|
||||
);
|
||||
}
|
||||
|
||||
const COLLAPSE_KEY = "umt.schedules.groups";
|
||||
|
||||
/** 组摘要:"6 个任务 · 下次 3 分后 · 1 个失败" */
|
||||
function groupSummary(list: ScheduledJob[], now: number): string {
|
||||
const next = list
|
||||
.filter((j) => j.enabled && j.available && j.nextRunAt)
|
||||
.map((j) => j.nextRunAt as string)
|
||||
.sort()[0];
|
||||
const failed = list.filter((j) => j.lastStatus === "failed").length;
|
||||
const running = list.filter((j) => j.runningSince).length;
|
||||
return [
|
||||
`${list.length} 个任务`,
|
||||
running ? `${running} 个运行中` : next ? `下次 ${countdown(next, now)}` : null,
|
||||
failed ? `${failed} 个失败` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
export function AutoTaskList({
|
||||
jobs,
|
||||
now,
|
||||
@@ -189,13 +222,28 @@ export function AutoTaskList({
|
||||
}) {
|
||||
const byGroup = new Map<ScheduleGroup, ScheduledJob[]>();
|
||||
for (const j of jobs) {
|
||||
// 未接入调度的占位任务(如备份)不列
|
||||
if (!j.available && !j.enabled && j.group === "maintain") continue;
|
||||
const list = byGroup.get(j.group);
|
||||
if (list) list.push(j);
|
||||
else byGroup.set(j.group, [j]);
|
||||
}
|
||||
const groups = TASK_GROUPS.filter((g) => byGroup.get(g)?.length);
|
||||
// 折叠:有失败 / 运行中 / 被高亮的组默认展开,其余收起;点过的按用户的来并记在本机
|
||||
const [saved, setSaved] = useState<Record<string, boolean>>(() => readCollapseState(COLLAPSE_KEY) ?? {});
|
||||
const expanded = (g: ScheduleGroup, list: ScheduledJob[]) =>
|
||||
list.some((j) => j.id === highlightJob) ||
|
||||
(saved[g] ?? list.some((j) => j.lastStatus === "failed" || !!j.runningSince));
|
||||
const toggle = (g: ScheduleGroup, v: boolean) =>
|
||||
setSaved((cur) => {
|
||||
const next = { ...cur, [g]: v };
|
||||
writeCollapseState(COLLAPSE_KEY, next);
|
||||
return next;
|
||||
});
|
||||
const anyOpen = groups.some((g) => expanded(g, byGroup.get(g) ?? []));
|
||||
return (
|
||||
<div className="pg-slab overflow-hidden rounded-xl">
|
||||
{anyOpen ? (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn("hidden border-b border-border/70 px-4 py-2 text-meta text-muted-foreground lg:grid", COLS)}
|
||||
@@ -207,19 +255,40 @@ export function AutoTaskList({
|
||||
<span>下次运行</span>
|
||||
<span className="text-right">操作</span>
|
||||
</div>
|
||||
) : null}
|
||||
{groups.map((g) => {
|
||||
const list = byGroup.get(g) ?? [];
|
||||
const open = expanded(g, list);
|
||||
const failed = list.some((j) => j.lastStatus === "failed");
|
||||
return (
|
||||
<section key={g} aria-label={GROUP_LABEL[g]}>
|
||||
<h3 className="flex items-baseline gap-2 border-b border-border/70 bg-foreground/[0.03] px-4 py-1.5 text-meta font-medium text-muted-foreground">
|
||||
{GROUP_LABEL[g]}
|
||||
<span className="tabular-nums">{list.length}</span>
|
||||
<section key={g} aria-label={GROUP_LABEL[g]} className="border-b border-border/70 last:border-b-0">
|
||||
<h3>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => toggle(g, !open)}
|
||||
className="flex w-full cursor-pointer flex-wrap items-baseline gap-x-3 gap-y-0.5 bg-foreground/[0.03] px-4 py-2 text-left transition-colors duration-micro hover:bg-foreground/[0.05]"
|
||||
>
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"size-3.5 shrink-0 self-center text-muted-foreground transition-transform duration-base",
|
||||
open && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
<span className="text-body font-medium text-foreground">{GROUP_LABEL[g]}</span>
|
||||
<span className={cn("text-meta tabular-nums", failed ? "text-destructive" : "text-muted-foreground")}>
|
||||
{groupSummary(list, now)}
|
||||
</span>
|
||||
</button>
|
||||
</h3>
|
||||
<ul className="divide-y divide-border/70 border-b border-border/70 last:border-b-0">
|
||||
{open ? (
|
||||
<ul className="divide-y divide-border/70 border-t border-border/70">
|
||||
{list.map((j) => (
|
||||
<AutoTaskRow key={j.id} job={j} now={now} highlight={highlightJob === j.id} />
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -12,10 +12,11 @@ describe("计划页标签", () => {
|
||||
expect(scheduleStreamPath("trash.purge")).toBe("/console/tasks?tab=all&kind=schedule&ref=trash.purge");
|
||||
});
|
||||
|
||||
it("formatUsd:小额保留 4 位", () => {
|
||||
it("formatUsd:两位小数,不足一分写 < $0.01", () => {
|
||||
expect(formatUsd(undefined)).toBe("—");
|
||||
expect(formatUsd(0)).toBe("$0");
|
||||
expect(formatUsd(0.0031258)).toBe("$0.0031");
|
||||
expect(formatUsd(0.0031258)).toBe("< $0.01");
|
||||
expect(formatUsd(0.0191)).toBe("$0.02");
|
||||
expect(formatUsd(1.234)).toBe("$1.23");
|
||||
});
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { StatusTone } from "~/components/console/status-badge.tsx";
|
||||
// 定时任务看板的标签映射(只在本页用,不放 lib/labels.ts)
|
||||
|
||||
export const GROUP_ORDER: ScheduleGroup[] = ["collect", "download", "organize", "maintain", "agent"];
|
||||
/** 计划页「自动任务」一节的分组(Agent 巡检单独一节) */
|
||||
/** 计划页「自动任务」一节的分组(智能体巡检单独一节,放在最上面);维护类合成一组「数据清理」 */
|
||||
export const TASK_GROUPS: ScheduleGroup[] = ["collect", "download", "organize", "maintain"];
|
||||
|
||||
/** 计划页两节的锚点:/console/schedules#auto、#checks */
|
||||
@@ -28,7 +28,7 @@ export const GROUP_LABEL: Record<ScheduleGroup, string> = {
|
||||
collect: "采集",
|
||||
download: "下载",
|
||||
organize: "整理",
|
||||
maintain: "维护",
|
||||
maintain: "数据清理",
|
||||
agent: "智能体巡检",
|
||||
};
|
||||
|
||||
@@ -70,11 +70,12 @@ export const INTERVAL_PRESETS: number[] = [
|
||||
7 * 24 * 3600_000,
|
||||
];
|
||||
|
||||
/** 美元费用:"$0.0031" / "$0.12";空值 "—" */
|
||||
/** 美元费用:两位小数,不足一分写 "< $0.01";空值 "—" */
|
||||
export function formatUsd(v: number | null | undefined): string {
|
||||
if (v === null || v === undefined) return "—";
|
||||
if (v === 0) return "$0";
|
||||
return `$${v < 0.1 ? v.toFixed(4) : v.toFixed(2)}`;
|
||||
if (v < 0.01) return "< $0.01";
|
||||
return `$${v.toFixed(2)}`;
|
||||
}
|
||||
|
||||
/** 毫秒 → 最大整除单位 */
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { SCHEDULE_CHANGED_EVENT, type ScheduledJob } from "@umt/contracts";
|
||||
import { AlertTriangle, Bot, CalendarClock, CircleCheck, Coins, LoaderCircle, Plus, XCircle } from "lucide-react";
|
||||
import { AlertTriangle, Bot, CalendarClock, Plus } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
import { usePiUpstreamModels, usePiUpstreams } from "~/api/pi.ts";
|
||||
import { useScheduleBoard } from "~/api/schedules.ts";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { StatTile } from "~/components/console/stat-tile.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { qk } from "~/lib/query-keys.ts";
|
||||
import { useSse } from "~/lib/sse.ts";
|
||||
@@ -16,8 +15,9 @@ import { AutoTaskList } from "./auto-task-list.tsx";
|
||||
import { formatUsd } from "./labels.ts";
|
||||
import { RunsSheet } from "./runs-sheet.tsx";
|
||||
|
||||
// 计划页(FE-6,/console/schedules):「自动任务」= 程序按固定周期执行的定时任务(紧凑列表);
|
||||
// 「Agent 巡检」= 到点发起 Agent 会话的定时任务(卡片)。自动任务的运行记录跳任务页按该定时任务筛选。
|
||||
// 计划页(FE-6,/console/schedules):「智能体巡检」= 到点发起智能体会话的定时任务(卡片,放最上面:
|
||||
// 数量少、由用户自己建、会花钱,是这页最需要看的);「自动任务」= 程序按固定周期执行的定时任务
|
||||
// (按分类折叠的紧凑列表,维护类合成「数据清理」)。自动任务的运行记录跳任务页按该定时任务筛选。
|
||||
|
||||
/** 每秒一跳的当前时间(倒计时用) */
|
||||
export function useNow(ms = 1000) {
|
||||
@@ -69,7 +69,17 @@ export function planStats(items: ScheduledJob[]) {
|
||||
};
|
||||
}
|
||||
|
||||
function SectionHead({ id, title, hint, actions }: { id: string; title: string; hint: string; actions?: ReactNode }) {
|
||||
function SectionHead({
|
||||
id,
|
||||
title,
|
||||
hint,
|
||||
actions,
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
hint: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-3 flex flex-wrap items-end gap-x-3 gap-y-2">
|
||||
<div className="min-w-0">
|
||||
@@ -107,7 +117,6 @@ export function PlanBoard({ highlightJob }: { highlightJob?: string }) {
|
||||
}
|
||||
: null;
|
||||
const s = planStats(items);
|
||||
const val = (n: number) => (board.isPending ? "…" : board.isError ? "—" : n);
|
||||
|
||||
// 首页计数链到 #auto / #checks:数据到了再滚过去
|
||||
const loaded = !!data;
|
||||
@@ -117,59 +126,8 @@ export function PlanBoard({ highlightJob }: { highlightJob?: string }) {
|
||||
if (id === "auto" || id === "checks") document.getElementById(id)?.scrollIntoView({ block: "start" });
|
||||
}, [loaded, hash, highlightJob]);
|
||||
|
||||
const jump = (id: string) => document.getElementById(id)?.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 grid gap-3 lg:grid-cols-2">
|
||||
<div className="grid gap-1.5">
|
||||
<p className="px-1 text-meta font-medium text-muted-foreground">自动任务</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<StatTile
|
||||
label="已启用"
|
||||
icon={CircleCheck}
|
||||
value={val(s.auto.enabled)}
|
||||
hint={board.isPending ? undefined : `共 ${s.auto.total} 项`}
|
||||
onClick={() => jump("auto")}
|
||||
/>
|
||||
<StatTile
|
||||
label="上次失败"
|
||||
icon={XCircle}
|
||||
tone={s.auto.failed > 0 ? "destructive" : "neutral"}
|
||||
value={val(s.auto.failed)}
|
||||
onClick={() => jump("auto")}
|
||||
/>
|
||||
<StatTile label="运行中" icon={LoaderCircle} value={val(s.auto.running)} onClick={() => jump("auto")} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<p className="px-1 text-meta font-medium text-muted-foreground">智能体巡检</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<StatTile
|
||||
label="已启用"
|
||||
icon={Bot}
|
||||
value={val(s.checks.enabled)}
|
||||
hint={board.isPending ? undefined : `共 ${s.checks.total} 项`}
|
||||
onClick={() => jump("checks")}
|
||||
/>
|
||||
<StatTile
|
||||
label="上次失败"
|
||||
icon={XCircle}
|
||||
tone={s.checks.failed > 0 ? "destructive" : "neutral"}
|
||||
value={val(s.checks.failed)}
|
||||
onClick={() => jump("checks")}
|
||||
/>
|
||||
<StatTile
|
||||
label="近 7 天费用"
|
||||
icon={Coins}
|
||||
value={data ? formatUsd(s.checks.weekCost) : "…"}
|
||||
hint={data ? `${s.checks.weekRuns} 次会话` : undefined}
|
||||
onClick={() => jump("checks")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data && !data.schedulerOnline ? (
|
||||
<p
|
||||
role="status"
|
||||
@@ -186,24 +144,21 @@ export function PlanBoard({ highlightJob }: { highlightJob?: string }) {
|
||||
<ErrorState error={board.error} onRetry={() => board.refetch()} />
|
||||
) : (
|
||||
<div className="grid gap-8">
|
||||
<section aria-labelledby="auto">
|
||||
<SectionHead
|
||||
id="auto"
|
||||
title="自动任务"
|
||||
hint="程序按固定周期执行,不调用模型。间隔可直接改,改过的可恢复默认。"
|
||||
/>
|
||||
{autoJobs.length === 0 ? (
|
||||
<EmptyState icon={CalendarClock} title="还没有自动任务" description="worker 启动后自动登记。" />
|
||||
) : (
|
||||
<AutoTaskList jobs={autoJobs} now={now} highlightJob={highlightJob} />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="checks">
|
||||
<SectionHead
|
||||
id="checks"
|
||||
title="智能体巡检"
|
||||
hint="到点按目标发起一次智能体会话,只在能力白名单和预算内执行;触发条件不满足时跳过,不调用模型。"
|
||||
hint={
|
||||
s.checks.total
|
||||
? [
|
||||
`${s.checks.enabled} 个启用`,
|
||||
s.checks.failed ? `${s.checks.failed} 个上次失败` : null,
|
||||
s.checks.weekRuns ? `近 7 天 ${s.checks.weekRuns} 次,${formatUsd(s.checks.weekCost)}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
: "到点让智能体按目标检查并汇报。"
|
||||
}
|
||||
actions={
|
||||
<Button size="sm" onClick={() => setEditing({ job: null, key: Date.now() })}>
|
||||
<Plus />
|
||||
@@ -229,6 +184,24 @@ export function PlanBoard({ highlightJob }: { highlightJob?: string }) {
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<section aria-labelledby="auto">
|
||||
<SectionHead
|
||||
id="auto"
|
||||
title="自动任务"
|
||||
hint={[
|
||||
`${s.auto.enabled} 个启用`,
|
||||
s.auto.running ? `${s.auto.running} 个运行中` : null,
|
||||
s.auto.failed ? `${s.auto.failed} 个上次失败` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
/>
|
||||
{autoJobs.length === 0 ? (
|
||||
<EmptyState icon={CalendarClock} title="还没有自动任务" description="worker 启动后自动登记。" />
|
||||
) : (
|
||||
<AutoTaskList jobs={autoJobs} now={now} highlightJob={highlightJob} />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Pause, Play, ShieldAlert } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { ChevronDown, Pause, Play, ShieldAlert } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useUpdateSettings } from "~/api/settings.ts";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { type FormValues, num } from "./resource-section.tsx";
|
||||
import { SettingsBlock } from "./settings-block.tsx";
|
||||
import { anchorId, SettingsBlock } from "./settings-block.tsx";
|
||||
import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
|
||||
|
||||
// 自动化分区:总开关、只读对账、缓存水位、每日下载预算、保留天数、存储预算与保留(CACHE-2)
|
||||
@@ -15,12 +16,12 @@ const GiB = 1024 ** 3;
|
||||
|
||||
const CACHE_CARD: CardSpec = {
|
||||
anchor: "cache",
|
||||
title: "缓存水位",
|
||||
description: "缓存卷使用率到硬阈值时停止新增下载并暂停本工具的下载任务;降到恢复水位以下只恢复因阈值暂停的任务。",
|
||||
title: "下载盘",
|
||||
description: "下载盘用量到暂停线时不再新增下载,并暂停本工具的下载任务;降到恢复线以下只恢复因此暂停的任务。",
|
||||
fields: [
|
||||
{ name: "cache.hardThresholdPercent", label: "硬阈值(%)", kind: "number" },
|
||||
{ name: "cache.resumePercent", label: "恢复水位(%)", kind: "number", hint: "低于硬阈值" },
|
||||
{ name: "cache.cleanupTargetPercent", label: "清理目标(%)", kind: "number", hint: "不高于恢复水位" },
|
||||
{ name: "cache.hardThresholdPercent", label: "暂停线(%)", kind: "number" },
|
||||
{ name: "cache.resumePercent", label: "恢复线(%)", kind: "number", hint: "低于暂停线" },
|
||||
{ name: "cache.cleanupTargetPercent", label: "清理目标(%)", kind: "number", hint: "不高于恢复线" },
|
||||
{ name: "cache.checkIntervalSec", label: "检查间隔(秒)", kind: "number", hint: "10–3600" },
|
||||
],
|
||||
fromSettings: (s) => ({
|
||||
@@ -75,7 +76,7 @@ const RETENTION_CARD: CardSpec = {
|
||||
kind: "number",
|
||||
hint: "复制入库后下载源在缓存里至少留这么久;0–365",
|
||||
},
|
||||
{ name: "retention.logRetentionDays", label: "日志保留天数", kind: "number", hint: "观察与日志类数据" },
|
||||
{ name: "retention.logRetentionDays", label: "日志保留天数", kind: "number" },
|
||||
],
|
||||
fromSettings: (s) => ({
|
||||
"retention.seedRetentionDays": n(s.retention.seedRetentionDays),
|
||||
@@ -211,6 +212,59 @@ function ToggleRow({
|
||||
);
|
||||
}
|
||||
|
||||
/** 高级:只读对账、存储预算、流水保留、缓存有效期;默认收起,从 #automation/advanced 进来时展开 */
|
||||
function Advanced({ children }: { children: ReactNode }) {
|
||||
const location = useLocation();
|
||||
const [open, setOpen] = useState(() => location.hash.endsWith("/advanced"));
|
||||
useEffect(() => {
|
||||
if (location.hash.endsWith("/advanced")) setOpen(true);
|
||||
}, [location.hash]);
|
||||
return (
|
||||
<section id={anchorId("automation", "advanced")} className="scroll-mt-20">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="inline-flex items-center gap-1.5 text-lead font-semibold text-foreground"
|
||||
>
|
||||
高级
|
||||
<ChevronDown
|
||||
className={cn("size-4 text-muted-foreground transition-transform duration-base", open && "rotate-180")}
|
||||
/>
|
||||
</button>
|
||||
<p className="mt-1 text-caption text-muted-foreground">只读对账、存储预算、数据保留和缓存有效期。一般不用改。</p>
|
||||
{open ? <div className="mt-6 grid grid-cols-1 gap-8">{children}</div> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadOnlySwitch() {
|
||||
const update = useUpdateSettings();
|
||||
return (
|
||||
<SettingsGate>
|
||||
{(s) => (
|
||||
<SettingsBlock section="automation" anchor="readonly" title="只读对账">
|
||||
<ToggleRow
|
||||
id="tg-readonly"
|
||||
icon={<ShieldAlert className="h-4 w-4" />}
|
||||
tone={s.automation.readOnlyReconcile ? "warning" : "success"}
|
||||
title={`只读对账 · ${s.automation.readOnlyReconcile ? "开启" : "关闭"}`}
|
||||
description="从备份恢复后先开启:只核对库存与路径,不做自动写入。核对完再关闭。"
|
||||
checked={s.automation.readOnlyReconcile}
|
||||
disabled={update.isPending}
|
||||
onChange={(v) =>
|
||||
update.mutateAsync({ automation: { readOnlyReconcile: v } }).then(
|
||||
() => toast.success(v ? "已开启只读对账" : "已关闭只读对账"),
|
||||
(err) => toast.error(errorMessage(err, "切换失败")),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</SettingsBlock>
|
||||
)}
|
||||
</SettingsGate>
|
||||
);
|
||||
}
|
||||
|
||||
function Switches() {
|
||||
const update = useUpdateSettings();
|
||||
const set = async (patch: { enabled?: boolean; readOnlyReconcile?: boolean }, msg: string) => {
|
||||
@@ -236,16 +290,6 @@ function Switches() {
|
||||
disabled={update.isPending}
|
||||
onChange={(v) => set({ enabled: v }, v ? "自动化已恢复" : "自动化已暂停")}
|
||||
/>
|
||||
<ToggleRow
|
||||
id="tg-readonly"
|
||||
icon={<ShieldAlert className="h-4 w-4" />}
|
||||
tone={s.automation.readOnlyReconcile ? "warning" : "success"}
|
||||
title={`只读对账 · ${s.automation.readOnlyReconcile ? "开启" : "关闭"}`}
|
||||
description="从备份恢复后先开启:只核对库存与路径,不做自动写入。核对完再关闭。"
|
||||
checked={s.automation.readOnlyReconcile}
|
||||
disabled={update.isPending}
|
||||
onChange={(v) => set({ readOnlyReconcile: v }, v ? "已开启只读对账" : "已关闭只读对账")}
|
||||
/>
|
||||
</div>
|
||||
</SettingsBlock>
|
||||
)}
|
||||
@@ -263,9 +307,12 @@ export function AutomationSection() {
|
||||
<SettingsCard section="automation" spec={CACHE_CARD} settings={s} />
|
||||
<SettingsCard section="automation" spec={BUDGET_CARD} settings={s} />
|
||||
<SettingsCard section="automation" spec={RETENTION_CARD} settings={s} />
|
||||
<Advanced>
|
||||
<ReadOnlySwitch />
|
||||
<SettingsCard section="automation" spec={STORAGE_BUDGET_CARD} settings={s} />
|
||||
<SettingsCard section="automation" spec={STORAGE_RETENTION_CARD} settings={s} />
|
||||
<SettingsCard section="automation" spec={STORAGE_TTL_CARD} settings={s} />
|
||||
</Advanced>
|
||||
</>
|
||||
)}
|
||||
</SettingsGate>
|
||||
|
||||
@@ -38,7 +38,7 @@ export const CLEANUP_LABEL: Record<StorageCleanupTarget, string> = {
|
||||
audit: "审计事件",
|
||||
capability_stats: "能力调用统计",
|
||||
identity_pool: "未识别资源归档",
|
||||
analyze: "统计信息更新",
|
||||
analyze: "数据库维护",
|
||||
images: "图片缓存容量",
|
||||
kv_cache: "过期缓存",
|
||||
};
|
||||
|
||||
@@ -94,7 +94,7 @@ export const RUN_STATUS_OPTIONS = ["pending", "running", "completed", "failed",
|
||||
export function runTitle(r: Pick<EngineRun, "business" | "workflowName">): string {
|
||||
const b = r.business;
|
||||
if (b?.title) return b.title;
|
||||
if (b) return `${BUSINESS_KIND_LABEL[b.kind]} ${b.refId.length > 12 ? b.refId.slice(0, 8) : b.refId}`;
|
||||
if (b) return BUSINESS_KIND_LABEL[b.kind];
|
||||
return workflowLabel(r.workflowName);
|
||||
}
|
||||
|
||||
|
||||
@@ -254,7 +254,7 @@ export function TaskDetailBody({ r, onOpenRun }: { r: EngineRunDetail; onOpenRun
|
||||
|
||||
return (
|
||||
<>
|
||||
<DetailSection title="业务摘要">
|
||||
<DetailSection title="概况">
|
||||
<KvList
|
||||
items={[
|
||||
{
|
||||
@@ -262,11 +262,11 @@ export function TaskDetailBody({ r, onOpenRun }: { r: EngineRunDetail; onOpenRun
|
||||
value: b ? (
|
||||
link ? (
|
||||
<Link to={link.to} className="inline-flex items-center gap-1 underline underline-offset-4">
|
||||
{BUSINESS_KIND_LABEL[b.kind]} · {b.title ?? b.refId}
|
||||
{b.title ? `${BUSINESS_KIND_LABEL[b.kind]} · ${b.title}` : BUSINESS_KIND_LABEL[b.kind]}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
) : (
|
||||
`${BUSINESS_KIND_LABEL[b.kind]} · ${b.title ?? b.refId}`
|
||||
`${BUSINESS_KIND_LABEL[b.kind]}${b.title ? ` · ${b.title}` : ""}`
|
||||
)
|
||||
) : (
|
||||
"无"
|
||||
@@ -279,7 +279,6 @@ export function TaskDetailBody({ r, onOpenRun }: { r: EngineRunDetail; onOpenRun
|
||||
label: "开始",
|
||||
value: r.startedAt ? `${formatDateTime(r.startedAt)}(${formatRelative(r.startedAt)})` : "未开始",
|
||||
},
|
||||
{ label: "耗时", value: formatDuration(spanSec(r)) },
|
||||
]}
|
||||
/>
|
||||
</DetailSection>
|
||||
@@ -304,9 +303,11 @@ export function TaskDetailBody({ r, onOpenRun }: { r: EngineRunDetail; onOpenRun
|
||||
</ul>
|
||||
</DetailSection>
|
||||
|
||||
<Collapsible title={`引擎步骤(${r.stepAttempts.length})`}>
|
||||
<Collapsible title={`技术信息 · 引擎步骤(${r.stepAttempts.length})`}>
|
||||
<KvList
|
||||
items={[
|
||||
{ label: "耗时", value: formatDuration(spanSec(r)) },
|
||||
{ label: "关联对象 ID", value: b?.refId ?? null, mono: true },
|
||||
{ label: "工作流", value: `${r.workflowName}${r.version ? ` ${r.version}` : ""}`, mono: true },
|
||||
{ label: "运行 ID", value: r.id, mono: true },
|
||||
{ label: "尝试次数", value: r.attempts },
|
||||
|
||||
@@ -10,7 +10,7 @@ import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { RunsSheet } from "~/components/schedules/runs-sheet.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { formatDuration, formatRelative, spanSec } from "~/lib/labels.ts";
|
||||
import { formatRelative } from "~/lib/labels.ts";
|
||||
import { qk } from "~/lib/query-keys.ts";
|
||||
import { useSse } from "~/lib/sse.ts";
|
||||
import {
|
||||
@@ -70,6 +70,8 @@ export type StreamFilters = {
|
||||
source: string;
|
||||
since: string;
|
||||
workflow: string;
|
||||
/** "1" = 显示系统任务 */
|
||||
system: string;
|
||||
};
|
||||
|
||||
function SourceBadge({ r }: { r: EngineRun }) {
|
||||
@@ -146,8 +148,13 @@ export function TaskStream({
|
||||
true,
|
||||
active ? 10_000 : false,
|
||||
);
|
||||
const rows = list.data?.pages.flatMap((p) => p.items);
|
||||
const filtered = Object.values(filters).some(Boolean);
|
||||
const all = list.data?.pages.flatMap((p) => p.items);
|
||||
// 系统任务(没有业务对象的引擎步骤、定时任务的成功运行)默认不列;按对象 / 定时任务筛选或勾选时显示
|
||||
const showSystem = filters.system === "1" || !!filters.ref || filters.kind === "schedule" || !!filters.workflow;
|
||||
const isSystem = (r: EngineRun) => !r.business || (r.business.kind === "schedule" && r.status === "completed");
|
||||
const rows = showSystem ? all : all?.filter((r) => !isSystem(r));
|
||||
const hidden = (all?.length ?? 0) - (rows?.length ?? 0);
|
||||
const filtered = Object.entries(filters).some(([k, v]) => k !== "system" && !!v);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -196,6 +203,14 @@ export function TaskStream({
|
||||
options={SINCE}
|
||||
allLabel="不限"
|
||||
/>
|
||||
<Button
|
||||
variant={filters.system === "1" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
aria-pressed={filters.system === "1"}
|
||||
onClick={() => setFilter("system", filters.system === "1" ? "" : "1")}
|
||||
>
|
||||
{filters.system === "1" ? "隐藏系统任务" : hidden ? `显示系统任务(${hidden})` : "显示系统任务"}
|
||||
</Button>
|
||||
{filters.workflow ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setFilter("workflow", "")}>
|
||||
{workflowLabel(filters.workflow)}
|
||||
@@ -234,11 +249,9 @@ export function TaskStream({
|
||||
cell: (r) => (
|
||||
<div className="min-w-0 max-w-[15rem] sm:max-w-sm">
|
||||
<p className="truncate font-medium text-foreground">{runTitle(r)}</p>
|
||||
<p className="truncate text-meta text-muted-foreground">
|
||||
{r.business
|
||||
? `${workflowLabel(r.workflowName)} · ${BUSINESS_KIND_LABEL[r.business.kind]}`
|
||||
: `未关联业务对象 · ${r.id.slice(0, 8)}`}
|
||||
</p>
|
||||
{r.business?.title ? (
|
||||
<p className="truncate text-meta text-muted-foreground">{workflowLabel(r.workflowName)}</p>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -253,7 +266,7 @@ export function TaskStream({
|
||||
},
|
||||
{
|
||||
key: "time",
|
||||
header: "开始 / 耗时",
|
||||
header: "开始",
|
||||
hideBelow: "sm",
|
||||
cell: (r) => (
|
||||
<div className="whitespace-nowrap">
|
||||
@@ -264,7 +277,6 @@ export function TaskStream({
|
||||
? `排队 ${formatRelative(r.createdAt)}`
|
||||
: `未开始 · ${formatRelative(r.createdAt)}`}
|
||||
</p>
|
||||
<p className="text-meta text-muted-foreground tabular-nums">{formatDuration(spanSec(r))}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -97,7 +97,7 @@ export const engineRunLabel = (v: string | null | undefined) => pick(ENGINE_RUN,
|
||||
/** 相对时长:秒 → "3 分钟" */
|
||||
export function formatDuration(sec: number | null | undefined): string {
|
||||
if (sec === null || sec === undefined || Number.isNaN(sec)) return "—";
|
||||
if (sec < 1) return `${Math.round(sec * 1000)} 毫秒`;
|
||||
if (sec < 1) return "< 1 秒";
|
||||
if (sec < 60) return `${Math.round(sec)} 秒`;
|
||||
if (sec < 3600) return `${Math.floor(sec / 60)} 分 ${Math.round(sec % 60)} 秒`;
|
||||
if (sec < 86400) return `${Math.floor(sec / 3600)} 小时 ${Math.floor((sec % 3600) / 60)} 分`;
|
||||
|
||||
@@ -5,18 +5,12 @@ import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Bot,
|
||||
CalendarClock,
|
||||
CalendarCog,
|
||||
CircleCheck,
|
||||
Gauge,
|
||||
HardDrive,
|
||||
Download,
|
||||
Library as LibraryIcon,
|
||||
ListTodo,
|
||||
type LucideIcon,
|
||||
Pause,
|
||||
Play,
|
||||
Radio,
|
||||
ShieldAlert,
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useRef } from "react";
|
||||
@@ -24,31 +18,27 @@ import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useDashboard, useSitesWithAccounts } from "~/api/dashboard.ts";
|
||||
import { useDecisions } from "~/api/decisions.ts";
|
||||
import { useEngineRunsPage, useEngineStats } from "~/api/engine.ts";
|
||||
import { useEngineRunsPage } from "~/api/engine.ts";
|
||||
import { useUpdateSettings } from "~/api/settings.ts";
|
||||
import { useSystemHealth } from "~/api/system.ts";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { consolePath, settingsPath } from "~/components/console/nav.ts";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatTile } from "~/components/console/stat-tile.tsx";
|
||||
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
|
||||
import { useVolumeLabel } from "~/components/downloads/cache-volumes.tsx";
|
||||
import { formatUsd, planPath } from "~/components/schedules/labels.ts";
|
||||
import { planStats, useLiveSchedules } from "~/components/schedules/schedule-board.tsx";
|
||||
import { engineRunStatus, runTitle, workflowLabel } from "~/components/tasks/labels.ts";
|
||||
import { runTitle } from "~/components/tasks/labels.ts";
|
||||
import { useLiveTasks } from "~/components/tasks/task-stream.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { decisionKindLabel, formatDuration, formatRelative, healthLabel, spanSec } from "~/lib/labels.ts";
|
||||
import { decisionKindLabel, formatRelative } from "~/lib/labels.ts";
|
||||
import { qk } from "~/lib/query-keys.ts";
|
||||
import { useSse, useSseStatus } from "~/lib/sse.ts";
|
||||
import { cn, formatBytes } from "~/lib/utils.ts";
|
||||
import { useSse } from "~/lib/sse.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
// 首页只回答三件事(DEAI-2):在忙什么、要我处理什么、最近入库了什么;顶部一行一句话状态。
|
||||
// 运行状态、计划、缓存参数、收录版本满足率不在这里,只在出问题时写进状态行。
|
||||
|
||||
/** 首页关心的事件:任意一个到达就(节流)刷新聚合 */
|
||||
const DASHBOARD_EVENTS = [
|
||||
"schedule.changed",
|
||||
"task.created",
|
||||
"task.status_changed",
|
||||
"download.submitted",
|
||||
@@ -56,8 +46,6 @@ const DASHBOARD_EVENTS = [
|
||||
"download.threshold",
|
||||
"organization.changed",
|
||||
"agent.run_status",
|
||||
"feed.fetched",
|
||||
"feed.health_changed",
|
||||
"decision.created",
|
||||
"decision.updated",
|
||||
"settings.changed",
|
||||
@@ -82,28 +70,24 @@ function Section({
|
||||
to,
|
||||
children,
|
||||
className,
|
||||
extra,
|
||||
}: {
|
||||
title: string;
|
||||
icon: LucideIcon;
|
||||
to?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
/** 标题右侧的摘要(计数、状态) */
|
||||
extra?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className={cn("pg-slab flex min-w-0 flex-col rounded-xl p-5", className)}>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<Icon className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||
<h2 className="text-body font-semibold text-foreground">{title}</h2>
|
||||
{extra}
|
||||
{to ? (
|
||||
<Link
|
||||
to={to}
|
||||
className="ml-auto inline-flex items-center gap-1 text-meta text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
查看
|
||||
全部
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
) : null}
|
||||
@@ -117,144 +101,170 @@ function Row({ children }: { children: ReactNode }) {
|
||||
return <li className="flex min-w-0 items-center justify-between gap-3 py-1.5 text-sm">{children}</li>;
|
||||
}
|
||||
|
||||
// ─── 自动化总开关:显眼状态 + 切换 ───
|
||||
function AutomationBanner({ a }: { a: Dashboard["automation"] }) {
|
||||
/** 非零项拼一句:[[3, "个下载"], [0, "个入库中"]] → "3 个下载" */
|
||||
function nonZero(parts: [number, string][]): string {
|
||||
return parts
|
||||
.filter(([n]) => n > 0)
|
||||
.map(([n, s]) => `${n} ${s}`)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
// ─── 顶部一行:自动化状态 + 需要知道的异常 ───
|
||||
function StatusLine({ d }: { d: Dashboard }) {
|
||||
const a = d.automation;
|
||||
const update = useUpdateSettings();
|
||||
const setAutomation = async (patch: { enabled?: boolean; readOnlyReconcile?: boolean }, ok: string) => {
|
||||
const health = useSystemHealth(60_000);
|
||||
const sites = useSitesWithAccounts();
|
||||
const setEnabled = async (enabled: boolean) => {
|
||||
try {
|
||||
await update.mutateAsync({ automation: patch });
|
||||
toast.success(ok);
|
||||
await update.mutateAsync({ automation: { enabled } });
|
||||
toast.success(enabled ? "自动化已恢复" : "自动化已暂停");
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "切换失败"));
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
const paused = !a.enabled;
|
||||
const readOnly = a.readOnlyReconcile;
|
||||
|
||||
// 只列不正常的:服务异常、下载盘到暂停线、站点账号失效、下载器离线
|
||||
const problems: { text: string; to: string }[] = [];
|
||||
const lv = health.data?.level;
|
||||
if (lv === "critical" || lv === "warn")
|
||||
problems.push({ text: lv === "critical" ? "服务异常" : "服务需要关注", to: consolePath("status") });
|
||||
for (const v of d.cache.volumes)
|
||||
if (v.usedPercent !== null && v.usedPercent >= v.hardThresholdPercent)
|
||||
problems.push({ text: `下载盘已用 ${Math.round(v.usedPercent)}%,新下载已暂停`, to: consolePath("downloads") });
|
||||
const badAccounts = (sites.data?.items ?? []).filter(
|
||||
(s) => s.enabled && s.accountStatus && ["invalid", "expired", "challenge"].includes(s.accountStatus),
|
||||
).length;
|
||||
if (badAccounts) problems.push({ text: `${badAccounts} 个站点账号需要重新登录`, to: consolePath("sites") });
|
||||
const offline = d.sources.downloaders.filter((x) => x.enabled && ["offline", "failing"].includes(x.health)).length;
|
||||
if (offline) problems.push({ text: `${offline} 个下载器连不上`, to: settingsPath("downloaders") });
|
||||
|
||||
const paused = !a.enabled;
|
||||
const warn = paused || a.readOnlyReconcile || problems.length > 0;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pg-slab mb-4 flex flex-col gap-4 rounded-xl p-4 sm:flex-row sm:items-center sm:p-5",
|
||||
(paused || readOnly) && "border border-warning/40",
|
||||
"pg-slab mb-4 flex flex-col gap-3 rounded-xl px-4 py-3 sm:flex-row sm:items-center sm:px-5",
|
||||
warn && "border border-warning/40",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-start gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-10 w-10 shrink-0 place-items-center rounded-full",
|
||||
paused || readOnly ? "bg-warning/10 text-warning" : "bg-success/10 text-success",
|
||||
)}
|
||||
>
|
||||
{readOnly ? (
|
||||
<ShieldAlert className="h-5 w-5" />
|
||||
) : paused ? (
|
||||
<Pause className="h-5 w-5" />
|
||||
) : (
|
||||
<Play className="h-5 w-5" />
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-lead font-semibold text-foreground">
|
||||
{paused ? "自动化已暂停" : readOnly ? "只读对账中" : "自动化运行中"}
|
||||
</p>
|
||||
<StatusBadge tone={paused ? "warning" : "success"}>总开关{paused ? "关" : "开"}</StatusBadge>
|
||||
{readOnly ? <StatusBadge tone="warning">只读对账</StatusBadge> : null}
|
||||
</div>
|
||||
<p className="mt-0.5 text-caption text-muted-foreground">
|
||||
{paused
|
||||
? "自动收录、下载、整理已停止,手动操作仍可用。"
|
||||
: readOnly
|
||||
? "只核对,不写入。确认无误后关闭。"
|
||||
: `${a.activeSubscriptions} 个订阅在追踪,自动收录、下载和整理。`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-2">
|
||||
<div className="flex items-center gap-2 text-caption text-foreground">
|
||||
<label htmlFor="home-readonly">只读对账</label>
|
||||
<Switch
|
||||
id="home-readonly"
|
||||
checked={readOnly}
|
||||
disabled={update.isPending}
|
||||
onCheckedChange={(v) =>
|
||||
setAutomation({ readOnlyReconcile: v }, v ? "已开启只读对账" : "已关闭只读对账").catch(() => {})
|
||||
}
|
||||
aria-label="只读对账模式"
|
||||
/>
|
||||
</div>
|
||||
<p className="min-w-0 flex-1 text-sm text-foreground">
|
||||
<span className={cn("mr-2 inline-block size-2 rounded-full", warn ? "bg-warning" : "bg-success")} aria-hidden />
|
||||
{paused ? (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={update.isPending}
|
||||
onClick={() => setAutomation({ enabled: true }, "自动化已恢复").catch(() => {})}
|
||||
>
|
||||
"自动化已暂停,手动操作仍可用"
|
||||
) : a.readOnlyReconcile ? (
|
||||
<>
|
||||
只读模式:不会自动下载和整理。
|
||||
<Link to={settingsPath("automation", "advanced")} className="underline underline-offset-4">
|
||||
去设置关闭
|
||||
</Link>
|
||||
</>
|
||||
) : a.activeSubscriptions > 0 ? (
|
||||
`自动化已开启 · ${a.activeSubscriptions} 个订阅`
|
||||
) : (
|
||||
<>
|
||||
自动化已开启 · 还没有订阅。
|
||||
<Link to={consolePath("search")} className="underline underline-offset-4">
|
||||
去搜索添加
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{problems.map((p) => (
|
||||
<span key={p.text}>
|
||||
{" · "}
|
||||
<Link to={p.to} className="text-warning underline-offset-4 hover:underline">
|
||||
{p.text}
|
||||
</Link>
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
{paused ? (
|
||||
<Button size="sm" disabled={update.isPending} onClick={() => setEnabled(true).catch(() => {})}>
|
||||
<Play />
|
||||
恢复自动化
|
||||
恢复
|
||||
</Button>
|
||||
) : (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="outline" size="sm" disabled={update.isPending}>
|
||||
<Pause />
|
||||
暂停自动化
|
||||
暂停
|
||||
</Button>
|
||||
}
|
||||
title="暂停全部自动化?"
|
||||
description="自动收录、下载、整理会停止,已提交的下载任务不受影响。可随时恢复。"
|
||||
confirmLabel="暂停自动化"
|
||||
onConfirm={() => setAutomation({ enabled: false }, "自动化已暂停")}
|
||||
onConfirm={() => setEnabled(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 正在进行:任务流 top N ───
|
||||
function InProgress({ w }: { w: Dashboard["currentWork"] }) {
|
||||
const running = useEngineRunsPage({ status: "running" }, 6, 10_000);
|
||||
const stats = useEngineStats();
|
||||
const rows = running.data?.items ?? [];
|
||||
// ─── 在忙什么:下载中的任务 + 进行中的智能体会话 + 有业务对象的运行 ───
|
||||
function Busy({ w }: { w: Dashboard["currentWork"] }) {
|
||||
const running = useEngineRunsPage({ status: "running" }, 10, 10_000);
|
||||
const runs = (running.data?.items ?? []).filter((r) => r.business);
|
||||
const downloads = w.items.slice(0, 4);
|
||||
const agents = w.agentRuns.slice(0, 2);
|
||||
const summary = nonZero([
|
||||
[w.downloading, "个下载中"],
|
||||
[w.importing, "个入库中"],
|
||||
[w.stalled, "个没有进展"],
|
||||
[w.paused, "个已暂停"],
|
||||
]);
|
||||
const empty = !downloads.length && !agents.length && !runs.length;
|
||||
return (
|
||||
<Section title="正在进行" icon={Activity} to="/console/tasks?tab=active">
|
||||
{running.isPending ? (
|
||||
<Section title="在忙什么" icon={Activity} to="/console/tasks?tab=active">
|
||||
{running.isPending && !downloads.length ? (
|
||||
<LoadingState rows={3} />
|
||||
) : running.isError ? (
|
||||
<ErrorState plain error={running.error} onRetry={() => running.refetch()} />
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyState plain icon={CircleCheck} title="没有进行中的任务" className="py-6" />
|
||||
) : empty ? (
|
||||
<EmptyState plain icon={CircleCheck} title="现在没有在跑的任务" className="py-6" />
|
||||
) : (
|
||||
<ul className="divide-y divide-border/60">
|
||||
{rows.slice(0, 5).map((r) => (
|
||||
{downloads.map((t) => (
|
||||
<Row key={t.downloadTaskId}>
|
||||
<Link to={`/console/downloads/${t.downloadTaskId}`} className="min-w-0 flex-1 hover:underline">
|
||||
<span className="block truncate">{t.name}</span>
|
||||
{t.status === "stalled" ? <span className="block text-meta text-warning">没有进展</span> : null}
|
||||
</Link>
|
||||
<span className="shrink-0 text-meta text-muted-foreground tabular-nums">
|
||||
<Download className="mr-1 inline h-3 w-3" aria-hidden />
|
||||
{Math.round(t.progress * 100)}%
|
||||
</span>
|
||||
</Row>
|
||||
))}
|
||||
{agents.map((r) => (
|
||||
<Row key={r.id}>
|
||||
<Link to={`/console/agent/${r.id}`} className="min-w-0 flex-1 hover:underline">
|
||||
<span className="block truncate">{r.goal}</span>
|
||||
</Link>
|
||||
<Bot className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-label="智能体" />
|
||||
</Row>
|
||||
))}
|
||||
{runs.slice(0, Math.max(0, 5 - downloads.length - agents.length)).map((r) => (
|
||||
<Row key={r.id}>
|
||||
<Link to={`/console/tasks?run=${encodeURIComponent(r.id)}`} className="min-w-0 flex-1 hover:underline">
|
||||
<span className="block truncate">{runTitle(r)}</span>
|
||||
<span className="block truncate text-meta text-muted-foreground">{workflowLabel(r.workflowName)}</span>
|
||||
</Link>
|
||||
<span className="shrink-0 text-meta text-muted-foreground tabular-nums">
|
||||
{formatDuration(spanSec(r))}
|
||||
</span>
|
||||
</Row>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="mt-auto pt-3 text-meta text-muted-foreground">
|
||||
排队 {stats.data?.byStatus.pending ?? "—"} · 下载中 {w.downloading} · 无进展 {w.stalled} · 入库中 {w.importing}
|
||||
</p>
|
||||
{summary ? <p className="mt-auto pt-3 text-meta text-muted-foreground">{summary}</p> : null}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 需要我处理:计数 + 前 3 条 ───
|
||||
// ─── 要我处理什么:计数 + 前 3 条 ───
|
||||
function NeedsMe({ n }: { n: Dashboard["needsAttention"] }) {
|
||||
const list = useDecisions({});
|
||||
const top = (list.data?.pages[0]?.items ?? []).slice(0, 3);
|
||||
return (
|
||||
<Section title="需要我处理" icon={AlertTriangle} to={consolePath("inbox")}>
|
||||
<Section title="要我处理" icon={AlertTriangle} to={consolePath("inbox")}>
|
||||
{n.total === 0 ? (
|
||||
<EmptyState plain icon={CircleCheck} title="没有待处理事项" className="py-6" />
|
||||
<EmptyState plain icon={CircleCheck} title="没有要你处理的事" className="py-6" />
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-2 text-title-sm font-semibold text-warning tabular-nums">
|
||||
@@ -268,9 +278,7 @@ function NeedsMe({ n }: { n: Dashboard["needsAttention"] }) {
|
||||
<Row key={d.id}>
|
||||
<Link to={`/console/inbox?kind=${d.kind}`} className="min-w-0 flex-1 hover:underline">
|
||||
<span className="block truncate">{d.title}</span>
|
||||
<span className="block truncate text-meta text-muted-foreground">
|
||||
{decisionKindLabel(d.kind)} · {formatRelative(d.createdAt)}
|
||||
</span>
|
||||
<span className="block truncate text-meta text-muted-foreground">{formatRelative(d.createdAt)}</span>
|
||||
</Link>
|
||||
</Row>
|
||||
))}
|
||||
@@ -281,387 +289,39 @@ function NeedsMe({ n }: { n: Dashboard["needsAttention"] }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 媒体概览:作品/季/资产计数 + 收录版本满足率 + 今日产出 ───
|
||||
function MediaOverview({ a }: { a: Dashboard["automation"] }) {
|
||||
const c = a.slotCounts;
|
||||
const total = Object.values(c).reduce((x, y) => x + y, 0);
|
||||
const rate = a.slotSatisfiedRate === null ? null : Math.round(a.slotSatisfiedRate * 100);
|
||||
const lib = a.library;
|
||||
// ─── 最近入库了什么:成功 / 部分成功的整理 ───
|
||||
function RecentImports({ d }: { d: Dashboard }) {
|
||||
const rows = d.recentOutput.filter((r) => r.status === "succeeded" || r.status === "partial").slice(0, 6);
|
||||
const today = d.automation.today.filesImported;
|
||||
return (
|
||||
<Section title="媒体概览" icon={LibraryIcon} to={consolePath("library")}>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<StatTile plain label="作品" value={lib.entities} hint={`电影 ${lib.movies} · 剧集 ${lib.series}`} />
|
||||
<StatTile plain label="季" value={lib.seasons} hint={`${lib.episodes} 集`} />
|
||||
<StatTile plain label="资产" value={lib.assets} hint="已入库视频" />
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="flex items-baseline justify-between text-sm">
|
||||
<span>收录版本满足率</span>
|
||||
<span className="font-semibold tabular-nums">{rate === null ? "—" : `${rate}%`}</span>
|
||||
</div>
|
||||
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-foreground/[0.08]" aria-hidden>
|
||||
<div className="h-full rounded-full bg-foreground/60" style={{ width: `${rate ?? 0}%` }} />
|
||||
</div>
|
||||
<p className="mt-1.5 text-meta text-muted-foreground">
|
||||
满足 {c.satisfied} / {total} · 需关注 {c.attention} · 下载中 {c.downloading}
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-auto pt-3 text-meta text-muted-foreground">
|
||||
今日 新计划 {a.today.plansCreated} · 提交下载 {a.today.downloadsSubmitted} · 入库文件 {a.today.filesImported} ·
|
||||
升级 {a.today.upgrades}
|
||||
</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 计划带:自动任务与巡检分别计数,链到计划页对应节 ───
|
||||
function PlanCount({
|
||||
to,
|
||||
label,
|
||||
icon: Icon,
|
||||
children,
|
||||
}: {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="flex min-w-0 flex-col gap-1 rounded-xl border border-border/70 px-3 py-2.5 transition-colors duration-micro hover:bg-foreground/[0.04]"
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-meta text-muted-foreground">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{label}
|
||||
</span>
|
||||
<span className="flex flex-wrap items-baseline gap-x-3 gap-y-0.5 text-caption text-muted-foreground">
|
||||
{children}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function Num({ children, tone }: { children: ReactNode; tone?: "destructive" }) {
|
||||
return (
|
||||
<span className={cn("text-lead font-semibold tabular-nums", tone ? "text-destructive" : "text-foreground")}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanStrip() {
|
||||
const board = useLiveSchedules();
|
||||
const s = planStats(board.data?.items ?? []);
|
||||
return (
|
||||
<Section
|
||||
title="计划"
|
||||
icon={CalendarCog}
|
||||
to={planPath()}
|
||||
extra={
|
||||
board.data && !board.data.schedulerOnline ? <span className="text-meta text-warning">调度器未在线</span> : null
|
||||
}
|
||||
>
|
||||
{board.isPending ? (
|
||||
<LoadingState rows={2} />
|
||||
) : board.isError ? (
|
||||
<ErrorState plain error={board.error} onRetry={() => board.refetch()} />
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<PlanCount to={planPath({ section: "auto" })} label="自动任务" icon={CalendarClock}>
|
||||
<span>
|
||||
启用 <Num>{s.auto.enabled}</Num> / {s.auto.total}
|
||||
</span>
|
||||
<span>
|
||||
失败 <Num tone={s.auto.failed ? "destructive" : undefined}>{s.auto.failed}</Num>
|
||||
</span>
|
||||
</PlanCount>
|
||||
<PlanCount to={planPath({ section: "checks" })} label="智能体巡检" icon={Bot}>
|
||||
<span>
|
||||
启用 <Num>{s.checks.enabled}</Num> / {s.checks.total}
|
||||
</span>
|
||||
<span title="近 7 天巡检会话费用合计">
|
||||
近 7 天 <Num>{formatUsd(s.checks.weekCost)}</Num>
|
||||
</span>
|
||||
</PlanCount>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 最近完成 / 失败 ───
|
||||
function RecentRuns() {
|
||||
const done = useEngineRunsPage({ status: "completed" }, 6);
|
||||
const failed = useEngineRunsPage({ status: "failed" }, 6);
|
||||
const rows = [...(done.data?.items ?? []), ...(failed.data?.items ?? [])]
|
||||
.sort((x, y) => (y.finishedAt ?? y.createdAt).localeCompare(x.finishedAt ?? x.createdAt))
|
||||
.slice(0, 6);
|
||||
const loading = done.isPending || failed.isPending;
|
||||
const err = done.error ?? failed.error;
|
||||
return (
|
||||
<Section title="最近完成 / 失败" icon={ListTodo} to="/console/tasks">
|
||||
{loading ? (
|
||||
<LoadingState rows={3} />
|
||||
) : err ? (
|
||||
<ErrorState
|
||||
plain
|
||||
error={err}
|
||||
onRetry={() => {
|
||||
void done.refetch();
|
||||
void failed.refetch();
|
||||
}}
|
||||
/>
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyState plain icon={ListTodo} title="还没有结束的任务" className="py-6" />
|
||||
) : (
|
||||
<ul className="divide-y divide-border/60">
|
||||
{rows.map((r) => {
|
||||
const s = engineRunStatus(r);
|
||||
return (
|
||||
<Row key={r.id}>
|
||||
<Link to={`/console/tasks?run=${encodeURIComponent(r.id)}`} className="min-w-0 flex-1 hover:underline">
|
||||
<span className="block truncate">{runTitle(r)}</span>
|
||||
<span className="block truncate text-meta text-muted-foreground">
|
||||
{workflowLabel(r.workflowName)} · {formatRelative(r.finishedAt)}
|
||||
</span>
|
||||
</Link>
|
||||
<StatusBadge tone={s.tone}>{s.label}</StatusBadge>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 来源健康:站点(含账号状态)+ Feed / 下载器 / 媒体服务器,有问题的排前 ───
|
||||
const ACCOUNT: Record<string, { label: string; tone: StatusTone }> = {
|
||||
unconfigured: { label: "未配置账号", tone: "neutral" },
|
||||
unknown: { label: "账号未验证", tone: "neutral" },
|
||||
active: { label: "账号正常", tone: "success" },
|
||||
invalid: { label: "账号失效", tone: "destructive" },
|
||||
expired: { label: "登录过期", tone: "warning" },
|
||||
challenge: { label: "需人工验证", tone: "warning" },
|
||||
};
|
||||
|
||||
function SourcesSection({ s }: { s: Dashboard["sources"] }) {
|
||||
const sites = useSitesWithAccounts();
|
||||
const siteRows = (sites.data?.items ?? s.sites).map((x) => {
|
||||
const acc = "accountStatus" in x && x.accountStatus ? ACCOUNT[x.accountStatus] : undefined;
|
||||
return {
|
||||
id: x.id,
|
||||
kind: "站点",
|
||||
name: x.name,
|
||||
enabled: x.enabled,
|
||||
health: "healthStatus" in x ? x.healthStatus : x.health,
|
||||
account: acc,
|
||||
to: `/console/sites/${x.id}`,
|
||||
};
|
||||
});
|
||||
const rows = [
|
||||
...siteRows,
|
||||
...s.feeds.map((x) => ({
|
||||
id: x.id,
|
||||
kind: "RSS",
|
||||
name: x.name,
|
||||
enabled: x.enabled,
|
||||
health: x.health,
|
||||
account: undefined,
|
||||
to: consolePath("sources"),
|
||||
})),
|
||||
...s.downloaders.map((x) => ({
|
||||
id: x.id,
|
||||
kind: "下载器",
|
||||
name: x.name,
|
||||
enabled: x.enabled,
|
||||
health: x.health,
|
||||
account: undefined,
|
||||
to: settingsPath("downloaders"),
|
||||
})),
|
||||
];
|
||||
const bad = (r: (typeof rows)[number]) =>
|
||||
(r.enabled && healthLabel(r.health).tone !== "success" ? 2 : 0) +
|
||||
(r.account && r.account.tone !== "success" ? 1 : 0);
|
||||
rows.sort((x, y) => bad(y) - bad(x));
|
||||
const accountIssues = siteRows.filter(
|
||||
(r) => r.account && (r.account.tone === "destructive" || r.account.tone === "warning"),
|
||||
).length;
|
||||
return (
|
||||
<Section title="来源健康" icon={Radio} to={consolePath("sites")}>
|
||||
<Section title="最近入库" icon={LibraryIcon} to={consolePath("organize")}>
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState plain icon={Radio} title="还没有来源" description="登记站点或新建 RSS 后显示。" className="py-6" />
|
||||
<EmptyState plain icon={LibraryIcon} title="最近没有新入库的作品" className="py-6" />
|
||||
) : (
|
||||
<>
|
||||
{accountIssues ? <p className="mb-1 text-meta text-warning">{accountIssues} 个站点的账号需要处理</p> : null}
|
||||
<ul className="divide-y divide-border/60">
|
||||
{rows.slice(0, 7).map((r) => {
|
||||
const h = r.enabled ? healthLabel(r.health) : healthLabel("disabled");
|
||||
return (
|
||||
<Row key={`${r.kind}-${r.id}`}>
|
||||
<Link to={r.to} className="min-w-0 flex-1 hover:underline">
|
||||
<span className="block truncate">
|
||||
<span className="mr-1.5 text-meta text-muted-foreground">{r.kind}</span>
|
||||
{r.name}
|
||||
</span>
|
||||
{r.account ? (
|
||||
<span
|
||||
className={cn(
|
||||
"block truncate text-meta",
|
||||
r.account.tone === "destructive"
|
||||
? "text-destructive"
|
||||
: r.account.tone === "warning"
|
||||
? "text-warning"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{r.account.label}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
<StatusBadge tone={h.tone}>{h.label}</StatusBadge>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 运行状态摘要(/system/health) ───
|
||||
const LEVEL: Record<string, { label: string; tone: StatusTone }> = {
|
||||
ok: { label: "正常", tone: "success" },
|
||||
warn: { label: "需要关注", tone: "warning" },
|
||||
critical: { label: "需要处理", tone: "destructive" },
|
||||
unknown: { label: "未知", tone: "neutral" },
|
||||
};
|
||||
|
||||
function StatusSummary() {
|
||||
const h = useSystemHealth(30_000);
|
||||
const d = h.data;
|
||||
const stale = d?.processes.filter((p) => p.stale) ?? [];
|
||||
const alive = (d?.processes.length ?? 0) - stale.length;
|
||||
const mountsBad = d?.mounts.filter((m) => m.status !== "online").length ?? 0;
|
||||
const lv = LEVEL[d?.level ?? "unknown"] ?? LEVEL.unknown;
|
||||
return (
|
||||
<Section
|
||||
title="运行状态"
|
||||
icon={Gauge}
|
||||
to={consolePath("status")}
|
||||
className="md:col-span-2 xl:col-span-3"
|
||||
extra={d ? <StatusBadge tone={lv.tone}>{lv.label}</StatusBadge> : null}
|
||||
>
|
||||
{h.isPending ? (
|
||||
<LoadingState rows={1} />
|
||||
) : h.isError ? (
|
||||
<ErrorState plain error={h.error} onRetry={() => h.refetch()} />
|
||||
) : d ? (
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-1.5 text-sm">
|
||||
<span>
|
||||
进程 <span className="font-medium tabular-nums">{alive}</span> 在线
|
||||
{stale.length ? (
|
||||
<span className="text-warning">
|
||||
{" "}
|
||||
· {stale.length} 个无心跳({stale.map((p) => `${p.processKind} ${p.pid}`).join("、")})
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span>
|
||||
待执行 <span className="font-medium tabular-nums">{d.engine.pending}</span>
|
||||
{d.engine.oldestPendingAgeSec ? ` · 最久 ${formatDuration(d.engine.oldestPendingAgeSec)}` : ""}
|
||||
</span>
|
||||
<span>
|
||||
24 小时失败率{" "}
|
||||
<span className="font-medium tabular-nums">
|
||||
{d.engine.failureRate24h === null ? "—" : `${(d.engine.failureRate24h * 100).toFixed(1)}%`}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
数据库{" "}
|
||||
<span className="font-medium tabular-nums">{d.db.latencyMs === null ? "—" : `${d.db.latencyMs} 毫秒`}</span>
|
||||
</span>
|
||||
{mountsBad ? <span className="text-destructive">{mountsBad} 个挂载根不可用</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function CacheSection({ c }: { c: Dashboard["cache"] }) {
|
||||
const volumeLabel = useVolumeLabel();
|
||||
return (
|
||||
<Section title="缓存与保留" icon={HardDrive} to={consolePath("downloads")}>
|
||||
{c.volumes.length === 0 ? (
|
||||
<EmptyState
|
||||
plain
|
||||
icon={HardDrive}
|
||||
title="还没有下载器"
|
||||
description="添加下载器后显示缓存水位。"
|
||||
className="py-6"
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-4">
|
||||
{c.volumes.map((v) => {
|
||||
const pct = v.usedPercent;
|
||||
const over = pct !== null && pct >= v.hardThresholdPercent;
|
||||
const near = pct !== null && pct >= v.resumePercent;
|
||||
return (
|
||||
<li key={v.volumeKey} className="min-w-0">
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
<span className="truncate font-medium">{volumeLabel(v)}</span>
|
||||
<StatusBadge tone={pct === null ? "neutral" : over ? "destructive" : near ? "warning" : "success"}>
|
||||
{pct === null ? "用量未知" : `${pct.toFixed(0)}%${over ? " 已达阈值" : ""}`}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<div className="relative mt-2 h-1.5 overflow-hidden rounded-full bg-foreground/[0.08]" aria-hidden>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full",
|
||||
over ? "bg-destructive" : near ? "bg-warning" : "bg-foreground/60",
|
||||
)}
|
||||
style={{ width: `${pct ?? 0}%` }}
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-y-0 w-px bg-destructive"
|
||||
style={{ left: `${v.hardThresholdPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1.5 text-meta text-muted-foreground">
|
||||
阈值 {v.hardThresholdPercent}% / 恢复 {v.resumePercent}% · 在途 {formatBytes(v.pendingBytes)} · 可清理{" "}
|
||||
{formatBytes(v.cleanableBytes)} · 保留期锁定 {formatBytes(v.retentionLockedBytes)}
|
||||
{v.pausedByThreshold ? ` · 因阈值暂停 ${v.pausedByThreshold} 个` : ""}
|
||||
</p>
|
||||
{v.notes.map((n) => (
|
||||
<p key={n} className="text-meta text-muted-foreground">
|
||||
{n}
|
||||
</p>
|
||||
))}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{rows.map((r) => (
|
||||
<Row key={r.recordId}>
|
||||
<Link
|
||||
to={settingsPath("automation", "retention")}
|
||||
className="mt-3 text-meta text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
||||
to={r.mediaEntityId ? `/console/library/${r.mediaEntityId}` : `/console/organize/${r.recordId}`}
|
||||
className="min-w-0 flex-1 hover:underline"
|
||||
>
|
||||
调整阈值与保留天数
|
||||
<span className="block truncate">{r.title ?? r.targetPath?.split("/").pop() ?? "未关联作品"}</span>
|
||||
{r.status === "partial" ? (
|
||||
<span className="block text-meta text-warning">
|
||||
{r.counts.succeeded}/{r.counts.total} 个文件入库
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
<span className="shrink-0 text-meta text-muted-foreground">{formatRelative(r.finishedAt)}</span>
|
||||
</Row>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{today ? <p className="mt-auto pt-3 text-meta text-muted-foreground">今天入库 {today} 个文件</p> : null}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function LiveIndicator() {
|
||||
const s = useSseStatus();
|
||||
const tone = s === "open" ? "success" : s === "reconnecting" ? "warning" : "neutral";
|
||||
const label = s === "open" ? "实时" : s === "reconnecting" ? "重连中" : "连接中";
|
||||
return <StatusBadge tone={tone}>{label}</StatusBadge>;
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const q = useLiveDashboard();
|
||||
useLiveTasks();
|
||||
@@ -669,21 +329,15 @@ export default function Home() {
|
||||
<>
|
||||
<PageHeader
|
||||
title="首页"
|
||||
description="正在进行的任务、需要处理的事项、计划和最近结果。"
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<LiveIndicator />
|
||||
{q.data ? (
|
||||
<span className="hidden text-meta text-muted-foreground sm:inline">
|
||||
更新于 {formatRelative(q.data.generatedAt)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
q.data ? (
|
||||
<span className="text-meta text-muted-foreground">更新于 {formatRelative(q.data.generatedAt)}</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{q.isPending ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{["a", "b", "c", "d", "e", "f"].map((k) => (
|
||||
{["a", "b", "c"].map((k) => (
|
||||
<div key={k} className="pg-slab rounded-xl p-5">
|
||||
<LoadingState rows={3} />
|
||||
</div>
|
||||
@@ -693,16 +347,11 @@ export default function Home() {
|
||||
<ErrorState title="首页加载失败" error={q.error} onRetry={() => q.refetch()} />
|
||||
) : (
|
||||
<>
|
||||
<AutomationBanner a={q.data.automation} />
|
||||
<StatusLine d={q.data} />
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<InProgress w={q.data.currentWork} />
|
||||
<Busy w={q.data.currentWork} />
|
||||
<NeedsMe n={q.data.needsAttention} />
|
||||
<MediaOverview a={q.data.automation} />
|
||||
<PlanStrip />
|
||||
<RecentRuns />
|
||||
<CacheSection c={q.data.cache} />
|
||||
<SourcesSection s={q.data.sources} />
|
||||
<StatusSummary />
|
||||
<RecentImports d={q.data} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -18,7 +18,6 @@ export default function SchedulesPage() {
|
||||
<>
|
||||
<PageHeader
|
||||
title="计划"
|
||||
description="定时任务的开关、间隔和下次运行。每次运行的结果在任务页。"
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -20,7 +20,7 @@ export const clientLoader = legacyRedirectLoader;
|
||||
|
||||
type Tab = StreamTab;
|
||||
const TABS: Tab[] = ["all", "active", "failed"];
|
||||
const FILTER_KEYS: (keyof StreamFilters)[] = ["status", "kind", "ref", "source", "since", "workflow"];
|
||||
const FILTER_KEYS: (keyof StreamFilters)[] = ["status", "kind", "ref", "source", "since", "workflow", "system"];
|
||||
|
||||
function Count({ n }: { n: number | undefined }) {
|
||||
if (!n) return null;
|
||||
@@ -99,7 +99,6 @@ export default function TasksPage() {
|
||||
<>
|
||||
<PageHeader
|
||||
title="任务"
|
||||
description="每次运行都是一条任务,不论定时、事件、手动还是智能体触发。"
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={() => qc.invalidateQueries({ queryKey: qk.all("engine") })}>
|
||||
<RotateCw />
|
||||
|
||||
@@ -803,14 +803,14 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
|
||||
{
|
||||
id: "automation",
|
||||
label: "自动化",
|
||||
description: "总开关、缓存水位、每日预算、保留天数",
|
||||
description: "总开关、下载盘、每日预算、保留天数",
|
||||
legacyTabs: [{ tab: "automation" }, { tab: "storage", anchor: "cache" }],
|
||||
anchors: [
|
||||
{ id: "switches", label: "开关" },
|
||||
{ id: "cache", label: "缓存水位" },
|
||||
{ id: "cache", label: "下载盘" },
|
||||
{ id: "budget", label: "每日预算" },
|
||||
{ id: "retention", label: "保留" },
|
||||
{ id: "storage", label: "存储与保留" },
|
||||
{ id: "advanced", label: "高级" },
|
||||
],
|
||||
fields: [
|
||||
"automation.enabled",
|
||||
|
||||
Reference in new issue
Block a user