feat: v0.3.8–v0.3.14 工作区汇总(依赖升级、整理来源、手动入库、原地入库等)

生产 v0.3.8~v0.3.13 均由 8013196 加工作区修改构建部署,此前未提交;本提交补齐源码,
并含未部署的 v0.3.14 原地入库。中间各版本源码状态未保存,无法逐版拆分,详见
docs/plan/17-full-upgrade.md 与 docs/plan/18-organize-release.md。

- v0.3.8 全面升级:Deno workspace 依赖、Router 8 / Vite 8、camofox 1.18.0 / Node 24、
  PostgreSQL 18.6、备份客户端 18.6、演示 qB 5.2.4
- v0.3.9 媒体整理:分类规则、整理来源与目录快照(0059)、文件管理整理入口
- v0.3.10 手动下载与入库:独立手动下载、详情页资源下载、TMDB / Local 元数据兜底
  (metadata.resolve_organize)、正常多版本 / 一次性洗版(0060)
- v0.3.11 搜索详情与桌面黑边修复
- v0.3.12 列表分页、搜索历史、首页概况
- v0.3.13 统一整理弹窗与队列
- v0.3.14 原地入库(0061,未部署)

同批审计修复:
- 刮削入队:确认原记录存在后才删重放产生的新记录;写回 run 关联失败时保持 queued,
  由处理器补关联(对齐 MP 先登记、只在入队失败时记失败)
- 识别升级链只在结果更确定时采用,不再用 no_match 顶替原有歧义候选
- bluray_dir 资产以原盘目录本身为刮削根(MP 同做法)
- metadata.resolve_organize 登记到入库排障 Skill 与能力地图
- db/src/types.ts 按迁移重新生成;contracts 整理动作枚举顺序与数据库对齐
- 测试:原地入库用例改为直接查记录表;domain 测试模板补 version
- 前端:通知铃铛弹层与用户菜单统一样式;站点卡片共用卡头、底栏贴底、分页间距

依赖升级证据只提交摘要文件,registry-snapshot.json 移至 .local/(SHA-256 记于文档)。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-02 15:47:17 +08:00
1 parent 8013196e3f
commit ceb66f8c77
265 files changed
+17751 -2483

No files matched your search

+5 -1
View File
@@ -2,11 +2,15 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { ApiKey, ApiKeyCreate, ApiKeyCreated, ApiKeyUpdate, CursorPage } from "@umt/contracts";
import { apiData } from "~/lib/api.ts";
import { qk } from "~/lib/query-keys.ts";
import { fetchAllCursorPages } from "./parse.ts";
export function useApiKeys() {
return useQuery({
queryKey: qk.list("api-keys"),
queryFn: ({ signal }) => apiData<CursorPage<ApiKey>>("/api-keys", { params: { limit: 200 }, signal }),
queryFn: ({ signal }) =>
fetchAllCursorPages((cursor) =>
apiData<CursorPage<ApiKey>>("/api-keys", { params: { limit: 200, cursor }, signal }),
),
});
}
+4 -2
View File
@@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query";
import { cursorPage, type Dashboard, SiteListItem } from "@umt/contracts";
import { apiData } from "~/lib/api.ts";
import { qk } from "~/lib/query-keys.ts";
import { apiParsed } from "./parse.ts";
import { apiParsed, fetchAllCursorPages } from "./parse.ts";
/** 首页聚合;SSE 事件触发 invalidate,另有 60 秒兜底轮询 */
export function useDashboard() {
@@ -20,7 +20,9 @@ export function useSitesWithAccounts() {
return useQuery({
queryKey: [...qk.dashboard, "sites-accounts"],
queryFn: ({ signal }) =>
apiParsed(SitePage, "/api/v1/sites", { params: { limit: 200, include: "accounts" }, signal }),
fetchAllCursorPages((cursor) =>
apiParsed(SitePage, "/api/v1/sites", { params: { limit: 200, cursor, include: "accounts" }, signal }),
),
refetchInterval: 60_000,
});
}
+7 -2
View File
@@ -13,7 +13,7 @@ import {
} from "@umt/contracts";
import { z } from "zod";
import { TTL_SECOND } from "~/lib/api.ts";
import { apiMutate, apiParsed, cleanParams } from "./parse.ts";
import { apiMutate, apiParsed, cleanParams, fetchAllCursorPages } from "./parse.ts";
// 下载任务、计划(界面统称"下载")、缓存卷(W2-C:/download-tasks、/acquisition-plans、/download-cache/cleanup、/dashboard)
@@ -181,7 +181,12 @@ export function useDownloaderNames() {
queryKey: ["downloads", "instances"],
staleTime: 5 * 60 * TTL_SECOND,
queryFn: ({ signal }) =>
apiParsed(cursorPage(DownloaderInstance), "/api/v1/downloader-instances", { params: { limit: 200 }, signal }),
fetchAllCursorPages((cursor) =>
apiParsed(cursorPage(DownloaderInstance), "/api/v1/downloader-instances", {
params: { limit: 200, cursor },
signal,
}),
),
select: (d) => new Map(d.items.map((i) => [i.id, i.name])),
});
}
+1
View File
@@ -9,6 +9,7 @@ export type EngineRunFilters = {
status?: string;
/** 触发来源,单值或逗号分隔多值(user,api_key) */
triggeredBy?: string;
/** 单值或逗号分隔多个工作流名称 */
workflowName?: string;
businessKind?: EngineBusinessKind;
businessRefId?: string;
+4 -2
View File
@@ -10,7 +10,7 @@ import {
} from "@umt/contracts";
import { z } from "zod";
import { TTL_SECOND } from "~/lib/api.ts";
import { apiMutate, apiParsed, cleanParams } from "./parse.ts";
import { apiMutate, apiParsed, cleanParams, fetchAllCursorPages } from "./parse.ts";
// Feed 来源(W2-B:/feed-sources)
@@ -25,7 +25,9 @@ export function useFeeds(f: { siteId?: string; health?: FeedHealth } = {}) {
return useQuery({
queryKey: feedKeys.list(f),
queryFn: ({ signal }) =>
apiParsed(FeedPage, "/api/v1/feed-sources", { params: cleanParams({ ...f, limit: 200 }), signal }),
fetchAllCursorPages((cursor) =>
apiParsed(FeedPage, "/api/v1/feed-sources", { params: cleanParams({ ...f, limit: 200, cursor }), signal }),
),
refetchInterval: 30 * TTL_SECOND,
});
}
+10 -4
View File
@@ -24,14 +24,16 @@ import type {
} from "@umt/contracts";
import { apiData } from "~/lib/api.ts";
import { type QueryArea, qk } from "~/lib/query-keys.ts";
import { fetchAllCursorPages } from "./parse.ts";
/** 实例类资源的标准 CRUD 钩子(列表取前 200 条,首版实例数量很少) */
/** 实例类资源的标准 CRUD 钩子(列表按游标取齐配置) */
function crudHooks<Row extends { id: string }, Create, Update>(area: QueryArea, base: string) {
return {
useList: () =>
useQuery({
queryKey: qk.list(area),
queryFn: ({ signal }) => apiData<CursorPage<Row>>(base, { params: { limit: 200 }, signal }),
queryFn: ({ signal }) =>
fetchAllCursorPages((cursor) => apiData<CursorPage<Row>>(base, { params: { limit: 200, cursor }, signal })),
}),
useCreate: () => {
const qc = useQueryClient();
@@ -100,14 +102,18 @@ export const pathMappings = {
export function useSiteOptions() {
return useQuery({
queryKey: qk.list("sites", { limit: 200 }),
queryFn: ({ signal }) => apiData<CursorPage<Site>>("/sites", { params: { limit: 200 }, signal }),
queryFn: ({ signal }) =>
fetchAllCursorPages((cursor) => apiData<CursorPage<Site>>("/sites", { params: { limit: 200, cursor }, signal })),
});
}
export function useSiteAccounts() {
return useQuery({
queryKey: qk.list("site-accounts"),
queryFn: ({ signal }) => apiData<CursorPage<SiteAccount>>("/site-accounts", { params: { limit: 200 }, signal }),
queryFn: ({ signal }) =>
fetchAllCursorPages((cursor) =>
apiData<CursorPage<SiteAccount>>("/site-accounts", { params: { limit: 200, cursor }, signal }),
),
});
}
+3 -1
View File
@@ -16,6 +16,7 @@ import {
} from "@umt/contracts";
import type { z } from "zod";
import { ApiRequestError, api, type QueryParams } from "~/lib/api.ts";
import { fetchAllCursorPages } from "./parse.ts";
// 媒体库:列表 / 详情 / 作品详情(内容单元、别名、字段来源)与作品级操作。
// 所有响应都按 contracts 的 zod schema 校验,结构不符按错误处理(不静默渲染半截数据)。
@@ -106,7 +107,8 @@ const RootPage = cursorPage(LibraryRoot);
export function useLibraryRoots() {
return useQuery({
queryKey: libraryKeys.roots,
queryFn: ({ signal }) => getParsed(RootPage, "/library-roots", { limit: 200 }, signal),
queryFn: ({ signal }) =>
fetchAllCursorPages((cursor) => getParsed(RootPage, "/library-roots", { limit: 200, cursor }, signal)),
staleTime: 5 * 60_000,
});
}
+54
View File
@@ -0,0 +1,54 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ClassificationConfigView,
type OrganizeSourceInput,
OrganizeSourceList,
OrganizeSourceView,
OrganizeStockResult,
} from "@umt/contracts";
import { z } from "zod";
import { apiParsed } from "./parse.ts";
export function useOrganizeSources() {
return useQuery({
queryKey: ["organize-sources"],
queryFn: ({ signal }) => apiParsed(OrganizeSourceList, "/organize-sources", { signal }),
refetchInterval: 10_000,
});
}
export function useSaveOrganizeSource() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, body }: { id: string; body: OrganizeSourceInput }) =>
apiParsed(OrganizeSourceView, `/organize-sources/${id}`, { method: "PUT", body }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["organize-sources"] }),
});
}
export function useRemoveOrganizeSource() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
apiParsed(z.object({ deleted: z.boolean() }), `/organize-sources/${id}`, { method: "DELETE" }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["organize-sources"] }),
});
}
export function useOrganizeStock() {
return useMutation({
mutationFn: ({ id, path }: { id: string; path?: string }) =>
apiParsed(OrganizeStockResult, `/organize-sources/${id}/stock`, { method: "POST", body: { path } }),
});
}
export function useClassification() {
return useQuery({
queryKey: ["classification"],
queryFn: ({ signal }) => apiParsed(ClassificationConfigView, "/classification", { signal }),
});
}
export function useSaveClassification() {
const qc = useQueryClient();
return useMutation({
mutationFn: (yaml: string) =>
apiParsed(ClassificationConfigView, "/classification", { method: "PUT", body: { yaml } }),
onSuccess: (data) => qc.setQueryData(["classification"], data),
});
}
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from "vitest";
import { fetchAllCursorPages } from "./parse.ts";
describe("fetchAllCursorPages", () => {
it("keeps configuration rows beyond the first cursor page", async () => {
const fetchPage = vi.fn(async (cursor?: string) =>
cursor
? { items: [201, 202], nextCursor: null, total: 202 }
: { items: Array.from({ length: 200 }, (_, i) => i + 1), nextCursor: "page-2", total: 202 },
);
const result = await fetchAllCursorPages(fetchPage);
expect(result.items).toHaveLength(202);
expect(result.items.slice(-2)).toEqual([201, 202]);
expect(result.total).toBe(202);
expect(result.nextCursor).toBeNull();
expect(fetchPage.mock.calls).toEqual([[], ["page-2"]]);
});
it("rejects a repeated cursor instead of looping indefinitely", async () => {
const fetchPage = vi.fn(async () => ({ items: [1], nextCursor: "same" }));
await expect(fetchAllCursorPages(fetchPage)).rejects.toThrow("分页游标重复");
expect(fetchPage).toHaveBeenCalledTimes(2);
});
it("propagates a later page failure instead of returning a partial configuration", async () => {
const failure = new Error("aborted");
await expect(
fetchAllCursorPages(async (cursor) => {
if (cursor) throw failure;
return { items: [1], nextCursor: "next" };
}),
).rejects.toBe(failure);
});
});
+18
View File
@@ -37,3 +37,21 @@ export function cleanParams(p: Record<string, string | number | boolean | undefi
for (const [k, v] of Object.entries(p)) if (v !== undefined && v !== null && v !== "") out[k] = v;
return out;
}
/** 小规模配置集合:跟随游标取齐,保留列表返回形状供选择器与本地分页共用。 */
export async function fetchAllCursorPages<P extends { items: unknown[]; nextCursor?: string | null }>(
fetchPage: (cursor?: string) => Promise<P>,
): Promise<P> {
const first = await fetchPage();
const items = [...first.items];
const seen = new Set<string>();
let cursor = first.nextCursor;
while (cursor) {
if (seen.has(cursor)) throw new ApiRequestError("internal", "分页游标重复,请重试");
seen.add(cursor);
const page = await fetchPage(cursor);
items.push(...page.items);
cursor = page.nextCursor;
}
return { ...first, items, nextCursor: null };
}
+3 -1
View File
@@ -9,6 +9,7 @@ import {
} from "@umt/contracts";
import { api } from "~/lib/api.ts";
import { getParsed, parseOrThrow } from "./library.ts";
import { fetchAllCursorPages } from "./parse.ts";
// 策略版本:草案 → 预览 → 授权发布 → 版本化 / 回滚(REQ §6.4)
@@ -24,7 +25,8 @@ const Page = cursorPage(PolicyRevision);
export function usePolicyRevisions(filters: PolicyFilters = {}) {
return useQuery({
queryKey: policyKeys.list(filters),
queryFn: ({ signal }) => getParsed(Page, "/policy-revisions", { ...filters, limit: 200 }, signal),
queryFn: ({ signal }) =>
fetchAllCursorPages((cursor) => getParsed(Page, "/policy-revisions", { ...filters, limit: 200, cursor }, signal)),
});
}
+12 -6
View File
@@ -11,6 +11,7 @@ import {
} from "@umt/contracts";
import { api } from "~/lib/api.ts";
import { getParsed, parseOrThrow } from "./library.ts";
import { fetchAllCursorPages } from "./parse.ts";
// 偏好标签与抑制记录(REQ §6.7):停用/删除/语义变更都会留抑制记录,Agent 不得据旧证据自动加回。
@@ -29,7 +30,10 @@ const SupPage = cursorPage(PreferenceSuppression);
export function usePreferenceTags(filters: TagFilters = {}) {
return useQuery({
queryKey: preferenceKeys.tags(filters),
queryFn: ({ signal }) => getParsed(TagPage, "/preference-tags", { ...filters, limit: 200 }, signal),
queryFn: ({ signal }) =>
fetchAllCursorPages((cursor) =>
getParsed(TagPage, "/preference-tags", { ...filters, limit: 200, cursor }, signal),
),
});
}
@@ -37,11 +41,13 @@ export function useSuppressions(active?: boolean) {
return useQuery({
queryKey: preferenceKeys.suppressions(active),
queryFn: ({ signal }) =>
getParsed(
SupPage,
"/preference-suppressions",
{ active: active === undefined ? undefined : String(active), limit: 200 },
signal,
fetchAllCursorPages((cursor) =>
getParsed(
SupPage,
"/preference-suppressions",
{ active: active === undefined ? undefined : String(active), limit: 200, cursor },
signal,
),
),
});
}
+6 -2
View File
@@ -15,7 +15,7 @@ import {
PublicOrganizationRecord,
} from "@umt/contracts";
import { TTL_SECOND } from "~/lib/api.ts";
import { apiMutate, apiParsed, cleanParams } from "./parse.ts";
import { apiMutate, apiParsed, cleanParams, fetchAllCursorPages } from "./parse.ts";
// 整理记录(W2-C:/organization-records;读 organize.read,发起 organize.execute)
@@ -23,6 +23,7 @@ const RecordPage = cursorPage(PublicOrganizationRecord);
export type RecordFilters = {
status?: OrganizationStatus;
phase?: "active" | "history";
trigger?: OrganizationTrigger;
mediaEntityId?: string;
q?: string;
@@ -145,6 +146,7 @@ export const TRIGGER_LABEL: Record<OrganizationTrigger, string> = { auto: "自
/** 整理方式 / 文件操作(记录的 action) */
export const ACTION_LABEL = {
copy: "复制",
in_place: "原地入库",
move: "移动",
rename: "重命名",
relocate: "目录调整",
@@ -174,7 +176,9 @@ export function useLibraryRoots() {
queryKey: ["records", "library-roots"],
staleTime: 5 * 60 * TTL_SECOND,
queryFn: ({ signal }) =>
apiParsed(cursorPage(LibraryRoot), "/api/v1/library-roots", { params: { limit: 200 }, signal }),
fetchAllCursorPages((cursor) =>
apiParsed(cursorPage(LibraryRoot), "/api/v1/library-roots", { params: { limit: 200, cursor }, signal }),
),
});
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
type ManualAcquisitionRequest,
ManualAcquisitionResult,
type ResolutionStatus,
WorkbenchReleaseDetail,
@@ -81,7 +82,7 @@ export function useRefreshReleaseDetail() {
export function useSubmitRelease() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: { sourceReleaseId: string; slotId?: string; entityId?: string; note?: string }) =>
mutationFn: (body: ManualAcquisitionRequest) =>
apiMutate("/api/v1/acquisition-plans", { body }).then((d) => ManualAcquisitionResult.parse(d)),
onSuccess: (r) => {
if (r.submitted) void qc.invalidateQueries({ queryKey: ["downloads"] });
+7 -3
View File
@@ -22,6 +22,7 @@ import {
} from "@umt/contracts";
import { api } from "~/lib/api.ts";
import { getParsed, parseOrThrow } from "./library.ts";
import { fetchAllCursorPages } from "./parse.ts";
// 规则(W9-D,docs/runbook/rules.md):规则组 = 收录过滤条件 + 偏好标签合一;自定义条件;三套默认链;抑制记录。
// 写入给 ruleString(优先)或 layers;PATCH 带 expectedRevision,不匹配 409。
@@ -53,7 +54,8 @@ const SupPage = cursorPage(PreferenceSuppression);
export function useRuleGroups(filters: RuleGroupFilters = {}) {
return useQuery({
queryKey: ruleKeys.groups(filters),
queryFn: ({ signal }) => getParsed(GroupPage, "/rules", { ...filters, limit: 200 }, signal),
queryFn: ({ signal }) =>
fetchAllCursorPages((cursor) => getParsed(GroupPage, "/rules", { ...filters, limit: 200, cursor }, signal)),
placeholderData: keepPreviousData,
});
}
@@ -78,7 +80,8 @@ export function useRuleKeywords() {
export function useRuleAtoms() {
return useQuery({
queryKey: ruleKeys.atoms,
queryFn: ({ signal }) => getParsed(AtomPage, "/rules/atoms", { limit: 200 }, signal),
queryFn: ({ signal }) =>
fetchAllCursorPages((cursor) => getParsed(AtomPage, "/rules/atoms", { limit: 200, cursor }, signal)),
});
}
@@ -92,7 +95,8 @@ export function useRuleDefaults() {
export function useRuleSuppressions() {
return useQuery({
queryKey: ruleKeys.suppressions,
queryFn: ({ signal }) => getParsed(SupPage, "/rules/suppressions", { limit: 200 }, signal),
queryFn: ({ signal }) =>
fetchAllCursorPages((cursor) => getParsed(SupPage, "/rules/suppressions", { limit: 200, cursor }, signal)),
});
}
+8 -4
View File
@@ -19,7 +19,7 @@ import {
} from "@umt/contracts";
import { z } from "zod";
import { TTL_SECOND } from "~/lib/api.ts";
import { apiMutate, apiParsed } from "./parse.ts";
import { apiMutate, apiParsed, fetchAllCursorPages } from "./parse.ts";
// 站点、站点账号、站点快照(W2-B:/sites、/site-accounts、/sites/{id}/snapshot(s))
// 站点中心(W9-A):include=accounts、凭证 PUT、健康检测、刷新登录、用户数据、删除
@@ -41,12 +41,14 @@ export const siteKeys = {
loginHistory: (accountId: string) => ["sites", "login-history", accountId] as const,
};
/** 站点列表(附账号与整体账号状态,站点页一次请求) */
/** 站点列表(附账号与整体账号状态,站点页按游标取齐配置) */
export function useSites() {
return useQuery({
queryKey: siteKeys.list(),
queryFn: ({ signal }) =>
apiParsed(SitePage, "/api/v1/sites", { params: { limit: 200, include: "accounts" }, signal }),
fetchAllCursorPages((cursor) =>
apiParsed(SitePage, "/api/v1/sites", { params: { limit: 200, include: "accounts", cursor }, signal }),
),
});
}
@@ -62,7 +64,9 @@ export function useSiteAccounts(siteId?: string) {
return useQuery({
queryKey: siteKeys.accounts(siteId),
queryFn: ({ signal }) =>
apiParsed(AccountPage, "/api/v1/site-accounts", { params: { limit: 200, siteId }, signal }),
fetchAllCursorPages((cursor) =>
apiParsed(AccountPage, "/api/v1/site-accounts", { params: { limit: 200, siteId, cursor }, signal }),
),
});
}
+5 -1
View File
@@ -8,6 +8,7 @@ import {
} from "@umt/contracts";
import { api } from "~/lib/api.ts";
import { getParsed, parseOrThrow } from "./library.ts";
import { fetchAllCursorPages } from "./parse.ts";
// 收录目标 Slot:硬条件 / 软偏好 / 回退 / 升级阈值 / 完成条件(REQ §6.1)
@@ -24,7 +25,10 @@ export function useSlots(filters: SlotFilters, enabled = true) {
return useQuery({
queryKey: slotKeys.list(filters),
enabled,
queryFn: ({ signal }) => getParsed(Page, "/acquisition-slots", { ...filters, limit: 200 }, signal),
queryFn: ({ signal }) =>
fetchAllCursorPages((cursor) =>
getParsed(Page, "/acquisition-slots", { ...filters, limit: 200, cursor }, signal),
),
});
}
@@ -1,11 +1,12 @@
import { Bot, ChevronRight } from "lucide-react";
import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react";
import { type ReactNode, useLayoutEffect, useRef, useState } from "react";
import { Link } from "react-router";
import { type AgentRunView, TRIGGER_LABEL, useAgentRuns } from "~/api/agent.ts";
import { CHEVRON_CLASS } from "~/components/agentui/disclosure.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { Button } from "~/components/ui/button.tsx";
import { useListPagination } from "~/components/console/use-list-pagination.ts";
import { Input } from "~/components/ui/input.tsx";
import { Pagination } from "~/components/ui/pagination.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import { cn } from "~/lib/utils.ts";
import { contextLabel, RunStatusBadge } from "./agent-conversation.tsx";
@@ -176,10 +177,10 @@ function Row({
}
/**
* 会话列表:搜索 + 全部 / 等你处理 / 失败 + 游标加载(滚到底自动加载)。
* 会话列表:搜索 + 全部 / 等你处理 / 失败 + 按需加载的统一分页。
* - hrefFor:会话页左栏,点一条跳到该会话页。
* - onOpen:抽屉历史弹层,点一条在抽屉内打开;remember 时筛选与滚动位置下次打开时恢复。
* - limit:历史弹层只列最近 N 条(置顶的算在内),不自动翻页。
* - limit:历史弹层的默认每页条数,可调节并翻页查看更早的会话。
*/
export function AgentSessionList({
activeId,
@@ -208,32 +209,29 @@ export function AgentSessionList({
const waiting = useAgentRuns("waiting");
const loaded = runs.data?.pages.flatMap((p) => p.items) ?? [];
const pending = waiting.data?.pages.flatMap((p) => p.items) ?? [];
const { pinned, rows } = arrangeHistory([...pending.filter(awaitingUser), ...loaded], f, limit);
const canLoadMore = !limit && !!runs.hasNextPage;
const arranged = arrangeHistory([...pending.filter(awaitingUser), ...loaded], f);
const loadWaiting = f.view !== "failed" && !!waiting.hasNextPage;
const canLoadMore = loadWaiting || !!runs.hasNextPage;
const paged = useListPagination([...arranged.pinned, ...arranged.rows], {
hasMore: canLoadMore,
onLoadMore: () => (loadWaiting ? waiting.fetchNextPage() : runs.fetchNextPage()),
loadingMore: waiting.isFetchingNextPage || runs.isFetchingNextPage,
error: runs.error ?? (loadWaiting ? waiting.error : undefined),
resetKey: JSON.stringify(f),
defaultPageSize: limit,
});
const visible = paged.items ?? [];
const pinned = f.view === "all" ? visible.filter((row) => awaitingUser(row.run)) : [];
const rows = f.view === "all" ? visible.filter((row) => !awaitingUser(row.run)) : visible;
const filtered = !!f.q.trim() || f.view !== "all";
const scroller = useRef<HTMLDivElement>(null);
const sentinel = useRef<HTMLDivElement>(null);
const restored = useRef(!remember);
useLayoutEffect(() => {
if (restored.current || !rows.length || !scroller.current) return;
restored.current = true;
scroller.current.scrollTop = drawerMemory.scroll;
});
const { fetchNextPage, isFetchingNextPage } = runs;
useEffect(() => {
const el = sentinel.current;
if (!el || !canLoadMore || typeof IntersectionObserver === "undefined") return;
const io = new IntersectionObserver(
(es) => {
if (es.some((e) => e.isIntersecting) && !isFetchingNextPage) void fetchNextPage();
},
{ root: scroller.current, rootMargin: "120px" },
);
io.observe(el);
return () => io.disconnect();
}, [canLoadMore, isFetchingNextPage, fetchNextPage]);
const rowProps = { activeId, hrefFor, onOpen };
return (
<nav aria-label="智能体会话列表" className={cn("flex min-h-0 flex-col", className)}>
@@ -305,20 +303,8 @@ export function AgentSessionList({
</ul>
</>
)}
{canLoadMore ? (
<div ref={sentinel} className="pt-2">
<Button
variant="ghost"
size="sm"
className="w-full"
disabled={isFetchingNextPage}
onClick={() => void fetchNextPage()}
>
{isFetchingNextPage ? "加载中…" : "加载更早的会话"}
</Button>
</div>
) : null}
</div>
{visible.length > 0 || canLoadMore ? <Pagination {...paged.pagination} /> : null}
</nav>
);
}
@@ -16,6 +16,7 @@ import { apiData, errorMessage } from "~/lib/api.ts";
import { qk } from "~/lib/query-keys.ts";
import { agentActive, controllerLabel, credentialBadge, holderName, waitingText } from "./coords.ts";
import { ReconnectPanel, ReloadNotice, useAutoReconnect } from "./reconnect.tsx";
import { ResourceDownload } from "./resource-download.tsx";
import { SiteOriginView } from "./site-origin-view.tsx";
// 原站视图桌面模式(docs/design/webtop-remote-browser.md §5.7):iframe 嵌浏览器桌面(Selkies),
@@ -208,6 +209,7 @@ function DesktopView({
return (
<div className="space-y-3">
<ResourceDownload session={s} />
<div className="flex flex-wrap items-center gap-2">
<StatusBadge tone={conn.tone}>{conn.text}</StatusBadge>
<StatusBadge tone={CONTROLLER_TONE[s.controller] ?? "neutral"}>
@@ -0,0 +1,88 @@
import type { BrowserResourceResult, BrowserSessionView } from "@umt/contracts";
import { Download, Settings2 } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { useSubmitRelease } from "~/api/releases.ts";
import { ManualDownloadDialog } from "~/components/downloads/manual-download-dialog.tsx";
import { Button } from "~/components/ui/button.tsx";
import { apiData, errorMessage } from "~/lib/api.ts";
/** 控件位于 iframe/截图外;直接解析当前详情页并交给共同手动下载入口。 */
export function ResourceDownload({ session }: { session: BrowserSessionView }) {
const [busy, setBusy] = useState(false);
const [resource, setResource] = useState<BrowserResourceResult | null>(null);
const [open, setOpen] = useState(false);
const [unrecognized, setUnrecognized] = useState(false);
const [planId, setPlanId] = useState<string | null>(null);
const submit = useSubmitRelease();
const prepare = async (options: boolean) => {
setBusy(true);
try {
const r = await apiData<BrowserResourceResult>("/browser/sessions/{id}/resource", {
method: "POST",
pathParams: { id: session.id },
body: {},
});
setResource(r);
setUnrecognized(false);
if (options) setOpen(true);
else {
const result = await submit.mutateAsync({
sourceReleaseId: r.release.id,
importMode: "normal",
replacementAssetIds: [],
});
if (result.requiresConfirmation) {
setUnrecognized(true);
setOpen(true);
} else if (result.submitted) {
setPlanId(result.plan?.id ?? null);
toast.success("已提交下载");
} else toast.warning(result.rejected.map((v) => v.message).join(";") || "没有提交下载");
}
} catch (e) {
toast.error(errorMessage(e));
} finally {
setBusy(false);
}
};
return (
<>
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" disabled={busy || session.controller === "closed"} onClick={() => void prepare(false)}>
<Download />
{busy ? "正在处理…" : "下载当前资源"}
</Button>
<Button
size="sm"
variant="outline"
disabled={busy || session.controller === "closed"}
onClick={() => void prepare(true)}
>
<Settings2 />
下载选项
</Button>
{planId ? (
<Link className="text-caption underline" to={`/console/downloads?plan=${planId}`}>
查看下载任务
</Link>
) : null}
</div>
{resource ? (
<ManualDownloadDialog
key={resource.release.id}
resource={{
title: resource.release.title,
sizeBytes: resource.release.sizeBytes,
entityId: resource.release.entity?.id,
}}
open={open}
onOpenChange={setOpen}
initialUnrecognized={unrecognized}
submit={(options) => submit.mutateAsync({ ...options, sourceReleaseId: resource.release.id })}
/>
) : null}
</>
);
}
@@ -36,6 +36,7 @@ import { Input } from "~/components/ui/input.tsx";
import { errorMessage } from "~/lib/api.ts";
import { controllerLabel, credentialBadge, holderName, toViewport, waitingText } from "./coords.ts";
import { isBrowserGone, ReconnectPanel, ReloadNotice, useAutoReconnect } from "./reconnect.tsx";
import { ResourceDownload } from "./resource-download.tsx";
// 原站视图(REQ §4.7.1、§4.7.3):同一个 camofox 会话的定时截图 + 人工输入映射。
// 人工接管前服务端先停掉该会话的自动化输入;交还后 Agent 必须重新读取页面。
@@ -153,6 +154,8 @@ export function SiteOriginView({ site }: { site: SiteListItem }) {
<p className="text-caption text-muted-foreground">验证码等需本人操作在此完成。</p>
<ReloadNotice session={s} />
<ResourceDownload session={s} />
<div className="flex flex-wrap items-center gap-2">
<StatusBadge tone={conn.tone}>{conn.text}</StatusBadge>
<StatusBadge tone={CONTROLLER_TONE[s.controller] ?? "neutral"}>
@@ -85,6 +85,6 @@ describe("DataTable groupBy:渲染", () => {
rowKey: (r: Row) => r.id,
}),
);
expect(html).not.toContain("aria-expanded");
expect(html).not.toContain('aria-expanded="true"');
});
});
+21 -14
View File
@@ -1,9 +1,10 @@
import { ChevronRight, type LucideIcon } from "lucide-react";
import { Fragment, type ReactNode, useEffect, useMemo, useState } from "react";
import { Button } from "~/components/ui/button.tsx";
import { Pagination, type PaginationProps } from "~/components/ui/pagination.tsx";
import { cn } from "~/lib/utils.ts";
import { groupRows, readCollapsed, toggleCollapsed, writeCollapsed } from "./data-table-groups.ts";
import { EmptyState, ErrorState, LoadingState } from "./empty-state.tsx";
import { useListPagination } from "./use-list-pagination.ts";
export type DataColumn<T> = {
/** 列 key(React key 用) */
@@ -33,7 +34,7 @@ const HIDE: Record<NonNullable<DataColumn<unknown>["hideBelow"]>, string> = {
};
/**
* 数据表:内置 loading / error / empty 三态与"加载更多"游标翻页。
* 数据表:内置 loading / error / empty 三态与统一分页。
* 外层自带 pg-slab 卡片(plain 时不带);表格在卡片内横向滚动,页面本身不溢出。
*
* <DataTable columns={cols} rows={q.data?.items} rowKey={(r) => r.id}
@@ -43,7 +44,7 @@ const HIDE: Record<NonNullable<DataColumn<unknown>["hideBelow"]>, string> = {
*/
export function DataTable<T>({
columns,
rows,
rows: allRows,
rowKey,
loading = false,
error,
@@ -57,6 +58,8 @@ export function DataTable<T>({
plain = false,
groupBy,
className,
pagination = true,
paginationKey,
}: {
columns: DataColumn<T>[];
rows: T[] | undefined;
@@ -73,7 +76,17 @@ export function DataTable<T>({
plain?: boolean;
groupBy?: DataGroupBy<T>;
className?: string;
pagination?: boolean | PaginationProps;
paginationKey?: string;
}) {
const paged = useListPagination(allRows, {
hasMore: pagination === true && hasMore,
onLoadMore,
loadingMore,
error,
resetKey: paginationKey ?? (allRows?.[0] ? rowKey(allRows[0]) : ""),
});
const rows = pagination === true ? paged.items : allRows;
const storageKey = groupBy?.storageKey;
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
// 折叠态在挂载后读(服务端渲染时没有 localStorage,首屏一律展开)
@@ -217,17 +230,11 @@ export function DataTable<T>({
return (
<div className={cn("min-w-0 overflow-hidden rounded-xl", !plain && "pg-slab", className)}>
{body}
{rows && rows.length > 0 && (hasMore || error) ? (
<div className="flex items-center justify-center gap-3 border-t border-border/60 px-4 py-3">
{error ? (
<span className="text-meta text-destructive">{String((error as Error).message ?? error)}</span>
) : null}
{hasMore && onLoadMore ? (
<Button variant="outline" size="sm" onClick={onLoadMore} disabled={loadingMore}>
{loadingMore ? "加载中…" : "加载更多"}
</Button>
) : null}
</div>
{error && allRows?.length ? (
<div className="px-4 py-2 text-meta text-destructive">{String((error as Error).message ?? error)}</div>
) : null}
{pagination && allRows ? (
<Pagination {...(typeof pagination === "object" ? pagination : paged.pagination)} />
) : null}
</div>
);
+29 -31
View File
@@ -4,6 +4,7 @@ import { ArrowRight, Bell, CheckCheck } from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { Link, useNavigate } from "react-router";
import { decisionKeys, useDecisionSummary, useDecisions } from "~/api/decisions.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { SEVERITY } from "~/components/library/labels.ts";
import { Button } from "~/components/ui/button.tsx";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
@@ -59,55 +60,54 @@ export function InboxBell() {
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="relative h-8 w-8"
variant="outline"
size="icon-sm"
className="relative"
aria-label={total ? `待处理 ${total} 项` : "待处理"}
title={total ? `待处理 ${total} 项` : "待处理"}
>
<Bell className="h-4 w-4" />
{total ? (
<span className="absolute -top-0.5 -right-0.5 min-w-4 rounded-full bg-warning px-1 text-center text-micro leading-4 font-semibold text-background tabular-nums">
<span className="absolute -top-0.5 -right-0.5 min-w-4 rounded-full bg-primary px-1 text-center text-micro leading-4 font-semibold text-primary-foreground tabular-nums">
{total > 99 ? "99+" : total}
</span>
) : null}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-[min(22rem,calc(100vw-2rem))] p-0">
<div className="flex items-center justify-between border-b border-border/60 px-4 py-3">
<PopoverContent
align="end"
sideOffset={8}
collisionPadding={16}
aria-label="待处理事项"
className="pg-float-solid flex max-h-[var(--radix-popover-content-available-height)] w-[min(22rem,calc(100vw-2rem))] flex-col overflow-hidden p-2"
>
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-2 pt-1 pb-2">
<p className="text-sm font-semibold text-foreground">
待处理
{total ? <span className="ml-1.5 text-warning tabular-nums">{total}</span> : null}
{total ? <span className="ml-1.5 font-normal text-muted-foreground tabular-nums">{total}</span> : null}
</p>
<button
type="button"
onClick={() => go(inboxPath())}
className="inline-flex cursor-pointer items-center gap-1 text-meta text-muted-foreground hover:text-foreground"
>
<Button variant="ghost" size="xs" onClick={() => go(inboxPath())}>
查看全部
<ArrowRight className="h-3.5 w-3.5" />
</button>
<ArrowRight />
</Button>
</div>
{list.isPending ? (
<p className="px-4 py-6 text-center text-caption text-muted-foreground">加载中…</p>
<LoadingState className="px-4 py-6" />
) : list.isError ? (
<p className="px-4 py-6 text-center text-caption text-destructive">加载失败,稍后再试</p>
<ErrorState plain error="稍后再试" className="py-8" />
) : groups.length === 0 ? (
<div className="flex flex-col items-center gap-2 px-4 py-8 text-caption text-muted-foreground">
<CheckCheck className="h-5 w-5" />
没有需要处理的事项
</div>
<EmptyState plain icon={CheckCheck} title="没有需要处理的事项" className="py-8" />
) : (
<div className="max-h-[60vh] overflow-y-auto py-1">
<div className="min-h-0 overflow-y-auto overscroll-contain pt-1">
{groups.map((g) => (
<section key={g.kind} className="py-1">
<section key={g.kind} className="pt-1">
<button
type="button"
onClick={() => go(inboxPath(g.kind))}
className="flex w-full cursor-pointer items-center justify-between px-4 py-1 text-meta font-medium text-muted-foreground hover:text-foreground"
className="flex w-full cursor-pointer items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50"
>
<span>{decisionKindLabel(g.kind)}</span>
<span className="tabular-nums">{g.count}</span>
<span className="tracking-normal tabular-nums">{g.count}</span>
</button>
<ul>
{g.items.map((d) => (
@@ -115,14 +115,12 @@ export function InboxBell() {
<Link
to={inboxPath(d.kind)}
onClick={() => setOpen(false)}
className="flex items-start gap-2.5 px-4 py-2 hover:bg-foreground/[0.04]"
className="block rounded-lg px-2 py-2 outline-none transition-colors hover:bg-foreground/[0.06] focus-visible:bg-foreground/[0.06] focus-visible:ring-2 focus-visible:ring-ring/50"
>
<span aria-hidden className={cn("mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full", DOT[d.severity])} />
<span className="min-w-0 flex-1">
<span className="line-clamp-2 text-caption text-foreground">{d.title}</span>
<span className="text-meta text-muted-foreground">
{SEVERITY[d.severity].label} · {formatRelative(d.createdAt)}
</span>
<span className="line-clamp-2 text-body break-words text-foreground">{d.title}</span>
<span className="mt-0.5 flex items-center gap-1.5 text-meta text-muted-foreground">
<span aria-hidden className={cn("h-1.5 w-1.5 shrink-0 rounded-full", DOT[d.severity])} />
{SEVERITY[d.severity].label} · {formatRelative(d.createdAt)}
</span>
</Link>
</li>
@@ -0,0 +1,47 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { Pagination, pageNumbers } from "~/components/ui/pagination.tsx";
import { DataTable } from "./data-table.tsx";
import { pageWindow } from "./use-list-pagination.ts";
describe("list pagination", () => {
it("keeps empty and short final pages in range", () => {
expect(pageWindow(0, 20, 8)).toEqual({ page: 1, pages: 1, start: 0, end: 0 });
expect(pageWindow(135, 100, 8)).toEqual({ page: 2, pages: 2, start: 100, end: 135 });
expect(pageWindow(135, 50, 0)).toEqual({ page: 1, pages: 3, start: 0, end: 50 });
});
it("shows nearby pages and endpoints without a button for every record page", () => {
expect(pageNumbers(50, 100)).toEqual([1, "…", 49, 50, 51, "…", 100]);
expect(pageNumbers(1, 2)).toEqual([1, 2]);
});
it("does not invent a total or last page for cursor results", () => {
const html = renderToStaticMarkup(
createElement(Pagination, {
page: 1,
pageSize: 50,
total: 50,
hasMore: true,
onPageChange: () => {},
}),
);
expect(html).toContain("已加载 50 条");
expect(html).toContain('aria-label="第 2 页"');
expect(html).not.toContain('aria-label="最后一页"');
});
it("limits table rows and shares the page-size control even for empty lists", () => {
const table = (rows: { id: string }[]) =>
renderToStaticMarkup(
createElement(DataTable<{ id: string }>, {
rows,
columns: [{ key: "id", header: "ID", cell: (r: { id: string }) => r.id }],
rowKey: (r: { id: string }) => r.id,
}),
);
const html = table(Array.from({ length: 135 }, (_, i) => ({ id: `item-${i}` })));
expect(html.split("<tbody>")[1]?.split("</tbody>")[0]?.match(/<tr /g)).toHaveLength(50);
expect(html).toContain('aria-label="每页条数"');
expect(html).not.toContain("item-50</td>");
expect(table([])).toContain('aria-label="每页条数"');
});
});
@@ -0,0 +1,33 @@
import type { ReactNode } from "react";
import { useListPagination } from "~/components/console/use-list-pagination.ts";
import { Pagination } from "~/components/ui/pagination.tsx";
/** Shared paging for custom record lists, keeping row markup and actions with the caller. */
export function PaginatedList<T>({
rows,
children,
label,
resetKey,
className,
as = "div",
compact = false,
}: {
rows: T[];
children: (row: T, index: number) => ReactNode;
label: string;
resetKey?: string;
className?: string;
as?: "div" | "ul" | "fieldset";
compact?: boolean;
}) {
const { items, pagination } = useListPagination(rows, { resetKey });
const Container = as;
return (
<section aria-label={label}>
<Container className={className}>
{items?.map((row, index) => children(row, (pagination.page - 1) * pagination.pageSize + index))}
</Container>
{!compact || rows.length > 10 ? <Pagination {...pagination} /> : null}
</section>
);
}
@@ -0,0 +1,100 @@
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import type { PaginationProps } from "~/components/ui/pagination.tsx";
export const PAGE_SIZES = [10, 20, 50, 100] as const;
const SIZE_KEY = "umt.list.page-size";
const SIZE_EVENT = "umt:list-page-size";
function readSize() {
try {
const size = Number(localStorage.getItem(SIZE_KEY));
return PAGE_SIZES.some((n) => n === size) ? size : 0;
} catch {
return 0;
}
}
function subscribeSize(notify: () => void) {
window.addEventListener("storage", notify);
window.addEventListener(SIZE_EVENT, notify);
return () => {
window.removeEventListener("storage", notify);
window.removeEventListener(SIZE_EVENT, notify);
};
}
export function pageWindow(total: number, size: number, requested: number) {
const pages = Math.max(1, Math.ceil(total / size));
const page = Math.max(1, Math.min(requested, pages));
return { page, pages, start: (page - 1) * size, end: Math.min(page * size, total) };
}
/** Present cached cursor results as pages; fetch only enough to fill the requested page. */
export function useListPagination<T>(
rows: T[] | undefined,
{
hasMore = false,
onLoadMore,
loadingMore = false,
error,
resetKey = "",
defaultPageSize = 50,
}: {
hasMore?: boolean;
onLoadMore?: () => unknown;
loadingMore?: boolean;
error?: unknown;
resetKey?: string;
defaultPageSize?: number;
} = {},
) {
const storedSize = useSyncExternalStore(subscribeSize, readSize, () => 0);
const [localSize, setLocalSize] = useState<number | null>(null);
const pageSize = localSize ?? (storedSize || defaultPageSize);
const scope = `${resetKey}|${pageSize}`;
const [position, setPosition] = useState({ scope, page: 1 });
useEffect(() => {
setPosition({ scope, page: 1 });
}, [scope]);
const requested = position.scope === scope ? position.page : 1;
const total = rows?.length ?? 0;
const range = pageWindow(total, pageSize, requested);
const needsMore = !!rows && hasMore && total < requested * pageSize;
const fetching = useRef(false);
const load = useCallback(() => {
if (!onLoadMore || fetching.current || loadingMore) return;
fetching.current = true;
Promise.resolve(onLoadMore())
.catch(() => {})
.finally(() => {
fetching.current = false;
});
}, [onLoadMore, loadingMore]);
// biome-ignore lint/correctness/useExhaustiveDependencies: every new cursor chunk must trigger filling the requested page
useEffect(() => {
if (needsMore && !error) load();
}, [needsMore, total, error, load]);
const onPageChange = (page: number) => {
const next = Math.max(1, Math.min(page, range.pages + (hasMore ? 1 : 0)));
setPosition({ scope, page: next });
if (hasMore && next * pageSize > total) load();
};
const onPageSizeChange = (size: number) => {
if (!PAGE_SIZES.some((n) => n === size)) return;
setLocalSize(size);
setPosition({ scope: `${resetKey}|${size}`, page: 1 });
try {
localStorage.setItem(SIZE_KEY, String(size));
window.dispatchEvent(new Event(SIZE_EVENT));
setLocalSize(null);
} catch {
/* selected size still applies to this list */
}
};
const pagination: PaginationProps = {
page: range.page,
pageSize,
total,
hasMore,
busy: loadingMore || (needsMore && !error),
onPageChange,
onPageSizeChange,
};
return { items: rows?.slice(range.start, range.end), pagination };
}
@@ -0,0 +1,209 @@
import { useQuery } from "@tanstack/react-query";
import { type Dashboard, DownloadBoard, type HealthLevel } from "@umt/contracts";
import { ArrowRight, Download, HardDrive, type LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { Link } from "react-router";
import { apiParsed } from "~/api/parse.ts";
import { useSystemHealth } from "~/api/system.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
import { formatRelative } from "~/lib/labels.ts";
import { cn, formatBytes } from "~/lib/utils.ts";
const LEVEL: Record<HealthLevel, { label: string; tone: StatusTone }> = {
ok: { label: "正常", tone: "success" },
warn: { label: "需要关注", tone: "warning" },
critical: { label: "需要处理", tone: "destructive" },
unknown: { label: "用量未知", tone: "neutral" },
};
const SCOPE = { data_dir: "系统数据", library_root: "媒体库", download_dir: "下载目录" };
function OverviewCard({
title,
icon: Icon,
to,
children,
}: {
title: string;
icon: LucideIcon;
to: string;
children: ReactNode;
}) {
return (
<section className="pg-slab min-w-0 rounded-xl p-5">
<div className="mb-3 flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" aria-hidden />
<h2 className="text-body font-semibold text-foreground">{title}</h2>
<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>
</div>
{children}
</section>
);
}
function StorageOverview() {
const q = useSystemHealth(60_000);
const mediaDisks = q.data?.disks.filter((d) => d.scope !== "data_dir") ?? [];
const disks = mediaDisks.length ? mediaDisks : (q.data?.disks ?? []);
return (
<OverviewCard title="存储情况" icon={HardDrive} to={consolePath("status")}>
{q.isPending ? (
<LoadingState rows={2} />
) : q.isError ? (
<ErrorState plain title="存储状态加载失败" onRetry={() => q.refetch()} className="py-6" />
) : !disks.length ? (
<EmptyState plain icon={HardDrive} title="暂无存储状态" className="py-6" />
) : (
<ul className="space-y-4">
{disks.map((d) => {
const pct = d.usedRatio === null ? null : d.usedRatio * 100;
const state = LEVEL[d.level];
return (
<li key={`${d.scope}-${d.id ?? d.path}`} className="min-w-0">
<div className="flex items-center justify-between gap-2">
<p className="min-w-0 truncate text-sm font-medium" title={d.name}>
{d.name !== SCOPE[d.scope] ? (
<span className="mr-1.5 text-meta font-normal text-muted-foreground">{SCOPE[d.scope]}</span>
) : null}
{d.name}
</p>
<StatusBadge tone={state.tone}>
{state.label}
{pct === null ? "" : ` · ${Math.round(pct)}%`}
</StatusBadge>
</div>
{pct !== null ? (
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-foreground/[0.08]" aria-hidden>
<div
className={cn(
"h-full rounded-full",
d.level === "critical"
? "bg-destructive"
: d.level === "warn"
? "bg-warning"
: "bg-foreground/60",
)}
style={{ width: `${pct}%` }}
/>
</div>
) : null}
<p className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-meta text-muted-foreground tabular-nums">
<span>
剩余{" "}
<span className="font-medium text-foreground">
{d.availableBytes === null ? "未知" : formatBytes(d.availableBytes, 1)}
</span>
</span>
<span>已用 {d.usedBytes === null ? "未知" : formatBytes(d.usedBytes, 1)}</span>
<span>总量 {d.totalBytes === null ? "未知" : formatBytes(d.totalBytes, 1)}</span>
</p>
<p className="mt-1 truncate text-meta text-muted-foreground" title={d.path}>
{d.path}
</p>
{d.error ? <p className="mt-1 text-meta text-warning">无法读取磁盘用量</p> : null}
</li>
);
})}
</ul>
)}
{q.data && !q.isError ? (
<p className="mt-3 text-meta text-muted-foreground">所在磁盘用量 · 检查于 {formatRelative(q.data.checkedAt)}</p>
) : null}
</OverviewCard>
);
}
function DownloaderOverview({ downloaders }: { downloaders?: Dashboard["sources"]["downloaders"] }) {
// 首页仅取一条面板记录,速度和计数仍是完整汇总;无需拉取下载任务列表。
const q = useQuery({
queryKey: ["downloads", "home-overview"],
queryFn: ({ signal }) =>
apiParsed(DownloadBoard, "/api/v1/download-tasks/board", { params: { state: "active", limit: 1 }, signal }),
refetchInterval: 15_000,
refetchIntervalInBackground: false,
});
const board = q.data;
const instances = board?.transfer.instances ?? [];
const sources =
downloaders ??
instances.map((d) => ({ id: d.instanceId, name: d.name, enabled: true, health: "unknown", lastSeenAt: null }));
return (
<OverviewCard title="下载器情况" icon={Download} to={consolePath("downloads")}>
{q.isPending ? (
<LoadingState rows={2} />
) : q.isError ? (
<ErrorState plain title="下载器状态加载失败" onRetry={() => q.refetch()} className="py-6" />
) : !sources.length ? (
<EmptyState plain icon={Download} title={downloaders ? "尚未配置下载器" : "暂无启用下载器"} className="py-6" />
) : (
<>
<p className="mb-3 text-title-sm font-semibold tabular-nums">
{board?.counts.active}
<span className="ml-1.5 text-caption font-normal text-muted-foreground">项进行中</span>
</p>
<ul className="divide-y divide-border/60">
{sources.map((d) => {
const live = instances.find((i) => i.instanceId === d.id);
const state = !d.enabled
? { label: "已停用", tone: "neutral" as const }
: !live
? { label: "状态未知", tone: "neutral" as const }
: live.reachable
? { label: "在线", tone: "success" as const }
: { label: "连接失败", tone: "warning" as const };
const readable = d.enabled && live?.reachable;
return (
<li key={d.id} className="py-2 first:pt-0 last:pb-0">
<div className="flex items-center justify-between gap-2">
<span className="min-w-0 truncate text-sm font-medium" title={d.name}>
{d.name}
</span>
<StatusBadge tone={state.tone}>{state.label}</StatusBadge>
</div>
{d.enabled ? (
<p className="mt-1.5 flex flex-wrap gap-x-4 gap-y-1 text-meta text-muted-foreground tabular-nums">
<span>
下载{" "}
<span className="text-foreground">
{readable ? `${formatBytes(live.dlSpeed, 1)}/s` : "未知"}
</span>
</span>
<span>
上传{" "}
<span className="text-foreground">
{readable ? `${formatBytes(live.upSpeed, 1)}/s` : "未知"}
</span>
</span>
</p>
) : null}
</li>
);
})}
</ul>
<p className="mt-3 text-meta text-muted-foreground">
进行中含等待计划、暂停和异常任务 · 速度含下载器全部任务
</p>
{board?.liveAt ? (
<p className="mt-1 text-meta text-muted-foreground">速度采样于 {formatRelative(board.liveAt)}</p>
) : null}
</>
)}
</OverviewCard>
);
}
export function HomeRuntime({ downloaders }: { downloaders?: Dashboard["sources"]["downloaders"] }) {
return (
<div className="mb-4 grid gap-4 lg:grid-cols-2">
<StorageOverview />
<DownloaderOverview downloaders={downloaders} />
</div>
);
}
@@ -231,6 +231,7 @@ export function BusinessTasks() {
/>
</FilterBar>
<DataTable
paginationKey={JSON.stringify(f.values)}
columns={columns}
rows={rows}
rowKey={(t) => t.id}
@@ -237,6 +237,7 @@ export function DownloadDetail({ task, live }: { task: DownloadTask; live?: Down
instanceId: task.instanceId,
contentPath: task.contentPath,
name: task.name,
tags: task.tags,
}}
onClose={() => setOrganizing(false)}
/>
@@ -0,0 +1,226 @@
import type { AssetVersion, ManualAcquisitionRequest, ManualAcquisitionResult } from "@umt/contracts";
import { Bot, Download } from "lucide-react";
import { useEffect, useId, useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { downloaders } from "~/api/instances.ts";
import { useLibraryVersions } from "~/api/library-versions.ts";
import { FilterSelect } from "~/components/console/filter-bar.tsx";
import { Button } from "~/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { Input } from "~/components/ui/input.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatBytes } from "~/lib/utils.ts";
export type ManualDownloadOptions = Omit<ManualAcquisitionRequest, "sourceReleaseId">;
export interface ManualDownloadResource {
title: string;
sizeBytes: number | null;
entityId?: string;
}
/** 搜索、工作台和原站视图共用的一次性下载选项。 */
export function ManualDownloadDialog({
resource,
open,
onOpenChange,
submit,
initialUnrecognized = false,
onDelegate,
}: {
resource: ManualDownloadResource;
open: boolean;
onOpenChange: (open: boolean) => void;
submit: (options: ManualDownloadOptions) => Promise<ManualAcquisitionResult>;
initialUnrecognized?: boolean;
onDelegate?: () => void;
}) {
const instances = downloaders.useList();
const formId = useId();
const inventory = useLibraryVersions(open ? resource.entityId : undefined);
const [instanceId, setInstanceId] = useState("");
const [savePath, setSavePath] = useState("");
const [mode, setMode] = useState<"normal" | "replace">("normal");
const [selected, setSelected] = useState<string[]>([]);
const [unrecognized, setUnrecognized] = useState(initialUnrecognized);
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<ManualAcquisitionResult | null>(null);
const versions = new Map<string, AssetVersion>();
for (const v of inventory.data?.movie?.versions ?? []) versions.set(v.assetId, v);
for (const season of inventory.data?.seasons ?? [])
for (const episode of season.episodes) for (const v of episode.versions) versions.set(v.assetId, v);
for (const v of inventory.data?.unplaced ?? []) versions.set(v.assetId, v);
const existing = [...versions.values()];
const single = existing.length === 1 ? existing[0]!.assetId : null;
useEffect(() => {
if (mode === "replace" && single) setSelected([single]);
}, [mode, single]);
useEffect(() => {
if (open) {
setResult(null);
setUnrecognized(initialUnrecognized);
}
}, [open, initialUnrecognized]);
const go = async () => {
setBusy(true);
try {
const r = await submit({
...(resource.entityId ? { entityId: resource.entityId } : {}),
...(instanceId ? { downloaderInstanceId: instanceId } : {}),
...(savePath.trim() ? { savePath: savePath.trim() } : {}),
...(unrecognized ? { allowUnrecognized: true } : {}),
importMode: mode,
replacementAssetIds: mode === "replace" ? selected : [],
});
setResult(r);
if (r.requiresConfirmation) setUnrecognized(true);
if (r.submitted) toast.success("已提交下载");
} catch (e) {
toast.error(errorMessage(e));
} finally {
setBusy(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>下载这个资源</DialogTitle>
<DialogDescription className="break-all">
{resource.title}
{resource.sizeBytes !== null ? ` · ${formatBytes(resource.sizeBytes, 1)}` : ""}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<FilterSelect
label="下载器"
value={instanceId}
onChange={setInstanceId}
allLabel="默认下载器"
options={(instances.data?.items ?? [])
.filter((v) => v.enabled)
.map((v) => ({ value: v.id, label: v.name }))}
/>
<label htmlFor={`${formId}-path`} className="block space-y-1 text-caption">
保存目录
<Input
id={`${formId}-path`}
value={savePath}
onChange={(e) => setSavePath(e.target.value)}
placeholder="留空使用默认下载目录"
/>
</label>
<FilterSelect
label="入库方式"
value={mode === "normal" ? "" : mode}
onChange={(v) => setMode(v === "replace" ? "replace" : "normal")}
allLabel="正常入库,保留已有版本"
options={[{ value: "replace", label: "一次性洗版入库" }]}
/>
{resource.entityId && inventory.isPending ? (
<p className="text-caption text-muted-foreground">正在读取已有版本…</p>
) : null}
{inventory.error ? (
<p className="text-caption text-destructive">已有版本读取失败,请重试后选择洗版。</p>
) : null}
{existing.length ? (
<fieldset className="max-h-56 space-y-2 overflow-auto rounded-lg border p-3">
<legend className="px-1 text-caption">
{mode === "replace" ? "选择要替换的已有文件" : "媒体库已有版本"}
</legend>
{existing.map((v) => (
<div key={v.assetId} className="flex items-start gap-2 text-caption">
{mode === "replace" ? (
<input
type="checkbox"
id={`${formId}-${v.assetId}`}
checked={selected.includes(v.assetId)}
onChange={(e) =>
setSelected((ids) =>
e.target.checked ? [...ids, v.assetId] : ids.filter((id) => id !== v.assetId),
)
}
/>
) : null}
{mode === "replace" ? (
<label htmlFor={`${formId}-${v.assetId}`} className="min-w-0 break-all">
<VersionDescription version={v} />
</label>
) : (
<VersionDescription version={v} />
)}
</div>
))}
</fieldset>
) : mode === "replace" ? (
<p className="text-caption text-muted-foreground">没有可替换的已有文件,请选择正常入库。</p>
) : null}
{mode === "replace" ? (
<p className="text-meta text-muted-foreground">
本次新文件成功入库后替换所选旧文件。剧集只替换成功对应的季集,其余保留。
</p>
) : null}
{unrecognized ? (
<p className="rounded-lg border border-warning/40 p-3 text-caption text-warning">
暂未识别到作品。可以继续下载;整理时会尝试补全识别和本地元数据。
</p>
) : null}
{result && !result.submitted && !result.requiresConfirmation ? (
<p className="text-caption text-warning">{result.rejected.map((r) => r.message).join(";")}</p>
) : null}
{result?.submitted ? (
<Link className="text-caption underline" to={`/console/downloads?plan=${result.plan?.id ?? ""}`}>
查看下载任务
</Link>
) : null}
</div>
<DialogFooter>
{onDelegate ? (
<Button
variant="outline"
disabled={busy}
onClick={() => {
onOpenChange(false);
onDelegate();
}}
>
<Bot />
委托智能体
</Button>
) : null}
<Button
onClick={() => void go()}
disabled={
busy ||
!!result?.submitted ||
(mode === "replace" && (!selected.length || inventory.isPending || !!inventory.error))
}
>
<Download />
{busy ? "提交中…" : unrecognized ? "继续下载" : "提交下载"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function VersionDescription({ version }: { version: AssetVersion }) {
return (
<span className="min-w-0 break-all">
{version.fileName}
<span className="block text-meta text-muted-foreground">
{[version.resolution, version.source, version.videoCodec, ...version.hdr, version.edition]
.filter(Boolean)
.join(" · ")}
</span>
</span>
);
}
+2 -1
View File
@@ -17,7 +17,7 @@ export function DirPicker({
onChange: (ref: FileRef) => void;
className?: string;
}) {
const writable = roots.filter((r) => r.writable && r.health !== "offline");
const writable = roots.filter((r) => r.kind === "library" && r.writable && r.health !== "offline");
return (
<div className={cn("overflow-y-auto rounded-xl border border-border p-1.5", className)}>
{writable.length ? (
@@ -25,6 +25,7 @@ export function DirPicker({
roots={writable}
current={value}
pickOnly
paginate
defaultOpen={value ? [value.root] : writable.slice(0, 1).map((r) => r.id)}
onSelect={onChange}
/>
+28 -2
View File
@@ -1,6 +1,8 @@
import { ChevronRight, Download, Folder, FolderOpen, HardDrive, LoaderCircle, TriangleAlert } from "lucide-react";
import { useEffect, useState } from "react";
import { type FileRoot, useDirChildren } from "~/api/files.ts";
import { useListPagination } from "~/components/console/use-list-pagination.ts";
import { Pagination } from "~/components/ui/pagination.tsx";
import { healthLabel } from "~/lib/labels.ts";
import { cn } from "~/lib/utils.ts";
import { groupRoots, joinRel, segments } from "./model.ts";
@@ -21,6 +23,7 @@ export function DirTree({
onSelect,
onDropTo,
pickOnly = false,
paginate = false,
defaultOpen = [],
className,
}: {
@@ -29,6 +32,8 @@ export function DirTree({
onSelect: (loc: Loc) => void;
onDropTo?: DropHandler;
pickOnly?: boolean;
/** 目录选择小窗按页展示子目录;主文件树保持懒展开。 */
paginate?: boolean;
/** 初始展开的根 id(目标选择器里默认展开可写根) */
defaultOpen?: string[];
className?: string;
@@ -75,6 +80,7 @@ export function DirTree({
onSelect={onSelect}
onDropTo={onDropTo}
pickOnly={pickOnly}
paginate={paginate}
/>
))}
</div>
@@ -92,6 +98,7 @@ function RootNode({
onSelect,
onDropTo,
pickOnly,
paginate,
}: {
root: FileRoot;
current: Loc | null;
@@ -100,6 +107,7 @@ function RootNode({
onSelect: (loc: Loc) => void;
onDropTo?: DropHandler;
pickOnly: boolean;
paginate: boolean;
}) {
const k = nodeKey(root.id, "");
const offline = root.health === "offline";
@@ -162,6 +170,7 @@ function RootNode({
onSelect={onSelect}
onDropTo={onDropTo}
pickOnly={pickOnly}
paginate={paginate}
/>
) : null}
</div>
@@ -178,6 +187,7 @@ function Children({
onSelect,
onDropTo,
pickOnly,
paginate,
}: {
root: FileRoot;
path: string;
@@ -188,8 +198,23 @@ function Children({
onSelect: (loc: Loc) => void;
onDropTo?: DropHandler;
pickOnly: boolean;
paginate: boolean;
}) {
const q = useDirChildren(root.id, path, true);
const dirs = q.data?.dirs ?? [];
const paged = useListPagination(dirs, { resetKey: JSON.stringify({ root: root.id, path }) });
const selectedIndex =
current?.root === root.id
? dirs.findIndex((dir) => {
const childPath = dir.path || joinRel(path, dir.name);
return current.path === childPath || current.path.startsWith(`${childPath}/`);
})
: -1;
// biome-ignore lint/correctness/useExhaustiveDependencies: 选中目录改变时定位;手动翻页不改变已有选择
useEffect(() => {
if (paginate && selectedIndex >= 0)
paged.pagination.onPageChange(Math.floor(selectedIndex / paged.pagination.pageSize) + 1);
}, [paginate, root.id, path, selectedIndex, paged.pagination.pageSize]);
if (q.isPending)
return (
<div
@@ -205,7 +230,6 @@ function Children({
读取失败
</p>
);
const dirs = q.data?.dirs ?? [];
if (!dirs.length)
return (
<p className="py-1 text-meta text-muted-foreground" style={{ paddingLeft: depth * 14 + 28 }}>
@@ -214,7 +238,7 @@ function Children({
);
return (
<div>
{dirs.map((d) => {
{(paginate ? paged.items : dirs)?.map((d) => {
const p = d.path || joinRel(path, d.name);
const k = nodeKey(root.id, p);
const isOpen = open.has(k);
@@ -251,11 +275,13 @@ function Children({
onSelect={onSelect}
onDropTo={onDropTo}
pickOnly={pickOnly}
paginate={paginate}
/>
) : null}
</div>
);
})}
{paginate && dirs.length > 0 ? <Pagination {...paged.pagination} /> : null}
{q.data?.truncated ? (
<p className="py-1 text-meta text-muted-foreground" style={{ paddingLeft: depth * 14 + 28 }}>
子目录过多,只列出一部分
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { directorySelectionAllowed } from "./directory-dialog.tsx";
describe("目录选择根保护", () => {
it("配置来源与目标默认允许根目录,手动整理只允许子目录", () => {
const root = { root: "dl:instance:0", path: "" };
expect(directorySelectionAllowed(root)).toBe(true);
expect(directorySelectionAllowed(root, false)).toBe(false);
expect(directorySelectionAllowed({ ...root, path: "completed/movie" }, false)).toBe(true);
expect(directorySelectionAllowed(null)).toBe(false);
expect(directorySelectionAllowed({ root: "", path: "movie" })).toBe(false);
});
});
@@ -0,0 +1,138 @@
import { ArrowUp, File, Folder } from "lucide-react";
import { useState } from "react";
import { type FileRef, filesErrorText, useFileList, useFileRoots } from "~/api/files.ts";
import { useListPagination } from "~/components/console/use-list-pagination.ts";
import { Button } from "~/components/ui/button.tsx";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/components/ui/dialog.tsx";
import { Pagination } from "~/components/ui/pagination.tsx";
import { DirTree } from "./dir-tree.tsx";
export const directorySelectionAllowed = (ref: FileRef | null, allowRoot = true): boolean =>
!!ref?.root && (allowRoot || ref.path !== "");
/** Source selection intentionally permits readable download roots; targets only permit writable libraries. */
export function DirectoryDialog({
mode,
value,
onSelect,
onClose,
allowRoot = true,
}: {
mode: "source" | "target";
value?: FileRef | null;
allowRoot?: boolean;
onSelect: (value: FileRef) => void;
onClose: () => void;
}) {
const roots = useFileRoots();
const [selected, setSelected] = useState<FileRef | null>(value ?? null);
const available = (roots.data ?? []).filter(
(r) => r.health !== "offline" && (mode === "source" || (r.kind === "library" && r.writable)),
);
const root = available.find((r) => r.id === selected?.root);
const listing = useFileList(root && selected ? { root: selected.root, path: selected.path } : null);
const entries = listing.data?.pages.flatMap((p) => p.entries) ?? [];
const paged = useListPagination(entries, {
hasMore: listing.hasNextPage,
onLoadMore: () => listing.fetchNextPage(),
loadingMore: listing.isFetchingNextPage,
error: listing.error,
resetKey: JSON.stringify(selected),
});
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle>{mode === "source" ? "选择来源目录" : "选择媒体库目标目录"}</DialogTitle>
<DialogDescription>展开登记目录,选择一个目录后确认。</DialogDescription>
</DialogHeader>
<div className="grid gap-3 sm:grid-cols-2">
<div className="max-h-80 overflow-auto rounded-xl border p-2">
<DirTree
roots={available}
current={selected}
onSelect={setSelected}
pickOnly={mode === "target"}
paginate
/>
{roots.isPending ? (
<p>读取目录中…</p>
) : !available.length ? (
<p className="text-caption text-muted-foreground">没有可用的登记目录。</p>
) : null}
</div>
<section className="max-h-80 space-y-1 overflow-auto rounded-xl border p-2" aria-label="当前目录内容">
{selected?.path ? (
<Button
variant="ghost"
size="sm"
onClick={() =>
setSelected({ root: selected.root, path: selected.path.split("/").slice(0, -1).join("/") })
}
>
<ArrowUp />
上一级
</Button>
) : null}
{!selected ? (
<p className="text-caption text-muted-foreground">在左侧选择登记目录,查看内容。</p>
) : listing.isPending ? (
<p className="text-caption">读取目录中…</p>
) : listing.error ? (
<p role="alert" className="text-caption text-destructive">
{filesErrorText(listing.error, "目录不可读取")}
</p>
) : !entries.length ? (
<p className="text-caption text-muted-foreground">目录为空。</p>
) : (
paged.items?.map((entry) =>
entry.type === "dir" ? (
<Button
key={entry.path}
variant="ghost"
className="w-full justify-start"
disabled={entry.escape}
onClick={() => setSelected({ root: selected.root, path: entry.path })}
>
<Folder />
<span className="truncate">{entry.name}</span>
</Button>
) : (
<p key={entry.path} className="flex items-center gap-2 px-3 py-2 text-caption text-muted-foreground">
<File className="h-4 w-4 shrink-0" />
<span className="truncate">{entry.name}</span>
</p>
),
)
)}
{entries.length > 0 || listing.hasNextPage ? <Pagination {...paged.pagination} /> : null}
</section>
</div>
{roots.error ? (
<p role="alert" className="text-destructive">
读取目录失败,请关闭后重试。
</p>
) : null}
<p className="break-all font-mono text-meta">
{root ? `${root.name} / ${selected?.path || "(根目录)"}` : "尚未选择"}
</p>
{!allowRoot && selected && !selected.path ? (
<p role="status" className="text-caption text-warning">
请选择子目录,或先新增来源配置,再整理根目录存量。
</p>
) : null}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={onClose}>
取消
</Button>
<Button
disabled={!root || !directorySelectionAllowed(selected, allowRoot) || listing.isPending || !!listing.error}
onClick={() => selected && directorySelectionAllowed(selected, allowRoot) && onSelect(selected)}
>
使用此目录
</Button>
</div>
</DialogContent>
</Dialog>
);
}
+12 -6
View File
@@ -854,7 +854,7 @@ function organize(body: {
season?: number;
episodes?: number[];
}[];
action: "copy" | "move";
action: "copy" | "move" | "in_place";
dryRun: boolean;
onExisting?: "skip" | "conflict" | "replace_with_verify";
naming?: "template" | "keep";
@@ -881,12 +881,15 @@ function organize(body: {
const e = it.episodes?.length ? `E${String(it.episodes[0]).padStart(2, "0")}` : "";
const orig = it.path.split("/").pop() ?? "";
const name = body.naming === "keep" ? orig : tv ? `${dirName} - ${s}${e}.${ext}` : `${dirName}.${ext}`;
const dst = tv
? `${libRoot}/${dirName}/Season ${String(it.season ?? 1).padStart(2, "0")}/${name}`
: `${libRoot}/${dirName}/${name}`;
const dst =
body.action === "in_place"
? `${state.roots.find((x) => x.id === it.root)?.hostPath}/${it.path}`
: tv
? `${libRoot}/${dirName}/Season ${String(it.season ?? 1).padStart(2, "0")}/${name}`
: `${libRoot}/${dirName}/${name}`;
const r = state.roots.find((x) => x.id === it.root);
const { node, parent } = find(it.root, it.path);
const exists = id === "e-dune";
const exists = body.action !== "in_place" && id === "e-dune";
if (exists && onExisting === "conflict") conflict = true;
const main = {
source: `${r?.hostPath}/${it.path}`,
@@ -915,7 +918,10 @@ function organize(body: {
.filter((c) => c !== node && !c.dir && c.name.startsWith(stem.slice(0, 16)) && /\.(srt|ass)$/.test(c.name))
.map((c) => ({
source: `${r?.hostPath}/${join(dirPath, c.name)}`,
target: dst.replace(/\.[^.]+$/, `.zh.${c.name.split(".").pop()}`),
target:
body.action === "in_place"
? `${r?.hostPath}/${join(dirPath, c.name)}`
: dst.replace(/\.[^.]+$/, `.zh.${c.name.split(".").pop()}`),
kind: "subtitle",
size: c.size,
action: "transfer" as const,
@@ -198,6 +198,7 @@ export function AliasesPanel() {
{entity ? <AddAlias key={entity.id} entity={entity} /> : null}
</Panel>
<DataTable
paginationKey={JSON.stringify({ entity: entity?.id, q })}
columns={columns}
rows={rows}
rowKey={(a) => a.id}
@@ -138,6 +138,7 @@ export function PoolTab() {
<FilterSelect label="原因" value={reason} onChange={setReason} options={opts(POOL_REASON)} />
</FilterBar>
<DataTable
paginationKey={JSON.stringify({ q: dq, status, reason })}
columns={columns}
rows={rows}
rowKey={(r) => r.id}
@@ -238,6 +239,7 @@ export function ExternalTab() {
<FilterSelect label="状态" value={status} onChange={setStatus} options={opts(IDENTITY_STATUS)} />
</FilterBar>
<DataTable
paginationKey={JSON.stringify({ provider, status })}
columns={columns}
rows={rows}
rowKey={(r) => r.id}
+21 -5
View File
@@ -11,6 +11,7 @@ import { Link } from "react-router";
import { useLibraryVersions } from "~/api/library-versions.ts";
import { type KvItem, KvList } from "~/components/console/kv-list.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { PaginatedList } from "~/components/console/paginated-list.tsx";
import { TechInfo } from "~/components/console/tech-info.tsx";
import { Panel } from "~/components/library/parts.tsx";
import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
@@ -175,6 +176,7 @@ export function VersionList({
match,
initial,
onCollapse,
compact = false,
}: {
versions: AssetVersion[];
match?: UnitMatch | undefined;
@@ -182,12 +184,20 @@ export function VersionList({
initial?: string | null;
/** 收起选中版本时回调(剧集用来收回到一行) */
onCollapse?: () => void;
compact?: boolean;
}) {
const [selected, setSelected] = useState<string | null>(initial ?? null);
const tagBest = match?.state === "satisfied" && versions.length > 1;
return (
<ul className="space-y-0.5">
{versions.map((v) => {
<PaginatedList
rows={versions}
as="ul"
label="入库文件版本"
resetKey={match?.assetId ?? "all"}
compact={compact}
className="space-y-0.5"
>
{(v) => {
const on = selected === v.assetId;
return (
<li key={v.assetId} className={cn("rounded-lg", on && "bg-foreground/[0.04]")}>
@@ -214,8 +224,8 @@ export function VersionList({
{on ? <VersionDetail v={v} match={match} /> : null}
</li>
);
})}
</ul>
}}
</PaginatedList>
);
}
@@ -241,7 +251,13 @@ export function EpisodeVersionList({
return (
<div className={cn("-mx-2 mt-1 space-y-0.5", className)}>
{open ? (
<VersionList versions={ordered} match={match} initial={lead.assetId} onCollapse={() => setOpen(false)} />
<VersionList
compact
versions={ordered}
match={match}
initial={lead.assetId}
onCollapse={() => setOpen(false)}
/>
) : (
<button
type="button"
@@ -95,7 +95,7 @@ function Option({
/**
* 作品选择器(§14 EntityPicker):上半识别候选(本地 + TMDB),下半搜索本地作品与 TMDB。
* 选 TMDB 条目 = 行标"将新建作品",执行时由后端建作品(G1)。
* 选 TMDB 条目 = 行标"将新建作品",生成预览时由后端建档(G1)。
*/
export function EntityPicker({
candidates = [],
@@ -104,6 +104,7 @@ export function EntityPicker({
placeholder = "选择作品…",
className,
invalid,
mediaType = "auto",
}: {
candidates?: FileIdentifyCandidate[];
value: WizardEntity | null;
@@ -111,6 +112,7 @@ export function EntityPicker({
placeholder?: string;
className?: string;
invalid?: boolean;
mediaType?: "auto" | "movie" | "tv";
}) {
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
@@ -126,7 +128,15 @@ export function EntityPicker({
setOpen(false);
setQ("");
};
const hits = search.data;
const hits = search.data
? {
...search.data,
local: search.data.local.filter(
(h) => mediaType === "auto" || (mediaType === "movie") === (h.kind === "movie"),
),
tmdb: search.data.tmdb.filter((h) => mediaType === "auto" || h.entityType === mediaType),
}
: undefined;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
@@ -5,6 +5,7 @@ import {
applyMinSize,
applyOffsetTo,
defaultOptions,
dialogRows,
displayPath,
downloadContentRef,
groupRows,
@@ -121,6 +122,18 @@ describe("识别结果 → 行", () => {
expect(rowProblem({ ...(rows[0] as WizardRow), season: "" })).toMatch(/季号/);
expect(rowProblem({ ...(rows[0] as WizardRow), episodes: "1-x" })).toMatch(/集号/);
});
it("只允许单集或连续集号,不让人工覆写被后端忽略", () => {
const r = rowsFromIdentify([item("show/a.mkv")], new Set())[0] as WizardRow;
expect(rowProblem({ ...r, episodes: "3,5" })).toBe("集号需要连续,如 3-5");
expect(wizardStats([{ ...r, episodes: "3,5" }]).pending).toBe(1);
expect(rowProblem({ ...r, episodes: "4,3,4" })).toBeNull();
expect(rowProblem({ ...r, episodes: "3-5" })).toBeNull();
expect(rowProblem({ ...r, episodes: "0" })).toBeNull();
expect(rowProblem({ ...r, episodes: "1-51" })).toMatch(/50/);
expect(rowProblem({ ...r, episodes: "3,5", excluded: "manual" })).toBeNull();
expect(rowProblem({ ...r, entity: tmdbMovie, episodes: "3,5", season: "" })).toBeNull();
});
});
describe("批量设置", () => {
@@ -152,6 +165,55 @@ describe("批量设置", () => {
});
});
describe("统一整理弹窗范围", () => {
it("指定作品覆盖整批,清除后恢复逐文件识别,季集解析保留", () => {
const rows = rowsFromIdentify([item("a.mkv"), item("b.mkv", { id: "e2" })], new Set());
const selected = dialogRows(rows, tmdbMovie, "", false);
expect(selected.map((r) => r.entity)).toEqual([tmdbMovie, tmdbMovie]);
expect(dialogRows(rows, null, "", false).map((r) => r.entity?.id)).toEqual(["e1", "e2"]);
expect(rows.map((r) => r.entity?.id)).toEqual(["e1", "e2"]);
expect(selected.map((r) => r.episodes)).toEqual(["2", "2"]);
});
it("成功历史默认参与;开启跳过后未识别的成功文件也不阻挡预览", () => {
const old = {
...item("old.mkv", { id: null }),
records: { count: 2, lastRecordId: "r", lastStatus: "failed" as const, successfulCount: 1 },
};
const rows = rowsFromIdentify([old, item("new.mkv")], new Set());
expect(wizardStats(dialogRows(rows, null, "", false)).pending).toBe(1);
const skipped = dialogRows(rows, null, "", true);
expect(skipped[0]?.excluded).toBe("successful_record");
expect(wizardStats(skipped).pending).toBe(0);
expect(
organizeRequest(skipped, defaultOptions({ skipSuccessfulRecords: true }), {
dryRun: true,
idempotencyKey: "k",
}).items.map((r) => r.path),
).toEqual(["new.mkv"]);
});
it("手动排除与体积排除不被统一作品或季号覆盖", () => {
const rows = rowsFromIdentify([item("a.mkv"), item("small.mkv", { skipped: "below_min_size" })], new Set());
rows[0]!.excluded = "manual";
const result = dialogRows(rows, tmdbMovie, "3", true);
expect(result.map((r) => r.excluded)).toEqual(["manual", "below_min_size"]);
expect(result.map((r) => r.season)).toEqual(["3", "3"]);
});
it("预览与入队携带相同刮削和历史跳过策略", () => {
const rows = rowsFromIdentify([item("a.mkv")], new Set());
const options = defaultOptions({ scrapeAfterImport: true, skipSuccessfulRecords: true });
const preview = organizeRequest(rows, options, { dryRun: true, idempotencyKey: "preview" });
const queue = organizeRequest(rows, options, { dryRun: false, idempotencyKey: "queue", planIds: ["plan"] });
expect(defaultOptions().skipSuccessfulRecords).toBe(false);
expect(preview).toMatchObject({ scrapeAfterImport: true, skipSuccessfulRecords: true });
const { dryRun: _p, idempotencyKey: _pk, planIds: _pp, ...p } = preview;
const { dryRun: _q, idempotencyKey: _qk, planIds: _qp, ...q } = queue;
expect(p).toEqual(q);
});
});
describe("请求体", () => {
it("本地作品带 mediaEntityId,TMDB 候选带 external;电影不带季集;排除项不提交", () => {
const rows = rowsFromIdentify([item("a.mkv"), item("b.mkv"), item("c.mkv")], new Set());
@@ -283,3 +345,63 @@ describe("来源换算", () => {
expect(downloadContentRef(null, "i", [], roots)).toBeNull();
});
});
describe("来源配置预览与执行", () => {
it("自动目标保持交给配置,手动目录可覆盖,两个请求都携带配置 ID", () => {
const rows = rowsFromIdentify([item("series/a.mkv")], new Set());
const options = defaultOptions({ action: "move" });
const dry = organizeRequest(rows, options, {
dryRun: true,
idempotencyKey: "preview",
sourceConfigId: "profile-1",
});
const exec = organizeRequest(rows, options, {
dryRun: false,
idempotencyKey: "execute",
sourceConfigId: "profile-1",
planIds: ["plan-1"],
});
expect(dry.sourceConfigId).toBe("profile-1");
expect(exec.sourceConfigId).toBe(dry.sourceConfigId);
expect(exec.items).toEqual(dry.items);
expect(dry.action).toBe("move");
expect(dry.targetLibraryRootId).toBeUndefined();
expect(dry.items[0]?.targetDir).toBeUndefined();
const manual = organizeRequest(
rows,
{ ...options, target: { mode: "dir", ref: { root: "lib:target", path: "chosen" } } },
{ dryRun: true, idempotencyKey: "manual", sourceConfigId: "profile-1" },
);
expect(manual.items[0]?.targetDir).toEqual({ root: "lib:target", path: "chosen" });
expect(manual.sourceConfigId).toBe("profile-1");
});
});
it("原地入库请求不携带旧搬运目标、分类来源与替换策略,预览和入队一致", () => {
const options = defaultOptions({
action: "in_place",
target: { mode: "dir", ref: { root: "lib:old", path: "old" } },
onExisting: "replace_with_verify",
naming: "template",
});
const rows = rowsFromIdentify([item("Original.S01E02.mkv")], new Set());
const dry = organizeRequest(rows, options, {
dryRun: true,
idempotencyKey: "in-place-dry",
sourceConfigId: "old-source",
});
const exec = organizeRequest(rows, options, {
dryRun: false,
idempotencyKey: "in-place-exec",
sourceConfigId: "old-source",
planIds: ["plan"],
});
expect(dry).toMatchObject({ action: "in_place", naming: "keep", onExisting: "conflict" });
expect(dry.items).toHaveLength(1);
expect(dry.items[0]?.targetDir).toBeUndefined();
expect(dry.sourceConfigId).toBeUndefined();
expect(dry.targetLibraryRootId).toBeUndefined();
expect(exec.action).toBe(dry.action);
expect(exec.naming).toBe(dry.naming);
expect(exec.onExisting).toBe(dry.onExisting);
});
@@ -13,7 +13,7 @@ import { formatEpisodes, parentRel, parseEpisodes, shiftEpisodes } from "~/compo
// ─── 作品 ────────────────────────────────────────────────────────
/** 行上选中的作品:本地作品(id)或只在 TMDB 上的候选(external,执行时由后端就地建作品) */
/** 行上选中的作品:本地作品(id)或只在 TMDB 上的候选(external,生成预览时由后端建档) */
export type WizardEntity = {
id: string | null;
external: { provider: "tmdb"; externalId: string; kind: "movie" | "tv" } | null;
@@ -32,7 +32,7 @@ export const isMovieKind = (k: string | null | undefined) => k === "movie";
// ─── 行 ──────────────────────────────────────────────────────────
export type ExcludeReason = "manual" | "below_min_size";
export type ExcludeReason = "manual" | "below_min_size" | "successful_record";
export type WizardRow = {
key: string;
@@ -55,10 +55,14 @@ export type WizardRow = {
export function rowProblem(r: WizardRow): string | null {
if (r.excluded) return null;
if (!r.entity) return "未选择作品";
if (isMovieKind(r.entity.kind)) return null;
const eps = parseEpisodes(r.episodes);
if (eps === null) return "集号格式不对,如 3、3-5、3,4";
if (eps === null) return "集号格式不对,请填写单集或连续区间,如 3、3-5";
const unique = [...new Set(eps)].sort((a, b) => a - b);
if (eps.length > 50) return "单个文件最多指定 50 个集号";
if (unique.some((n, i) => i > 0 && n !== unique[i - 1]! + 1)) return "集号需要连续,如 3-5";
if (r.season.trim() && !/^\d{1,4}$/.test(r.season.trim())) return "季号必须是数字";
if (!isMovieKind(r.entity.kind) && !r.season.trim()) return "剧集需要季号";
if (!r.season.trim()) return "剧集需要季号";
return null;
}
@@ -186,6 +190,28 @@ export function wizardStats(rows: WizardRow[]): WizardStats {
};
}
/** 共用弹窗的作品、季号作用于整个本次范围;成功历史跳过独立于目标冲突。 */
export function dialogRows(
rows: WizardRow[],
manual: WizardEntity | null,
season: string,
skipSuccessfulRecords: boolean,
): WizardRow[] {
return rows.map((row) => {
const excluded =
!row.excluded && skipSuccessfulRecords && (row.item.records?.successfulCount ?? 0) > 0
? ("successful_record" as const)
: row.excluded;
return {
...row,
excluded,
entity: manual ?? row.entity,
slotId: manual && entityKey(manual) !== entityKey(row.entity) ? "" : row.slotId,
season: season.trim() || row.season,
};
});
}
/** 按源目录分组(保持首次出现顺序) */
export function groupRows(rows: WizardRow[]): { group: string; dir: string; rows: WizardRow[] }[] {
const map = new Map<string, WizardRow[]>();
@@ -198,11 +224,13 @@ export function groupRows(rows: WizardRow[]): { group: string; dir: string; rows
export type WizardTarget = { mode: "auto" } | { mode: "root"; rootId: string } | { mode: "dir"; ref: FileRef };
export type WizardOptions = {
action: "copy" | "move";
action: "copy" | "move" | "in_place";
target: WizardTarget;
onExisting: FilesOnExisting;
naming: "template" | "keep";
refreshMetadata: boolean;
scrapeAfterImport: boolean;
skipSuccessfulRecords: boolean;
minSizeBytes: number;
};
@@ -215,6 +243,8 @@ export const defaultOptions = (o: Partial<WizardOptions> = {}): WizardOptions =>
onExisting: "conflict",
naming: "template",
refreshMetadata: true,
scrapeAfterImport: false,
skipSuccessfulRecords: false,
minSizeBytes: DEFAULT_MIN_SIZE,
...o,
});
@@ -235,7 +265,7 @@ export function organizeItems(rows: WizardRow[], o: WizardOptions): FilesOrganiz
if (!movie && r.season.trim()) item.season = Number(r.season.trim());
if (!movie && eps?.length) item.episodes = eps;
if (r.slotId && e.id) item.slotId = r.slotId;
if (o.target.mode === "dir") item.targetDir = o.target.ref;
if (o.action !== "in_place" && o.target.mode === "dir") item.targetDir = o.target.ref;
return item;
});
}
@@ -244,19 +274,23 @@ export function organizeItems(rows: WizardRow[], o: WizardOptions): FilesOrganiz
export function organizeRequest(
rows: WizardRow[],
o: WizardOptions,
x: { dryRun: boolean; idempotencyKey: string; planIds?: string[] },
x: { dryRun: boolean; idempotencyKey: string; planIds?: string[]; sourceConfigId?: string },
): FilesOrganizeRequest {
return {
idempotencyKey: x.idempotencyKey,
items: organizeItems(rows, o),
action: o.action,
targetLibraryRootId: o.target.mode === "root" ? libraryRootIdOf(o.target.rootId) : undefined,
sourceConfigId: o.action === "in_place" ? undefined : x.sourceConfigId,
targetLibraryRootId:
o.action !== "in_place" && o.target.mode === "root" ? libraryRootIdOf(o.target.rootId) : undefined,
dryRun: x.dryRun,
planIds: x.planIds,
minSizeBytes: o.minSizeBytes,
onExisting: o.onExisting,
naming: o.naming,
onExisting: o.action === "in_place" ? "conflict" : o.onExisting,
naming: o.action === "in_place" ? "keep" : o.naming,
refreshMetadata: o.refreshMetadata,
scrapeAfterImport: o.scrapeAfterImport,
skipSuccessfulRecords: o.skipSuccessfulRecords,
};
}
@@ -326,6 +360,7 @@ export function nestSidecars(files: GroupFile[]): { main: GroupFile; sidecars: G
}
export const EXCLUDE_REASON: Record<string, string> = {
successful_record: "已有成功整理记录",
below_min_size: "低于最小体积",
sample: "样片",
too_small: "体积过小",
@@ -339,6 +374,7 @@ export const EXCLUDE_REASON: Record<string, string> = {
};
export const SKIP_REASON: Record<string, string> = {
successful_record: "已有成功整理记录",
already_in_library: "已在库中",
target_exists: "目标已存在",
main_not_transferred: "主文件未整理",
File diff suppressed because it is too large. Load diff
@@ -70,6 +70,7 @@ export function StepOptions({
roots,
hnrCount,
kinds,
profileLabel,
}: {
options: WizardOptions;
onChange: (o: WizardOptions) => void;
@@ -78,6 +79,7 @@ export function StepOptions({
hnrCount: number;
/** 本次涉及的作品类型(过滤库根) */
kinds: Set<string>;
profileLabel?: string;
}) {
const set = (p: Partial<WizardOptions>) => onChange({ ...options, ...p });
const libRoots = roots.filter((r) => r.kind === "library" && r.writable && r.health !== "offline");
@@ -109,7 +111,6 @@ export function StepOptions({
title="移动"
hint="入库后删源;跨卷时按复制 → 核验 → 删源"
/>
<Choice checked={false} onSelect={() => {}} disabled="还不支持硬链接" title="硬链接" />
</div>
</Section>
@@ -144,7 +145,11 @@ export function StepOptions({
checked={t.mode === "auto"}
onSelect={() => set({ target: { mode: "auto" } })}
title="自动"
hint="已绑定的作品沿用原目录,否则按作品类型选媒体库目录"
hint={
profileLabel
? `按来源「${profileLabel}」的目标目录与分类规则整理`
: "已绑定的作品沿用原目录,否则按作品类型选媒体库目录"
}
/>
{libRoots.map((r) => (
<Choice
@@ -214,7 +219,9 @@ export function StepOptions({
aria-label="入库后刷新元数据"
/>
</div>
<p className="text-meta text-muted-foreground">同名字幕、NFO、图片随主文件一起处理,预览里列出。</p>
<p className="text-meta text-muted-foreground">
同目录同名字幕和外挂音轨会随视频整理,预览里列出。现有 NFO、图片不搬入媒体库,可在整理后刮削生成。
</p>
</Section>
</div>
</div>
@@ -4,8 +4,10 @@ import { Link } from "react-router";
import type { FileRoot, FilesOrganizeGroup } from "~/api/files.ts";
import { RECORD_STATUS_LABEL, RECORD_STATUS_TONE } from "~/api/records.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { useListPagination } from "~/components/console/use-list-pagination.ts";
import { baseName, parentRel } from "~/components/files/model.ts";
import { Button } from "~/components/ui/button.tsx";
import { recordFileProgress } from "~/components/organize/record-parts.ts";
import { Pagination } from "~/components/ui/pagination.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { cn, formatBytes } from "~/lib/utils.ts";
import {
@@ -18,7 +20,6 @@ import {
groupHasProblem,
nestSidecars,
type PreviewStats,
pageOf,
SKIP_REASON,
TEMPLATE_SOURCE,
} from "./model.ts";
@@ -108,6 +109,8 @@ function FileLine({ f, roots, nested }: { f: GroupFile; roots: FileRoot[]; neste
<StatusBadge>跳过{f.skipReason ? ` · ${SKIP_REASON[f.skipReason] ?? f.skipReason}` : ""}</StatusBadge>
) : f.action === "replace" ? (
<StatusBadge tone="warning">替换</StatusBadge>
) : f.target === f.source ? (
<StatusBadge>原地登记</StatusBadge>
) : !f.target ? (
<StatusBadge>不入库</StatusBadge>
) : null;
@@ -202,6 +205,12 @@ export function PreviewGroupCard({
{errorText(g.error)}
</p>
) : null}
{result && record && recordFileProgress(record) ? (
<p className="break-all text-caption text-muted-foreground">
已完成 {recordFileProgress(record)?.completed}/{recordFileProgress(record)?.total} 个文件
{recordFileProgress(record)?.current ? ` · 当前:${recordFileProgress(record)?.current}` : ""}
</p>
) : null}
{record?.reason && result ? <p className="text-caption text-warning">{record.reason.message}</p> : null}
{g.warnings.map((w) => (
<p key={w} className="flex items-start gap-1.5 text-caption text-warning">
@@ -245,25 +254,21 @@ export function PreviewList({
titleOf,
roots,
onlyProblems,
page,
onPage,
}: {
groups: FilesOrganizeGroup[];
titleOf: GroupTitle;
roots: FileRoot[];
onlyProblems: boolean;
page: number;
onPage: (p: number) => void;
}) {
const shown = onlyProblems ? groups.filter(groupHasProblem) : groups;
const pg = pageOf(shown, page);
const { items, pagination } = useListPagination(shown, { resetKey: String(onlyProblems) });
if (!shown.length)
return (
<p className="py-8 text-center text-caption text-muted-foreground">没有问题项。关闭「只看问题」查看全部。</p>
);
return (
<div className="space-y-3">
{pg.items.map((g, i) => (
{items?.map((g, i) => (
<PreviewGroupCard
key={`${g.mediaEntityId}-${g.contentUnitId}-${g.planId ?? i}`}
g={g}
@@ -272,19 +277,7 @@ export function PreviewList({
onlyProblems={onlyProblems}
/>
))}
{pg.pages > 1 ? (
<div className="flex items-center justify-center gap-2 text-caption">
<Button size="sm" variant="outline" disabled={pg.page === 0} onClick={() => onPage(pg.page - 1)}>
上一页
</Button>
<span className="text-muted-foreground tabular-nums">
{pg.page + 1} / {pg.pages}
</span>
<Button size="sm" variant="outline" disabled={pg.page >= pg.pages - 1} onClick={() => onPage(pg.page + 1)}>
下一页
</Button>
</div>
) : null}
<Pagination {...pagination} />
</div>
);
}
@@ -0,0 +1,83 @@
import { useEffect } from "react";
import { filesErrorText } from "~/api/files.ts";
import { useOrganizeSources } from "~/api/organize-sources.ts";
import type { OrganizeSource } from "~/components/organize-wizard/organize-wizard.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/components/ui/dialog.tsx";
import { matchingOrganizeSources } from "./source-match.ts";
type FileSource = Extract<OrganizeSource, { kind: "files" }>;
/** Wait for profiles even on the first click; ambiguous sources require an explicit choice. */
export function FileSourceSelection({
source,
onSelect,
onClose,
}: {
source: FileSource;
onSelect: (source: FileSource) => void;
onClose: () => void;
}) {
const profiles = useOrganizeSources();
const matches = matchingOrganizeSources(source.items, profiles.data?.items ?? []);
useEffect(() => {
if (!profiles.isSuccess || matches.length > 1) return;
const profile = matches[0];
onSelect(
profile
? { ...source, sourceConfigId: profile.id, defaultAction: profile.action, label: `来源 · ${profile.name}` }
: source,
);
}, [profiles.isSuccess, matches, source, onSelect]);
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{profiles.isPending ? "读取整理来源…" : "选择整理策略"}</DialogTitle>
<DialogDescription>
匹配来源配置后,使用其整理方式、目标目录与分类规则。媒体类型将在整理预览时核对。
</DialogDescription>
</DialogHeader>
{profiles.isPending ? (
<p role="status" className="text-caption text-muted-foreground">
正在检查所选文件对应的来源配置…
</p>
) : profiles.error ? (
<>
<p role="alert" className="text-caption text-destructive">
{filesErrorText(profiles.error, "读取整理来源失败")}
</p>
<Button variant="outline" onClick={() => void profiles.refetch()}>
重试
</Button>
</>
) : matches.length > 1 ? (
<div className="max-h-72 space-y-2 overflow-auto">
{matches.map((profile) => (
<Button
key={profile.id}
variant="outline"
className="h-auto w-full justify-start whitespace-normal py-3 text-left"
onClick={() =>
onSelect({
...source,
sourceConfigId: profile.id,
defaultAction: profile.action,
label: `来源 · ${profile.name}`,
})
}
>
{profile.name} · {profile.action === "copy" ? "复制" : "移动"} · {profile.category ?? "自动分类"}
{!profile.enabled ? " · 自动监控已停用" : ""}
</Button>
))}
</div>
) : null}
{!profiles.isPending && (profiles.error || matches.length > 1) ? (
<Button variant="outline" onClick={() => onSelect(source)}>
不使用来源配置,手动指定选项
</Button>
) : null}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,189 @@
import { useQueryClient } from "@tanstack/react-query";
import type { EngineRun } from "@umt/contracts";
import { FileStack, RefreshCw } from "lucide-react";
import { Link } from "react-router";
import { useDownloadTask } from "~/api/downloads.ts";
import { useEngineRun, useEngineRuns } from "~/api/engine.ts";
import { useScrapeRecords } from "~/api/files.ts";
import { ACTION_LABEL, useRecord } from "~/api/records.ts";
import { useRecordsForDownload } from "~/api/tasks.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { useListPagination } from "~/components/console/use-list-pagination.ts";
import { relTime } from "~/components/library/parts.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Pagination } from "~/components/ui/pagination.tsx";
import { qk } from "~/lib/query-keys.ts";
import { useSse } from "~/lib/sse.ts";
import { organizeQueueStage, organizeRunInput, scrapeQueueStage } from "./queue-model.ts";
import { recordFileProgress, recordTitle } from "./record-parts.ts";
function ScrapeQueueItem({ run }: { run: EngineRun }) {
const detail = useEngineRun(run.id);
const input = organizeRunInput(detail.data?.input);
const scrapeId = input.scrapeRecordId ?? input.recordId;
const scrape = useScrapeRecords(scrapeId ? [scrapeId] : []);
const record = scrape.data?.records[0];
const status = run.status === "pending" ? "排队" : run.status === "sleeping" ? "等待" : "执行中";
return (
<li className="pg-slab space-y-2 rounded-xl p-4">
<div className="flex flex-wrap items-center gap-2">
<strong className="min-w-0 flex-1 break-words text-body">{record?.match?.title ?? "媒体库刮削"}</strong>
<StatusBadge>刮削</StatusBadge>
<StatusBadge tone={run.status === "sleeping" ? "warning" : "neutral"}>{status}</StatusBadge>
<span className="text-meta text-muted-foreground">{relTime(run.createdAt)}</span>
</div>
<div className="min-w-0 text-caption">
<span className="text-muted-foreground">目标目录 / 文件</span>
<p className="break-all font-mono text-meta">
{record ? `${record.root} / ${record.path || "(根目录)"}` : "读取刮削目标中…"}
</p>
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-muted-foreground">
<span>{scrapeQueueStage(run)}</span>
{record ? (
<span>
已写入 {record.written} · 跳过 {record.skipped} · 失败 {record.failed}
</span>
) : null}
<Link
className="ml-auto hover:text-foreground hover:underline"
to={`/console/tasks/${encodeURIComponent(run.id)}`}
>
任务详情
</Link>
{record?.organizationRecordId ? (
<Link
className="hover:text-foreground hover:underline"
to={`/console/organize/${record.organizationRecordId}`}
>
关联整理记录
</Link>
) : null}
</div>
{detail.error || scrape.error ? (
<p className="text-meta text-warning">刮削详细信息暂时无法读取,任务状态仍会自动刷新。</p>
) : null}
</li>
);
}
function QueueItem({ run }: { run: EngineRun }) {
const detail = useEngineRun(run.id);
const input = organizeRunInput(detail.data?.input);
const recordId = run.business?.kind === "organization" ? run.business.refId : input.recordId;
const downloadId = run.business?.kind === "download" ? run.business.refId : input.downloadTaskId;
const related = useRecordsForDownload(downloadId);
const matchingRecord =
related.data?.items.find((item) => item.status === "queued" || item.status === "running") ??
related.data?.items.find((item) => item.createdAt >= run.createdAt);
const recordQuery = useRecord(recordId ?? matchingRecord?.id);
const download = useDownloadTask(downloadId);
const record = recordQuery.data ?? matchingRecord;
const progress = record ? recordFileProgress(record) : null;
const title = record ? recordTitle(record) : (download.data?.name ?? run.business?.title ?? "核验入库任务");
const source = record?.source.path ?? download.data?.contentPath ?? download.data?.savePath;
const status = run.status === "pending" ? "排队" : run.status === "sleeping" ? "等待" : "执行中";
return (
<li className="pg-slab space-y-2 rounded-xl p-4">
<div className="flex flex-wrap items-center gap-2">
<strong className="min-w-0 flex-1 break-words text-body">{title}</strong>
<StatusBadge tone={run.status === "sleeping" ? "warning" : "neutral"}>{status}</StatusBadge>
<span className="text-meta text-muted-foreground">{relTime(run.createdAt)}</span>
</div>
<div className="grid gap-2 text-caption sm:grid-cols-2">
<div className="min-w-0">
<span className="text-muted-foreground">来源</span>
<p className="break-all font-mono text-meta">
{source ??
(detail.isFetching || download.isFetching || recordQuery.isFetching
? "读取来源信息中…"
: "来源信息暂不可用")}
</p>
</div>
<div className="min-w-0">
<span className="text-muted-foreground">目标</span>
<p className="break-all font-mono text-meta">{record?.target.path ?? "核验与生成整理计划后确定"}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-muted-foreground">
<span>{organizeQueueStage(run, record)}</span>
{record ? <span>{ACTION_LABEL[record.action]}</span> : null}
{progress ? (
<span>
已完成 {progress.completed}/{progress.total} 个文件
{progress.current ? ` · 当前:${progress.current.split("/").pop()}` : ""}
</span>
) : null}
{run.attempts > 1 ? <span>第 {run.attempts} 次尝试</span> : null}
<Link
className="ml-auto hover:text-foreground hover:underline"
to={`/console/tasks/${encodeURIComponent(run.id)}`}
>
任务详情
</Link>
{record ? (
<Link className="hover:text-foreground hover:underline" to={`/console/organize/${record.id}`}>
整理详情
</Link>
) : null}
</div>
{detail.error || recordQuery.error || related.error || download.error ? (
<p className="text-meta text-warning">部分详细信息暂时无法读取,任务状态仍会自动刷新。</p>
) : null}
</li>
);
}
/** Existing engine workflows are authoritative, including jobs that have not created a record yet. */
export function OrganizeQueue() {
const qc = useQueryClient();
const runs = useEngineRuns(
{ workflowName: "verifyAndImport,scrapeMedia", status: "pending,running,sleeping" },
true,
5_000,
);
useSse(["task.created", "task.status_changed", "organization.changed", "download.status_changed"], () => {
void qc.invalidateQueries({ queryKey: qk.all("engine") });
void qc.invalidateQueries({ queryKey: ["records"] });
});
const rows = runs.data?.pages.flatMap((page) => page.items);
const paged = useListPagination(rows, {
hasMore: runs.hasNextPage,
onLoadMore: () => runs.fetchNextPage(),
loadingMore: runs.isFetchingNextPage,
error: runs.error,
resetKey: "organize-queue",
});
return (
<section aria-label="排队与执行中的整理任务" className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-caption text-muted-foreground">
下载核验、手动与自动整理、整理后的 NFO / 图片刮削在此排队。整理完成后进入记录,刮削继续显示到任务结束。
</p>
<Button variant="outline" size="sm" disabled={runs.isFetching} onClick={() => void runs.refetch()}>
<RefreshCw />
刷新
</Button>
</div>
{runs.isPending ? (
<LoadingState rows={4} />
) : runs.error && !rows ? (
<ErrorState error={runs.error} onRetry={() => runs.refetch()} />
) : !rows?.length ? (
<EmptyState icon={FileStack} title="没有排队或执行中的整理任务" />
) : (
<ul className="space-y-3">
{paged.items?.map((run) =>
run.workflowName === "scrapeMedia" ? (
<ScrapeQueueItem key={run.id} run={run} />
) : (
<QueueItem key={run.id} run={run} />
),
)}
</ul>
)}
{rows ? <Pagination {...paged.pagination} /> : null}
</section>
);
}
@@ -0,0 +1,78 @@
import { useQuery } from "@tanstack/react-query";
import { FilesScrapeRecordsResult } from "@umt/contracts";
import { Link } from "react-router";
import { apiParsed } from "~/api/parse.ts";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { PaginatedList } from "~/components/console/paginated-list.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { SCRAPE_STATUS, scrapeCounts } from "~/components/library/scrape-panel.tsx";
import { reasonLine } from "~/lib/reasons.ts";
import { formatDateTime } from "~/lib/utils.ts";
/** 整理与刮削分别展示结果,避免将刮削失败当作文件搬运失败。 */
export function OrganizeScrapeHistory({ recordId }: { recordId: string }) {
const query = useQuery({
queryKey: ["files", "scrape-records", "organization", recordId],
queryFn: ({ signal }) =>
apiParsed(FilesScrapeRecordsResult, "/files/scrape-records", {
params: { organizationRecordId: recordId },
signal,
}),
refetchInterval: 5_000,
});
return (
<section className="pg-slab space-y-3 rounded-xl p-4" aria-label="整理后刮削">
<h2 className="text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">整理后刮削</h2>
{query.isPending ? (
<LoadingState rows={1} />
) : query.error ? (
<ErrorState error={query.error} onRetry={() => query.refetch()} />
) : query.data?.records.length ? (
<PaginatedList rows={query.data.records} label="关联刮削记录" resetKey={recordId} as="ul" className="space-y-3">
{(record) => (
<li key={record.id} className="space-y-1.5 rounded-xl border border-border p-3">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge tone={SCRAPE_STATUS[record.status].tone}>{SCRAPE_STATUS[record.status].label}</StatusBadge>
<span className="text-caption">NFO 与图片</span>
<span className="ml-auto text-meta text-muted-foreground">{formatDateTime(record.requestedAt)}</span>
</div>
<p className="break-all font-mono text-meta text-muted-foreground">
{record.match?.title ?? record.path} · {record.path || "/"}
</p>
<p className="text-meta text-muted-foreground">{scrapeCounts(record) || "等待写入 NFO 与图片"}</p>
{record.reason ? <p className="text-meta text-destructive">{reasonLine(record.reason)}</p> : null}
{record.files.some((file) => file.action === "failed") ? (
<details className="text-meta">
<summary className="cursor-pointer text-muted-foreground">失败文件</summary>
<PaginatedList
rows={record.files.filter((file) => file.action === "failed")}
label="刮削失败文件"
resetKey={record.id}
as="ul"
className="mt-2 space-y-1"
>
{(file) => (
<li key={`${file.path}:${file.role}`} className="break-all text-destructive">
{file.path} · {reasonLine(file.reason) || "写入失败"}
</li>
)}
</PaginatedList>
</details>
) : null}
{record.taskId ? (
<Link
to={`/console/tasks/${encodeURIComponent(record.taskId)}`}
className="inline-block text-meta underline"
>
查看刮削任务
</Link>
) : null}
</li>
)}
</PaginatedList>
) : (
<p className="text-caption text-muted-foreground">没有关联的刮削记录。</p>
)}
</section>
);
}
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { organizeQueueStage, organizeRunInput, scrapeQueueStage } from "./queue-model.ts";
describe("organize queue", () => {
it("reads pending workflow references before a record exists", () => {
expect(organizeRunInput({ input: { downloadTaskId: "download", taskId: "task" } })).toEqual({
downloadTaskId: "download",
recordId: undefined,
});
expect(organizeQueueStage({ status: "pending" })).toBe("等待执行");
expect(organizeQueueStage({ status: "running" })).toBe("核验文件与生成整理计划");
});
it("distinguishes file transfer, plan waiting, and workflow waiting", () => {
expect(organizeRunInput({ recordId: "record" }).recordId).toBe("record");
expect(organizeQueueStage({ status: "running" }, { status: "queued" })).toContain("等待搬运");
expect(organizeQueueStage({ status: "running" }, { status: "running" })).toContain("搬运文件");
expect(organizeQueueStage({ status: "sleeping" }, { status: "running" })).toBe("等待继续执行");
});
it("keeps the independent scrape stage distinct after importing finishes", () => {
expect(organizeRunInput({ input: { scrapeRecordId: "scrape" } }).scrapeRecordId).toBe("scrape");
expect(scrapeQueueStage({ status: "running" })).toBe("刮削 NFO / 图片");
expect(scrapeQueueStage({ status: "pending" })).toContain("等待刮削");
});
it("原地入库显示核验与登记,不显示搬运", () => {
expect(organizeQueueStage({ status: "running" }, { status: "running", action: "in_place" })).toBe(
"核验原文件与登记媒体库",
);
expect(organizeQueueStage({ status: "running" }, { status: "queued", action: "in_place" })).toBe("等待原地登记");
});
it("does not describe a finished business record as transferring", () => {
expect(organizeQueueStage({ status: "running" }, { status: "succeeded" })).toContain("收尾");
expect(organizeRunInput(null)).toEqual({});
expect(organizeRunInput({ input: { recordId: 5 } })).toEqual({ recordId: undefined, downloadTaskId: undefined });
});
});
@@ -0,0 +1,37 @@
import type { EngineRun, PublicOrganizationRecord } from "@umt/contracts";
export function organizeRunInput(value: unknown): {
recordId?: string;
downloadTaskId?: string;
scrapeRecordId?: string;
} {
if (!value || typeof value !== "object") return {};
const envelope = value as Record<string, unknown>;
const input =
envelope.input && typeof envelope.input === "object" ? (envelope.input as Record<string, unknown>) : envelope;
return {
recordId: typeof input.recordId === "string" ? input.recordId : undefined,
downloadTaskId: typeof input.downloadTaskId === "string" ? input.downloadTaskId : undefined,
...(typeof input.scrapeRecordId === "string" ? { scrapeRecordId: input.scrapeRecordId } : {}),
};
}
export function organizeQueueStage(
run: Pick<EngineRun, "status">,
record?: Pick<PublicOrganizationRecord, "status"> & Partial<Pick<PublicOrganizationRecord, "action">>,
) {
if (run.status === "sleeping") return "等待继续执行";
if (run.status === "pending") return "等待执行";
if (!record) return "核验文件与生成整理计划";
if (record.action === "in_place" && (record.status === "queued" || record.status === "running"))
return record.status === "queued" ? "等待原地登记" : "核验原文件与登记媒体库";
if (record.status === "queued") return "整理计划已生成,等待搬运";
if (record.status === "running") return "搬运文件与登记媒体库";
return "整理已结束,完成任务收尾";
}
export function scrapeQueueStage(run: Pick<EngineRun, "status">) {
if (run.status === "pending") return "等待刮削 NFO / 图片";
if (run.status === "sleeping") return "等待继续刮削 NFO / 图片";
return "刮削 NFO / 图片";
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { canReorganize, episodeTag, hasFiles, isRunning, recordTitle } from "./record-parts.ts";
import { canReorganize, episodeTag, hasFiles, isRunning, recordFileProgress, recordTitle } from "./record-parts.ts";
// HIS-1 整理记录展示小件:标题 / 季集、能否重新整理、能否连文件删除
@@ -52,3 +52,16 @@ describe("整理记录展示", () => {
expect(hasFiles({ action: "move" })).toBe(true);
});
});
describe("文件级进度", () => {
it("没有明细时不推算进度;已结束包含成功、失败和跳过", () => {
expect(recordFileProgress({ files: null })).toBeNull();
expect(recordFileProgress({ files: [] })).toBeNull();
const files = ["succeeded", "failed", "skipped", "running", "queued"].map((status, i) => ({
status,
source: path(`/source/${i}.mkv`),
target: path(null),
})) as unknown as NonNullable<Parameters<typeof recordFileProgress>[0]["files"]>;
expect(recordFileProgress({ files })).toEqual({ completed: 3, total: 5, current: "/source/3.mkv" });
});
});
@@ -23,13 +23,27 @@ export function recordTitle(r: Rec): string {
/** 能重新整理的记录:复制 / 移动 / 硬链接,且已结束 */
export const canReorganize = (r: Pick<PublicOrganizationRecord, "action" | "status">) =>
(r.action === "copy" || r.action === "move" || r.action === "hardlink") &&
(r.action === "copy" || r.action === "move" || r.action === "in_place" || r.action === "hardlink") &&
r.status !== "queued" &&
r.status !== "running";
/** 有源文件 / 媒体库文件可删的记录(改名、删除、还原类只删记录本身) */
export const hasFiles = (r: Pick<PublicOrganizationRecord, "action">) =>
r.action === "copy" || r.action === "move" || r.action === "hardlink";
r.action === "copy" || r.action === "move" || r.action === "in_place" || r.action === "hardlink";
export const isRunning = (r: Pick<PublicOrganizationRecord, "status">) =>
r.status === "queued" || r.status === "running";
/** File counts come from record detail, never from elapsed time or transferred bytes. */
export function recordFileProgress(
r: Pick<PublicOrganizationRecord, "files">,
): { completed: number; total: number; current: string | null } | null {
if (!r.files?.length) return null;
const files = r.files;
const current = files.find((f) => f.status === "running");
return {
completed: files.filter((f) => !["queued", "running"].includes(f.status)).length,
total: files.length,
current: current?.source.path ?? current?.target.path ?? null,
};
}
@@ -0,0 +1,50 @@
import type { OrganizeSourceConfig } from "@umt/contracts";
import { describe, expect, it } from "vitest";
import { matchingOrganizeSources } from "./source-match.ts";
const profile = (id: string, path: string, root = "dl:one:0"): OrganizeSourceConfig => ({
id,
name: id,
enabled: true,
source: { root, path },
target: { root: "lib:target", path: "" },
mediaType: "all",
category: null,
action: "copy",
trigger: "manual",
downloaderInstanceId: null,
tag: null,
intervalSec: 300,
});
const ref = (path: string, root = "dl:one:0") => ({ root, path });
describe("文件管理整理来源匹配", () => {
it("根目录来源覆盖根内所选文件,但不跨根", () => {
const root = profile("root", "");
expect(matchingOrganizeSources([ref(""), ref("movie/a.mkv")], [root])).toEqual([root]);
expect(matchingOrganizeSources([ref("movie/a.mkv", "dl:two:0")], [root])).toEqual([]);
});
it("目录匹配保持分段边界,也可选来源目录本身", () => {
const movie = profile("movie", "completed/movie");
expect(matchingOrganizeSources([ref("completed/movie"), ref("completed/movie/a.mkv")], [movie])).toEqual([movie]);
expect(matchingOrganizeSources([ref("completed/movies/a.mkv")], [movie])).toEqual([]);
expect(matchingOrganizeSources([ref("completed")], [movie])).toEqual([]);
});
it("所有所选文件必须归属同一配置,空选择不匹配", () => {
const movie = profile("movie", "movie");
expect(matchingOrganizeSources([ref("movie/a.mkv"), ref("tv/b.mkv")], [movie])).toEqual([]);
expect(matchingOrganizeSources([ref("movie/a.mkv"), ref("movie/b.mkv", "dl:two:0")], [movie])).toEqual([]);
expect(matchingOrganizeSources([], [movie])).toEqual([]);
});
it("优先最长来源路径,同长度的多个配置全部保留供用户选择", () => {
const all = profile("all", "");
const completed = profile("completed", "completed");
const a = profile("a", "completed/movie");
const b = profile("b", "completed/movie");
expect(matchingOrganizeSources([ref("completed/movie/a.mkv")], [all, completed, a])).toEqual([a]);
expect(matchingOrganizeSources([ref("completed/movie/a.mkv")], [all, a, b])).toEqual([a, b]);
expect(
matchingOrganizeSources([ref("completed/movie/a.mkv"), ref("completed/tv/b.mkv")], [all, completed, a]),
).toEqual([completed]);
});
});
@@ -0,0 +1,15 @@
import type { FileRef, OrganizeSourceConfig } from "@umt/contracts";
/** Match every selected item at directory boundaries, then prefer the most specific source. */
export function matchingOrganizeSources<T extends OrganizeSourceConfig>(items: FileRef[], profiles: T[]): T[] {
if (!items.length) return [];
const matches = profiles.filter((p) =>
items.every(
(item) =>
item.root === p.source.root &&
(p.source.path === "" || item.path === p.source.path || item.path.startsWith(`${p.source.path}/`)),
),
);
const longest = Math.max(-1, ...matches.map((p) => p.source.path.length));
return matches.filter((p) => p.source.path.length === longest);
}
@@ -0,0 +1,106 @@
import type { OrganizeSourceView, OrganizeStockResult } from "@umt/contracts";
import { useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { filesErrorText } from "~/api/files.ts";
import { useOrganizeSources, useOrganizeStock } from "~/api/organize-sources.ts";
import { PaginatedList } from "~/components/console/paginated-list.tsx";
import { DirectoryDialog } from "~/components/files/directory-dialog.tsx";
import type { OrganizeSource } from "~/components/organize-wizard/organize-wizard.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/components/ui/dialog.tsx";
export function stockWizardSource(profile: OrganizeSourceView, stock: OrganizeStockResult): OrganizeSource {
return {
kind: "files",
items: stock.items,
sourceConfigId: profile.id,
defaultAction: profile.action,
label: `整理存量 · ${profile.name}`,
scanSummary: `找到 ${stock.items.length} 个文件,跳过 ${stock.skipped} 个,不可读取 ${stock.unavailable} 个${stock.truncated ? ";扫描已达到上限,仅显示本批结果" : ""}。本次扫描不改变自动整理基线,请先预览再执行。`,
};
}
export function SourceSelection({
onClose,
onSelect,
}: {
onClose: () => void;
onSelect: (source: OrganizeSource) => void;
}) {
const sources = useOrganizeSources();
const stock = useOrganizeStock();
const [manual, setManual] = useState(false);
if (manual)
return (
<DirectoryDialog
mode="source"
allowRoot={false}
onClose={() => setManual(false)}
onSelect={(ref) => onSelect({ kind: "files", items: [ref], label: "手动选择来源目录" })}
/>
);
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>选择整理来源</DialogTitle>
<DialogDescription>扫描来源存量后,进入文件识别、预览与执行。扫描不会改变自动整理基线。</DialogDescription>
</DialogHeader>
<div className="max-h-72 space-y-2 overflow-auto">
{sources.isPending ? <p>读取来源中…</p> : null}
{sources.error ? (
<p role="alert" className="text-destructive">
来源配置读取失败,仍可手动选择目录。
</p>
) : null}
{sources.data?.items.length ? (
<PaginatedList
rows={sources.data.items}
label="整理来源配置"
resetKey="organize-source-selection"
className="space-y-2"
>
{(p) => (
<Button
key={p.id}
variant="outline"
className="h-auto w-full justify-start py-3"
disabled={stock.isPending}
onClick={() =>
stock.mutate(
{ id: p.id },
{
onSuccess: (r) =>
r.items.length
? onSelect(stockWizardSource(p, r))
: toast.info(
`没有可整理文件;跳过 ${r.skipped},不可读取 ${r.unavailable}${r.truncated ? ",扫描已达到上限" : ""}`,
),
onError: (e) => toast.error(filesErrorText(e, "扫描存量失败")),
},
)
}
>
{p.name} · {p.action === "copy" ? "复制" : "移动"}
{!p.enabled ? " · 已停用" : ""}
</Button>
)}
</PaginatedList>
) : null}
</div>
<Button variant="outline" disabled={stock.isPending} onClick={() => setManual(true)}>
手动选择其他来源目录
</Button>
{sources.isSuccess && !sources.data.items.length ? (
<Link
to="/console/settings#library/sources"
onClick={onClose}
className="text-caption text-muted-foreground underline"
>
去设置新增整理来源
</Link>
) : null}
</DialogContent>
</Dialog>
);
}
+9 -1
View File
@@ -1,13 +1,16 @@
import type { RuleGroup } from "@umt/contracts";
import { ListChecks, ShieldOff, Tags, Trash2 } from "lucide-react";
import { useSearchParams } from "react-router";
import { toast } from "sonner";
import { type RuleRefs, useDeleteRuleGroup, useUpdateRuleGroup } from "~/api/rules.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { EmptyState } from "~/components/console/empty-state.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { useListPagination } from "~/components/console/use-list-pagination.ts";
import { Chip, Panel, relTime } from "~/components/library/parts.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Pagination } from "~/components/ui/pagination.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
@@ -65,6 +68,7 @@ export function RuleGroupTable({
onOpen: (g: RuleGroup) => void;
onCreate: () => void;
}) {
const [params] = useSearchParams();
const { toggle, pending } = useToggle();
const columns: DataColumn<RuleGroup>[] = [
{
@@ -132,6 +136,7 @@ export function RuleGroupTable({
<DataTable
columns={columns}
rows={rows}
paginationKey={params.toString()}
rowKey={(g) => g.id}
loading={loading}
error={error}
@@ -234,6 +239,8 @@ function RuleTag({ g, onOpen }: { g: RuleGroup; onOpen: () => void }) {
}
export function RuleTags({ rows, onOpen }: { rows: RuleGroup[]; onOpen: (g: RuleGroup) => void }) {
const [params] = useSearchParams();
const paged = useListPagination(rows, { resetKey: params.toString() });
return (
<Panel
title="偏好"
@@ -250,11 +257,12 @@ export function RuleTags({ rows, onOpen }: { rows: RuleGroup[]; onOpen: (g: Rule
/>
) : (
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{rows.map((g) => (
{paged.items?.map((g) => (
<RuleTag key={g.id} g={g} onOpen={() => onOpen(g)} />
))}
</ul>
)}
{rows.length > 0 ? <Pagination {...paged.pagination} /> : null}
</Panel>
);
}
@@ -5,7 +5,9 @@ import { Link } from "react-router";
import { toast } from "sonner";
import { useRunScheduleNow, useSkipSchedule, useUpdateSchedule } from "~/api/schedules.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { useListPagination } from "~/components/console/use-list-pagination.ts";
import { Button } from "~/components/ui/button.tsx";
import { Pagination } from "~/components/ui/pagination.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatRelative } from "~/lib/labels.ts";
@@ -228,7 +230,16 @@ export function AutoTaskList({
if (list) list.push(j);
else byGroup.set(j.group, [j]);
}
const groups = TASK_GROUPS.filter((g) => byGroup.get(g)?.length);
const ordered = TASK_GROUPS.flatMap((g) => byGroup.get(g) ?? []);
const paged = useListPagination(ordered, { resetKey: "auto-tasks" });
const visibleIds = new Set(paged.items?.map((job) => job.id));
const groups = TASK_GROUPS.filter((g) => byGroup.get(g)?.some((job) => visibleIds.has(job.id)));
const highlightedIndex = ordered.findIndex((job) => job.id === highlightJob);
// biome-ignore lint/correctness/useExhaustiveDependencies: 仅在深链目标或每页数量变化时定位所在页
useEffect(() => {
if (highlightedIndex >= 0)
paged.pagination.onPageChange(Math.floor(highlightedIndex / paged.pagination.pageSize) + 1);
}, [highlightJob, highlightedIndex, paged.pagination.pageSize]);
// 折叠:有失败 / 运行中 / 被高亮的组默认展开,其余收起;点过的按用户的来并记在本机
const [saved, setSaved] = useState<Record<string, boolean>>(() => readCollapseState(COLLAPSE_KEY) ?? {});
const expanded = (g: ScheduleGroup, list: ScheduledJob[]) =>
@@ -284,14 +295,17 @@ export function AutoTaskList({
</h3>
{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} />
))}
{list
.filter((job) => visibleIds.has(job.id))
.map((j) => (
<AutoTaskRow key={j.id} job={j} now={now} highlight={highlightJob === j.id} />
))}
</ul>
) : null}
</section>
);
})}
{ordered.length > 0 ? <Pagination {...paged.pagination} /> : null}
</div>
);
}
@@ -6,6 +6,8 @@ import { DetailSheet } from "~/components/console/detail-sheet.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { useListPagination } from "~/components/console/use-list-pagination.ts";
import { Pagination } from "~/components/ui/pagination.tsx";
import { agentRunLabel } from "~/lib/labels.ts";
import { describeSchedule, formatClock, formatMs, RUN_STATUS, TRIGGER_LABEL } from "./labels.ts";
@@ -13,6 +15,7 @@ import { describeSchedule, formatClock, formatMs, RUN_STATUS, TRIGGER_LABEL } fr
export function RunsSheet({ job, onClose }: { job: ScheduledJob | null; onClose: () => void }) {
const runs = useScheduleRuns(job?.id ?? null);
const items = runs.data?.items ?? [];
const paged = useListPagination(items, { resetKey: job?.id });
return (
<DetailSheet
open={!!job}
@@ -30,7 +33,7 @@ export function RunsSheet({ job, onClose }: { job: ScheduledJob | null; onClose:
<EmptyState plain icon={History} title="还没有运行记录" />
) : (
<ol className="grid gap-2">
{items.map((r) => {
{paged.items?.map((r) => {
const s = RUN_STATUS[r.status];
return (
<li key={r.id} className="grid gap-1 rounded-tight border border-border/70 px-3 py-2">
@@ -60,6 +63,7 @@ export function RunsSheet({ job, onClose }: { job: ScheduledJob | null; onClose:
})}
</ol>
)}
{items.length > 0 ? <Pagination {...paged.pagination} /> : null}
</DetailSheet>
);
}
@@ -6,7 +6,9 @@ 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 { useListPagination } from "~/components/console/use-list-pagination.ts";
import { Button } from "~/components/ui/button.tsx";
import { Pagination } from "~/components/ui/pagination.tsx";
import { qk } from "~/lib/query-keys.ts";
import { useSse } from "~/lib/sse.ts";
import { AgentCheckCard, type DefaultUpstream } from "./agent-check-card.tsx";
@@ -105,6 +107,13 @@ export function PlanBoard({ highlightJob }: { highlightJob?: string }) {
const items = data?.items ?? [];
const autoJobs = items.filter((j) => j.kind === "task");
const checks = items.filter((j) => j.kind === "agent_check");
const pagedChecks = useListPagination(checks, { resetKey: "agent-checks" });
const highlightedIndex = checks.findIndex((job) => job.id === highlightJob);
// biome-ignore lint/correctness/useExhaustiveDependencies: 仅在深链目标或每页数量变化时定位所在页
useEffect(() => {
if (highlightedIndex >= 0)
pagedChecks.pagination.onPageChange(Math.floor(highlightedIndex / pagedChecks.pagination.pageSize) + 1);
}, [highlightJob, highlightedIndex, pagedChecks.pagination.pageSize]);
const def = upstreams.data?.items.find((u) => u.isDefault);
// 上游没填默认模型时,运行时用模型目录的第一个(packages/agent pi/upstream.ts pickModel),这里同样取目录首个
const catalog = usePiUpstreamModels(def && !def.defaultModel ? def.id : null);
@@ -170,7 +179,7 @@ export function PlanBoard({ highlightJob }: { highlightJob?: string }) {
<EmptyState icon={Bot} title="还没有巡检" description="新建一个巡检,到点由智能体按目标检查并汇报。" />
) : (
<div className="grid auto-rows-fr gap-4 md:grid-cols-2 2xl:grid-cols-3">
{checks.map((j) => (
{pagedChecks.items?.map((j) => (
<AgentCheckCard
key={j.id}
job={j}
@@ -183,6 +192,7 @@ export function PlanBoard({ highlightJob }: { highlightJob?: string }) {
))}
</div>
)}
{checks.length > 0 ? <Pagination {...pagedChecks.pagination} /> : null}
</section>
<section aria-labelledby="auto">
<SectionHead
@@ -7,6 +7,7 @@ import { settingsPath } from "~/components/console/nav.ts";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "~/components/ui/dialog.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
import { SearchHistory, useSearchHistory } from "./search-history.tsx";
import { actionIcon, mediaKindLabel } from "./search-labels.ts";
import {
isSearchShortcut,
@@ -77,6 +78,7 @@ export function CommandPalette() {
function PaletteBody({ initial }: { initial: string }) {
const navigate = useNavigate();
const history = useSearchHistory();
const listId = useId();
const [q, setQ] = useState(initial);
const term = useDebounced(q.trim(), 150);
@@ -87,6 +89,7 @@ function PaletteBody({ initial }: { initial: string }) {
const close = () => setSearchPaletteOpen(false);
const go = (to: string) => {
if (q.trim()) history.add(q);
close();
navigate(to);
};
@@ -183,9 +186,17 @@ function PaletteBody({ initial }: { initial: string }) {
className="min-h-0 flex-1 overflow-y-auto p-2 sm:max-h-[60vh]"
>
{!term ? (
<p className="px-3 py-6 text-center text-caption text-muted-foreground">
输入作品名、别名或页面名称。选中作品打开详情页。
</p>
<div className="px-3 pt-2">
<SearchHistory
onSearch={(term) => {
history.add(term);
go(searchPagePath(term));
}}
/>
<p className="py-6 text-center text-caption text-muted-foreground">
输入作品名、别名或页面名称。选中作品打开详情页。
</p>
</div>
) : (
<>
{items.map((it, i) => {
@@ -19,9 +19,12 @@ import { type ReleaseSearchFilters, useRefreshReleaseSearch, useReleaseSearch }
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { TechInfo } from "~/components/console/tech-info.tsx";
import { useListPagination } from "~/components/console/use-list-pagination.ts";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Pagination } from "~/components/ui/pagination.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { ReleaseDetailSheet } from "~/components/workbench/release-detail-sheet.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import { errorMessage } from "~/lib/api.ts";
import { cn, formatBytes } from "~/lib/utils.ts";
@@ -119,7 +122,15 @@ export function ReleaseResults({
const pages = res.data?.pages ?? [];
const first = pages[0];
const rows = useMemo(() => pages.flatMap((p) => p.items), [pages]);
const paged = useListPagination(rows, {
hasMore: res.hasNextPage,
onLoadMore: () => res.fetchNextPage(),
loadingMore: res.isFetchingNextPage,
error: res.error,
resetKey: JSON.stringify(api),
});
const [dl, setDl] = useState<SearchRelease | null>(null);
const [selected, setSelected] = useState<SearchRelease | null>(null);
const filtered =
selectedCount(quick) > 0 || RELEASE_FILTER_KEYS.some((k) => k !== "sort" && k !== "order" && !!filters[k]);
// 旧链接里的免费 / H&R 参数并进快速筛选
@@ -227,18 +238,12 @@ export function ReleaseResults({
/>
) : (
<ul className="divide-y divide-border/60">
{rows.map((r) => (
<ReleaseRow key={r.key} r={r} onDownload={() => setDl(r)} />
{paged.items?.map((r) => (
<ReleaseRow key={r.key} r={r} onOpen={() => setSelected(r)} onDownload={() => setDl(r)} />
))}
</ul>
)}
{rows.length > 0 && res.hasNextPage ? (
<div className="flex justify-center border-t border-border/60 px-4 py-3">
<Button variant="outline" size="sm" onClick={() => res.fetchNextPage()} disabled={res.isFetchingNextPage}>
{res.isFetchingNextPage ? "加载中…" : "加载更多"}
</Button>
</div>
) : null}
{rows.length > 0 || res.hasNextPage ? <Pagination {...paged.pagination} /> : null}
</div>
{first ? (
@@ -254,6 +259,17 @@ export function ReleaseResults({
) : null}
{dl ? <SearchDownloadDialog release={dl} open={!!dl} onOpenChange={(o) => !o && setDl(null)} /> : null}
{selected?.sourceReleaseId ? (
<ReleaseDetailSheet
key={selected.key}
siteId={selected.siteId}
release={null}
releaseId={selected.sourceReleaseId}
title={selected.title}
entry={selected.inventory ?? undefined}
onClose={() => setSelected(null)}
/>
) : null}
</section>
);
}
@@ -404,7 +420,7 @@ function SizeSortControls({
);
}
function ReleaseRow({ r, onDownload }: { r: SearchRelease; onDownload: () => void }) {
function ReleaseRow({ r, onOpen, onDownload }: { r: SearchRelease; onOpen: () => void; onDownload: () => void }) {
const promo = promoLabel(r.promotion.type);
const inv = r.inventory ? INVENTORY_STATE[r.inventory.state] : null;
const p = r.parsed;
@@ -412,7 +428,14 @@ function ReleaseRow({ r, onDownload }: { r: SearchRelease; onDownload: () => voi
return (
<li className="px-4 py-3">
<div className="flex min-w-0 flex-wrap items-start gap-x-2 gap-y-1">
<span className="min-w-0 flex-1 text-sm font-medium break-all text-foreground">{r.title}</span>
<button
type="button"
onClick={onOpen}
disabled={!r.sourceReleaseId}
className="min-w-0 flex-1 cursor-pointer text-left text-sm font-medium break-all text-foreground underline-offset-4 hover:underline focus-visible:underline disabled:cursor-default disabled:no-underline"
>
{r.title}
</button>
<span className="flex flex-wrap gap-1">
{promo ? <StatusBadge tone={r.free ? "success" : "neutral"}>{promo}</StatusBadge> : null}
{r.hr === true ? <StatusBadge tone="warning">H&R</StatusBadge> : null}
@@ -1,44 +1,15 @@
import type { ManualAcquisitionResult, SearchRelease } from "@umt/contracts";
import { Bot, Download } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import type { SearchRelease } from "@umt/contracts";
import { usePlanFromSearch } from "~/api/search.ts";
import { useSlots } from "~/api/slots.ts";
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
import { FilterSelect } from "~/components/console/filter-bar.tsx";
import { SLOT_STATUS } from "~/components/library/labels.ts";
import { Button } from "~/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatBytes } from "~/lib/utils.ts";
import { ManualDownloadDialog } from "~/components/downloads/manual-download-dialog.tsx";
// 与站点工作台同一套下载说明;请求走 POST /search/releases/{siteId}/{siteReleaseId}/plan(同 /acquisition-plans 流程)
const STEPS = [
"预留收录版本和预算;每个收录版本同时只有一个在途的下载",
"已有相同资源或相同 hash 的任务时直接接管,不重复提交",
"缓存卷超过硬阈值或读不到磁盘状态时无法提交",
"提交前复核免费、做种等易变字段",
];
/** 委托 Agent:和工作台一样带上资源、作品、站点 */
export function delegateToAgent(r: SearchRelease) {
export function delegateToAgent(release: SearchRelease) {
openAgentDrawer({
label: `资源:${r.title}`,
releaseId: r.sourceReleaseId ?? undefined,
entityId: r.entity?.id,
siteId: r.siteId,
goal: r.sourceReleaseId
? "判断这个资源是否值得下载;合适就按正常流程提交"
: `判断站点 ${r.siteName} 的资源「${r.title}」是否值得下载;合适就按正常流程提交`,
label: `资源:${release.title}`,
releaseId: release.sourceReleaseId ?? undefined,
entityId: release.entity?.id,
siteId: release.siteId,
goal: `判断资源「${release.title}」是否值得下载;合适就按手动下载流程提交`,
});
}
@@ -49,124 +20,23 @@ export function SearchDownloadDialog({
}: {
release: SearchRelease;
open: boolean;
onOpenChange: (o: boolean) => void;
onOpenChange: (open: boolean) => void;
}) {
const plan = usePlanFromSearch();
const entityId = release.entity?.id;
const slots = useSlots({ mediaEntityId: entityId }, open && !!entityId);
const slotList = slots.data?.items ?? [];
const [slotId, setSlotId] = useState("");
const [rejected, setRejected] = useState<ManualAcquisitionResult["rejected"] | null>(null);
const [done, setDone] = useState<string | null>(null);
const slotName = (id: string | null) => slotList.find((s) => s.id === id)?.name ?? null;
const go = () =>
plan.mutate(
{
siteId: release.siteId,
siteReleaseId: release.siteReleaseId,
...(slotId ? { slotId } : entityId ? { entityId } : {}),
},
{
onSuccess: (r) => {
if (r.submitted && r.plan) {
setRejected(null);
setDone(r.plan.id);
toast.success(`已提交${slotName(r.slotId) ? `(${slotName(r.slotId)})` : ""},进度见「下载面板」`);
} else {
setRejected(r.rejected);
toast.warning("没有下载");
}
},
onError: (e) => toast.error(errorMessage(e)),
},
);
const submit = usePlanFromSearch();
return (
<Dialog
<ManualDownloadDialog
key={`${release.siteId}:${release.siteReleaseId}`}
open={open}
onOpenChange={(o) => {
if (!o) {
setRejected(null);
setDone(null);
}
onOpenChange(o);
onOpenChange={onOpenChange}
resource={{
title: release.title,
sizeBytes: release.sizeBytes,
entityId: release.entityMatch === "resolved" ? release.entity?.id : undefined,
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>下载这个资源</DialogTitle>
<DialogDescription className="break-all">
{release.siteName} · {release.title}
{release.sizeBytes !== null ? ` · ${formatBytes(release.sizeBytes, 1)}` : ""}
</DialogDescription>
</DialogHeader>
<ol className="list-decimal space-y-1 pl-5 text-caption text-muted-foreground">
{STEPS.map((s) => (
<li key={s}>{s}</li>
))}
</ol>
{!entityId ? (
<p className="text-meta text-muted-foreground">资源未关联本地作品。先在「身份与元数据」指定。</p>
) : slots.isPending ? (
<p className="text-meta text-muted-foreground">正在读取收录版本…</p>
) : slotList.length ? (
<div className="flex flex-wrap items-center gap-2">
<FilterSelect
label="收录版本"
value={slotId}
onChange={setSlotId}
allLabel="自动匹配"
options={slotList.map((s) => ({
value: s.id,
label: `${s.name}(${SLOT_STATUS[s.status]?.label ?? s.status})`,
}))}
/>
</div>
) : (
<p className="text-meta text-muted-foreground">这个作品没有收录版本。先在订阅里新建。</p>
)}
{release.entityMatch === "alias" ? (
<p className="text-meta text-warning">作品关联来自标题别名,尚未确认。</p>
) : null}
{rejected ? (
<div className="rounded-xl border border-warning/40 bg-warning/10 px-3 py-2.5 text-caption text-warning">
<p className="mb-1">没有下载:</p>
<ul className="list-disc space-y-0.5 pl-5">
{rejected.map((r, i) => (
<li key={`${r.slotId}-${r.code}-${i}`} className="break-words">
{slotName(r.slotId) ? `${slotName(r.slotId)}:` : ""}
{r.message}
</li>
))}
</ul>
</div>
) : null}
{done ? (
<p className="text-caption text-success">
已加入下载。
<Link to={`/console/downloads?plan=${done}`} className="underline underline-offset-4">
查看
</Link>
</p>
) : null}
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => {
onOpenChange(false);
delegateToAgent(release);
}}
>
<Bot />
委托智能体
</Button>
<Button onClick={go} disabled={plan.isPending || !!done || !entityId}>
<Download />
{plan.isPending ? "提交中…" : "提交下载"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
onDelegate={() => delegateToAgent(release)}
submit={(options) =>
submit.mutateAsync({ ...options, siteId: release.siteId, siteReleaseId: release.siteReleaseId })
}
/>
);
}
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { addHistory, HISTORY_LIMIT, parseHistory } from "./search-history.tsx";
describe("search history", () => {
it("ignores malformed storage and non-text entries", () => {
expect(parseHistory("invalid json")).toEqual([]);
expect(parseHistory('[null,8," ","剧名","剧名"]')).toEqual(["剧名"]);
});
it("moves repeat searches to the front without saving intermediate typing", () => {
expect(addHistory(["Movie", "剧集"], " movie ")).toEqual(["movie", "剧集"]);
expect(addHistory(["剧集"], " ")).toEqual(["剧集"]);
});
it("retains only the latest searches", () => {
const history = Array.from({ length: HISTORY_LIMIT }, (_, i) => `query-${i}`);
const next = addHistory(history, "新搜索");
expect(next).toHaveLength(HISTORY_LIMIT);
expect(next[0]).toBe("新搜索");
expect(next).not.toContain(history.at(-1));
});
});
@@ -0,0 +1,114 @@
import { History, X } from "lucide-react";
import { useCallback, useMemo, useSyncExternalStore } from "react";
import { useSession } from "~/api/auth.ts";
import { Button } from "~/components/ui/button.tsx";
const EVENT = "umt:search-history";
export const HISTORY_LIMIT = 30;
export function parseHistory(raw: string): string[] {
try {
const value: unknown = JSON.parse(raw);
if (!Array.isArray(value)) return [];
return [
...new Set(value.filter((s): s is string => typeof s === "string" && !!s.trim()).map((s) => s.trim())),
].slice(0, HISTORY_LIMIT);
} catch {
return [];
}
}
export function addHistory(history: string[], term: string) {
const q = term.trim();
return q
? [q, ...history.filter((s) => s.toLocaleLowerCase() !== q.toLocaleLowerCase())].slice(0, HISTORY_LIMIT)
: history;
}
function subscribe(notify: () => void) {
window.addEventListener(EVENT, notify);
window.addEventListener("storage", notify);
return () => {
window.removeEventListener(EVENT, notify);
window.removeEventListener("storage", notify);
};
}
const memory = new Map<string, string>();
function read(key: string) {
try {
return localStorage.getItem(key) ?? "";
} catch {
return memory.get(key) ?? "";
}
}
export function useSearchHistory() {
const session = useSession();
const actor = session.data?.actor;
const key = actor ? `umt.search-history.${actor.type}.${actor.id}` : "";
const raw = useSyncExternalStore(
subscribe,
() => (key ? read(key) : ""),
() => "",
);
const entries = useMemo(() => parseHistory(raw), [raw]);
const write = useCallback(
(next: string[]) => {
if (!key) return;
const value = JSON.stringify(next);
memory.set(key, value);
try {
localStorage.setItem(key, value);
} catch {
/* session-only fallback */
}
window.dispatchEvent(new Event(EVENT));
},
[key],
);
return {
entries,
add: useCallback((term: string) => write(addHistory(parseHistory(key ? read(key) : ""), term)), [key, write]),
remove: (term: string) => write(entries.filter((s) => s !== term)),
clear: () => write([]),
};
}
export function SearchHistory({ onSearch }: { onSearch: (term: string) => void }) {
const history = useSearchHistory();
if (!history.entries.length) return null;
return (
<section aria-label="搜索历史" className="mb-6 min-w-0">
<div className="mb-2 flex items-center justify-between gap-2">
<h2 className="flex items-center gap-2 text-caption font-medium text-muted-foreground">
<History className="h-4 w-4" />
搜索历史
</h2>
<Button type="button" size="sm" variant="ghost" onClick={history.clear}>
清空历史
</Button>
</div>
<div className="flex flex-wrap gap-2">
{history.entries.map((term) => (
<div
key={term}
className="flex min-w-0 max-w-full items-center rounded-full border border-border bg-foreground/[0.03] text-caption"
>
<button
type="button"
onClick={() => onSearch(term)}
className="min-w-0 truncate rounded-l-full px-3 py-1.5 text-left outline-none hover:bg-foreground/[0.05] focus-visible:ring-2 focus-visible:ring-ring"
>
{term}
</button>
<button
type="button"
aria-label={`删除搜索历史:${term}`}
onClick={() => history.remove(term)}
className="mr-1 shrink-0 rounded-full p-1 text-muted-foreground outline-none hover:bg-foreground/[0.07] focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
</section>
);
}
@@ -6,7 +6,9 @@ import { mediaServers } from "~/api/instances.ts";
import { useSearch } from "~/api/search.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { settingsPath } from "~/components/console/nav.ts";
import { useListPagination } from "~/components/console/use-list-pagination.ts";
import { Button } from "~/components/ui/button.tsx";
import { Pagination } from "~/components/ui/pagination.tsx";
import { actionIcon } from "./search-labels.ts";
import { SitePickerDialog } from "./site-picker-dialog.tsx";
import { releaseSearchPath, WorkCard, workRef } from "./work-card.tsx";
@@ -40,11 +42,12 @@ export function useReleaseSearchLauncher() {
}
export function WorksSection({ q }: { q: string }) {
const res = useSearch(q, "media", 20);
const res = useSearch(q, "media", 50);
const ms = mediaServers.useList();
const emby = (ms.data?.items ?? []).some((i) => (i as { enabled?: boolean }).enabled !== false);
const launcher = useReleaseSearchLauncher();
const works = res.data?.media?.works ?? [];
const paged = useListPagination(works, { resetKey: q });
const tmdb = res.data?.media?.tmdb;
const searchReleases = (w: SearchWork) => {
const ref = workRef(w);
@@ -77,11 +80,12 @@ export function WorksSection({ q }: { q: string }) {
</div>
) : (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{works.map((w) => (
{paged.items?.map((w) => (
<WorkCard key={w.key} w={w} emby={emby} onSearchReleases={searchReleases} />
))}
</div>
)}
{res.data ? <Pagination {...paged.pagination} /> : null}
{tmdb && !tmdb.configured ? (
<p className="mt-2 text-meta text-muted-foreground">
TMDB 未配置,只搜了本地作品。
@@ -11,8 +11,10 @@ import {
useRenamePasskey,
} from "~/api/auth.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { useListPagination } from "~/components/console/use-list-pagination.ts";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Pagination } from "~/components/ui/pagination.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatDateTime } from "~/lib/utils.ts";
import { SettingsBlock } from "./settings-block.tsx";
@@ -124,6 +126,7 @@ export function PasskeyBlock({ disabled }: { disabled: boolean }) {
const [name, setName] = useState("");
const supported = passkeySupported();
const items = list.data?.items ?? [];
const paged = useListPagination(items, { resetKey: String(disabled) });
const add = async (e: React.FormEvent) => {
e.preventDefault();
@@ -147,13 +150,14 @@ export function PasskeyBlock({ disabled }: { disabled: boolean }) {
<p className="text-caption text-muted-foreground">读取中…</p>
) : items.length ? (
<ul className="divide-y divide-border/60">
{items.map((p) => (
{paged.items?.map((p) => (
<PasskeyRow key={p.id} item={p} />
))}
</ul>
) : (
<p className="text-caption text-muted-foreground">还没有登记 Passkey。</p>
)}
{items.length > 0 ? <Pagination {...paged.pagination} /> : null}
{!supported ? (
<p className="text-meta text-muted-foreground">这个浏览器用不了 Passkey:需要 HTTPS 或 localhost 访问。</p>
) : adding ? (
@@ -1,5 +1,6 @@
import { LibraryRootsTable } from "./instances.tsx";
import { NamingSection } from "./naming.tsx";
import { ClassificationSection, OrganizeSourcesSection } from "./organize-sources.tsx";
import { str } from "./resource-section.tsx";
import { SettingsBlock } from "./settings-block.tsx";
import { type CardSpec, SettingsCard, SettingsGate } from "./settings-card.tsx";
@@ -98,6 +99,8 @@ export function LibrarySection() {
</SettingsBlock>
<SettingsGate>{(s) => <SettingsCard section="library" spec={ORGANIZE_CARD} settings={s} />}</SettingsGate>
<SettingsGate>{(s) => <SettingsCard section="library" spec={SCRAPE_CARD} settings={s} />}</SettingsGate>
<ClassificationSection />
<OrganizeSourcesSection />
<NamingSection />
</div>
);
@@ -0,0 +1,470 @@
import type { FileRef, OrganizeSourceInput, OrganizeSourceView } from "@umt/contracts";
import { cloneElement, type ReactElement, useId, useState } from "react";
import { toast } from "sonner";
import { filesErrorText, useFileRoots } from "~/api/files.ts";
import { downloaders } from "~/api/instances.ts";
import {
useClassification,
useOrganizeSources,
useOrganizeStock,
useRemoveOrganizeSource,
useSaveClassification,
useSaveOrganizeSource,
} from "~/api/organize-sources.ts";
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
import { useListPagination } from "~/components/console/use-list-pagination.ts";
import { DirectoryDialog } from "~/components/files/directory-dialog.tsx";
import { stockWizardSource } from "~/components/organize/source-selection.tsx";
import { type OrganizeSource, OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/components/ui/dialog.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Pagination } from "~/components/ui/pagination.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { formatDateTime } from "~/lib/utils.ts";
import { SaveBar, SettingsBlock } from "./settings-block.tsx";
const selectClass = "w-full rounded-lg border border-border bg-background px-3 py-2 text-caption";
function Field({ label, children }: { label: string; children: ReactElement<{ id?: string }> }) {
const id = useId();
return (
<label htmlFor={id} className="block space-y-1.5 text-caption">
<span>{label}</span>
{cloneElement(children, { id })}
</label>
);
}
export function ClassificationSection() {
const config = useClassification();
const save = useSaveClassification();
const [draft, setDraft] = useState<string | null>(null);
const yaml = draft ?? config.data?.yaml ?? "";
const dirty = draft !== null && draft !== config.data?.yaml;
return (
<SettingsBlock
section="library"
anchor="classification"
title="分类规则"
dirty={dirty}
description="电影使用 movie,剧集和综艺使用 tv;分类目录按下列规则生成。"
actions={
<Button
size="sm"
variant="outline"
onClick={() =>
openAgentDrawer({
label: "媒体分类规则",
goal: `协助检查和修改媒体分类 YAML,说明调整后的匹配结果。当前规则:\n${yaml}`,
})
}
>
让智能体协助
</Button>
}
>
{config.error ? (
<p role="alert" className="text-destructive">
{filesErrorText(config.error, "读取分类规则失败")}
</p>
) : null}
<Textarea
aria-label="分类规则 YAML"
className="min-h-80 font-mono text-caption"
value={yaml}
disabled={config.isPending || save.isPending}
onChange={(e) => setDraft(e.target.value)}
/>
<p className="mt-3 text-caption text-muted-foreground">
按书写顺序匹配,首个符合的分类生效。同一规则的多个字段为 AND;字段值用逗号表达 OR,用 ! 前缀排除。空规则(null
或 {"{}"})作为兜底,放在最后。
</p>
<p className="mt-2 text-caption text-muted-foreground">
可用字段:original_language(原始语言)、genre_ids(类型
ID)、production_countries(电影制作国家)、origin_country(剧集来源国家)、release_year(发行年份,支持
2020-2025 范围)。
</p>
<SaveBar dirty={dirty}>
<Button variant="outline" disabled={!dirty || save.isPending} onClick={() => setDraft(null)}>
撤销
</Button>
<Button
disabled={!dirty || !yaml.trim() || save.isPending}
onClick={() =>
save.mutate(yaml, {
onSuccess: () => {
setDraft(null);
toast.success("分类规则已保存");
},
onError: (e) => toast.error(filesErrorText(e, "保存分类规则失败")),
})
}
>
{save.isPending ? "保存中…" : "保存规则"}
</Button>
</SaveBar>
</SettingsBlock>
);
}
function SourceEditor({ initial, onClose }: { initial: OrganizeSourceView | null; onClose: () => void }) {
const [body, setBody] = useState<OrganizeSourceInput>(() =>
initial
? { ...initial }
: {
name: "",
enabled: true,
source: { root: "", path: "" },
target: { root: "", path: "" },
mediaType: "all",
category: null,
action: "copy",
trigger: "manual",
downloaderInstanceId: null,
tag: null,
intervalSec: 300,
},
);
const [picker, setPicker] = useState<"source" | "target" | null>(null);
const [id] = useState(() => initial?.id ?? crypto.randomUUID());
const roots = useFileRoots();
const instances = downloaders.useList();
const classification = useClassification();
const save = useSaveOrganizeSource();
const patch = (p: Partial<OrganizeSourceInput>) => setBody((b) => ({ ...b, ...p }));
const categories = [
...new Set(
body.mediaType === "all"
? [...(classification.data?.categories.movie ?? []), ...(classification.data?.categories.tv ?? [])]
: (classification.data?.categories[body.mediaType] ?? []),
),
];
const refLabel = (ref: FileRef) =>
ref.root
? `${roots.data?.find((r) => r.id === ref.root)?.name ?? ref.root} / ${ref.path || "(根目录)"}`
: "选择目录…";
const valid =
body.name.trim() &&
body.source.root &&
body.target.root &&
(body.trigger !== "qb_tag" || (body.downloaderInstanceId && body.tag?.trim())) &&
body.intervalSec >= 60 &&
body.intervalSec <= 86400;
return (
<>
<Dialog open onOpenChange={(open) => !open && !save.isPending && onClose()}>
<DialogContent className="max-h-[90vh] overflow-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{initial ? "编辑整理来源" : "新增整理来源"}</DialogTitle>
<DialogDescription>
首次保存或更换来源目录先建立当前文件基线,只自动处理后续新增文件。历史文件请使用「整理存量」。
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="名称">
<Input value={body.name} onChange={(e) => patch({ name: e.target.value })} maxLength={100} />
</Field>
<Field label="启用">
<Switch aria-label="启用来源" checked={body.enabled} onCheckedChange={(enabled) => patch({ enabled })} />
</Field>
<Field label="来源目录">
<Button
variant="outline"
className="h-auto w-full justify-start whitespace-normal break-all text-left"
onClick={() => setPicker("source")}
>
{refLabel(body.source)}
</Button>
</Field>
<Field label="媒体库目标目录">
<Button
variant="outline"
className="h-auto w-full justify-start whitespace-normal break-all text-left"
onClick={() => setPicker("target")}
>
{refLabel(body.target)}
</Button>
</Field>
<Field label="媒体类型">
<select
className={selectClass}
value={body.mediaType}
onChange={(e) =>
patch({ mediaType: e.target.value as OrganizeSourceInput["mediaType"], category: null })
}
>
<option value="all">全部</option>
<option value="movie">电影</option>
<option value="tv">剧集 / 综艺</option>
</select>
</Field>
<Field label="分类">
<select
className={selectClass}
value={body.category ?? ""}
onChange={(e) => patch({ category: e.target.value || null })}
>
<option value="">自动匹配分类规则</option>
{body.category && !categories.includes(body.category) ? (
<option value={body.category}>{body.category}(当前配置)</option>
) : null}
{categories.map((c) => (
<option key={c}>{c}</option>
))}
</select>
</Field>
<Field label="整理方式">
<select
className={selectClass}
value={body.action}
onChange={(e) => patch({ action: e.target.value as "copy" | "move" })}
>
<option value="copy">复制,保留源文件</option>
<option value="move">移动,入库后删源</option>
</select>
</Field>
<Field label="触发方式">
<select
className={selectClass}
value={body.trigger}
onChange={(e) => patch({ trigger: e.target.value as OrganizeSourceInput["trigger"] })}
>
<option value="manual">手动</option>
<option value="qb_tag">qBittorrent 标签</option>
<option value="snapshot">目录快照监控</option>
</select>
</Field>
{body.trigger === "qb_tag" ? (
<>
<Field label="qBittorrent 实例">
<select
className={selectClass}
value={body.downloaderInstanceId ?? ""}
onChange={(e) => patch({ downloaderInstanceId: e.target.value || null })}
>
<option value="">选择实例…</option>
{instances.data?.items
.filter((d) => d.kind === "qbittorrent")
.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</select>
</Field>
<Field label="标签">
<Input value={body.tag ?? ""} onChange={(e) => patch({ tag: e.target.value || null })} />
</Field>
</>
) : null}
{body.trigger === "snapshot" ? (
<Field label="扫描间隔(秒,60–86400)">
<Input
type="number"
min={60}
max={86400}
value={body.intervalSec}
onChange={(e) => patch({ intervalSec: Number(e.target.value) })}
/>
</Field>
) : null}
</div>
<p className="text-caption text-muted-foreground">
{body.category
? "固定分类:所选目标已是分类目录,不再追加分类层级。"
: "自动分类:在目标目录下追加规则匹配的分类目录。"}{" "}
同一个分类可配置多个不同来源。
</p>
<div className="flex justify-end gap-2">
<Button variant="outline" disabled={save.isPending} onClick={onClose}>
取消
</Button>
<Button
disabled={!valid || save.isPending}
onClick={() => {
const {
name,
enabled,
source,
target,
mediaType,
category,
action,
trigger,
downloaderInstanceId,
tag,
intervalSec,
} = body;
save.mutate(
{
id,
body: {
name,
enabled,
source,
target,
mediaType,
category,
action,
trigger,
downloaderInstanceId: trigger === "qb_tag" ? downloaderInstanceId : null,
tag: trigger === "qb_tag" ? tag : null,
intervalSec,
},
},
{
onSuccess: () => {
toast.success("来源已保存,当前存量不会自动整理");
onClose();
},
onError: (e) => toast.error(filesErrorText(e, "保存来源失败")),
},
);
}}
>
{save.isPending ? "保存并建立基线…" : "保存来源"}
</Button>
</div>
</DialogContent>
</Dialog>
{picker ? (
<DirectoryDialog
mode={picker}
value={body[picker]}
onClose={() => setPicker(null)}
onSelect={(ref) => {
patch({ [picker]: ref });
setPicker(null);
}}
/>
) : null}
</>
);
}
export function OrganizeSourcesSection() {
const roots = useFileRoots();
const pathLabel = (ref: FileRef) =>
`${roots.data?.find((r) => r.id === ref.root)?.name ?? "登记目录"} / ${ref.path || "(根目录)"}`;
const sources = useOrganizeSources();
const paged = useListPagination(sources.data?.items, { resetKey: "organize-sources" });
const save = useSaveOrganizeSource();
const remove = useRemoveOrganizeSource();
const stock = useOrganizeStock();
const [editor, setEditor] = useState<OrganizeSourceView | null | undefined>(undefined);
const [wizard, setWizard] = useState<OrganizeSource | null>(null);
const [deleting, setDeleting] = useState<OrganizeSourceView | null>(null);
return (
<SettingsBlock
section="library"
anchor="sources"
title="整理来源"
description="每条配置绑定一个来源与一个媒体库目标。新增来源建立基线,历史存量由你预览后执行。"
actions={
<Button size="sm" onClick={() => setEditor(null)}>
新增来源
</Button>
}
>
<div className="space-y-3">
{sources.isPending ? (
<p>读取来源中…</p>
) : sources.error ? (
<p role="alert" className="text-destructive">
{filesErrorText(sources.error, "读取来源失败")}
</p>
) : !sources.data?.items.length ? (
<p className="text-caption text-muted-foreground">尚未配置整理来源。</p>
) : (
paged.items?.map((p) => (
<div key={p.id} className="space-y-2 rounded-xl border border-border p-3">
<div className="flex flex-wrap items-center gap-2">
<strong className="mr-auto">{p.name}</strong>
<Switch
aria-label={`启用 ${p.name}`}
checked={p.enabled}
disabled={save.isPending}
onCheckedChange={(enabled) => {
const { id, baselineAt: _, lastScanAt: _s, lastError: _e, fileCount: _c, ...body } = p;
save.mutate(
{ id, body: { ...body, enabled } },
{ onError: (e) => toast.error(filesErrorText(e, "切换启用失败")) },
);
}}
/>
<Button size="sm" variant="outline" onClick={() => setEditor(p)}>
编辑
</Button>
<Button
size="sm"
variant="outline"
disabled={stock.isPending}
onClick={() =>
stock.mutate(
{ id: p.id },
{
onSuccess: (r) =>
r.items.length
? setWizard(stockWizardSource(p, r))
: toast.info(
`没有可整理文件;跳过 ${r.skipped},不可读取 ${r.unavailable}${r.truncated ? ",扫描达到上限" : ""}`,
),
onError: (e) => toast.error(filesErrorText(e, "扫描存量失败")),
},
)
}
>
整理存量
</Button>
<Button size="sm" variant="ghost" onClick={() => setDeleting(p)}>
删除
</Button>
</div>
<p className="break-all font-mono text-meta">
{pathLabel(p.source)} → {pathLabel(p.target)}
</p>
<p className="text-caption text-muted-foreground">
{p.action === "copy" ? "复制" : "移动"} ·{" "}
{p.mediaType === "movie" ? "电影" : p.mediaType === "tv" ? "剧集 / 综艺" : "全部类型"} ·{" "}
{p.category ?? "自动分类"} ·{" "}
{p.trigger === "snapshot"
? `目录快照 · 每 ${p.intervalSec} 秒`
: p.trigger === "qb_tag"
? `qBittorrent 标签 · ${p.tag}`
: "手动"}
</p>
<p className="text-meta text-muted-foreground">
基线:{p.baselineAt ? formatDateTime(p.baselineAt) : "尚未建立"} · 最近扫描:
{p.lastScanAt ? formatDateTime(p.lastScanAt) : "尚未扫描"} · 快照 {p.fileCount} 个文件
</p>
{p.lastError ? <p className="text-caption text-destructive">{p.lastError}</p> : null}
</div>
))
)}
</div>
{sources.data?.items.length ? <Pagination {...paged.pagination} /> : null}
{editor !== undefined ? <SourceEditor initial={editor} onClose={() => setEditor(undefined)} /> : null}
{wizard ? <OrganizeWizard source={wizard} onClose={() => setWizard(null)} /> : null}
{deleting ? (
<Dialog open onOpenChange={(o) => !o && setDeleting(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>删除来源「{deleting.name}」?</DialogTitle>
<DialogDescription>删除配置及监控快照,文件仍保留在原目录。</DialogDescription>
</DialogHeader>
<Button
variant="outline"
disabled={remove.isPending}
onClick={() =>
remove.mutate(deleting.id, {
onSuccess: () => setDeleting(null),
onError: (e) => toast.error(filesErrorText(e, "删除来源失败")),
})
}
>
确认删除来源
</Button>
</DialogContent>
</Dialog>
) : null}
</SettingsBlock>
);
}
@@ -7,7 +7,9 @@ import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { DetailSection } from "~/components/console/detail-sheet.tsx";
import { zodFieldErrors } from "~/components/console/form-field.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { useListPagination } from "~/components/console/use-list-pagination.ts";
import { Button } from "~/components/ui/button.tsx";
import { Pagination } from "~/components/ui/pagination.tsx";
import { errorMessage } from "~/lib/api.ts";
import { healthLabel } from "~/lib/labels.ts";
import { FieldControl, type FormValues, num, str } from "./resource-section.tsx";
@@ -37,6 +39,7 @@ export function PathMappingsPanel({
const rows = (list.data?.items ?? []).filter((m) =>
viewKind === "downloader" ? m.downloaderInstanceId === instanceId : m.mediaServerInstanceId === instanceId,
);
const paged = useListPagination(rows, { resetKey: JSON.stringify({ viewKind, instanceId }) });
const rootOpts = [
{ value: "none", label: "不关联" },
...(roots.data?.items ?? []).map((d) => ({ value: d.id, label: d.name })),
@@ -96,7 +99,7 @@ export function PathMappingsPanel({
<p className="text-caption text-muted-foreground">还没有映射,路径两边一致时不用加。</p>
) : (
<ul className="divide-y divide-border/60 rounded-lg border border-border/60">
{rows.map((m) => {
{paged.items?.map((m) => {
const h = healthLabel(m.verifyStatus);
return (
<li key={m.id} className="flex items-center gap-2 px-3 py-2">
@@ -140,6 +143,7 @@ export function PathMappingsPanel({
})}
</ul>
)}
{rows.length > 0 ? <Pagination {...paged.pagination} /> : null}
{adding ? (
<form
onSubmit={submit}
+35 -35
View File
@@ -12,7 +12,7 @@ import {
Trash2,
} from "lucide-react";
import { type ReactNode, useState } from "react";
import { Link } from "react-router";
import { Link, useSearchParams } from "react-router";
import { toast } from "sonner";
import { useFeeds } from "~/api/feeds.ts";
import { snapshotStale, useSnapshotSite, useToggleSite, useUpdateSite } from "~/api/sites.ts";
@@ -218,6 +218,32 @@ function Stat({ label, children, className }: { label: string; children: ReactNo
);
}
/** 卡片头:名称、域名(外链)、启停;两种卡片共用,保证下方内容同一基线 */
function SiteCardHeader({ site }: { site: SiteListItem }) {
return (
<header className="flex items-start justify-between gap-3">
<div className="min-w-0">
<Link
to={consolePath("sites", site.id)}
className="block truncate text-lead font-semibold text-foreground hover:underline"
>
{site.name}
</Link>
<a
href={site.baseUrl}
target="_blank"
rel="noreferrer noopener"
className="flex max-w-full items-center gap-1 text-meta text-muted-foreground hover:text-foreground"
>
<span className="truncate">{hostOf(site.baseUrl)}</span>
<ExternalLink className="h-3 w-3 shrink-0" />
</a>
</div>
<EnableSwitch site={site} withLabel />
</header>
);
}
/** 没有登录的站点(公开 BT、自定义 RSS 源):卡片只看 RSS */
function useSiteFeeds(siteId: string) {
// 列表页所有卡片共用一次 /feed-sources 请求
@@ -237,18 +263,7 @@ function FeedSiteCard({ site, onDialog }: Omit<Props, "checks">) {
const f = useSiteFeeds(site.id);
return (
<article className={cn("pg-slab flex min-w-0 flex-col rounded-xl p-4", !site.enabled && "opacity-75")}>
<header className="flex items-start justify-between gap-3">
<div className="min-w-0">
<Link
to={consolePath("sites", site.id)}
className="block truncate text-lead font-semibold text-foreground hover:underline"
>
{site.name}
</Link>
<p className="truncate text-meta text-muted-foreground">{hostOf(site.baseUrl)}</p>
</div>
<EnableSwitch site={site} withLabel />
</header>
<SiteCardHeader site={site} />
<div className="mt-3 flex flex-wrap items-center gap-1.5">
<StatusBadge>{kindLabel(site.kind)}</StatusBadge>
{f.failing ? <StatusBadge tone="destructive">{f.failing} 个 RSS 抓取失败</StatusBadge> : null}
@@ -269,8 +284,9 @@ function FeedSiteCard({ site, onDialog }: Omit<Props, "checks">) {
<dd className="text-foreground">{relTime(f.lastOk)}</dd>
</div>
</dl>
<div aria-hidden className="min-h-4 flex-1" />
<footer className="mt-auto flex flex-wrap items-center justify-end gap-2 border-t border-border/60 pt-3">
<Button asChild variant="outline" size="sm" className="mr-auto">
<Button asChild variant="outline" size="sm">
<Link to={`${consolePath("sites", site.id)}?tab=rss`}>{f.loaded && !f.count ? "添加 RSS" : "管理 RSS"}</Link>
</Button>
<SiteMoreMenu site={site} onDialog={onDialog} />
@@ -297,26 +313,7 @@ function AccountSiteCard({ site, checks, onDialog }: Props) {
return (
<article className={cn("pg-slab flex min-w-0 flex-col rounded-xl p-4", !site.enabled && "opacity-75")}>
<header className="flex items-start justify-between gap-3">
<div className="min-w-0">
<Link
to={consolePath("sites", site.id)}
className="block truncate text-lead font-semibold text-foreground hover:underline"
>
{site.name}
</Link>
<a
href={site.baseUrl}
target="_blank"
rel="noreferrer noopener"
className="inline-flex max-w-full items-center gap-1 truncate text-meta text-muted-foreground hover:text-foreground"
>
<span className="truncate">{hostOf(site.baseUrl)}</span>
<ExternalLink className="h-3 w-3 shrink-0" />
</a>
</div>
<EnableSwitch site={site} withLabel />
</header>
<SiteCardHeader site={site} />
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{site.kind && site.kind !== "pt" ? <StatusBadge>{kindLabel(site.kind)}</StatusBadge> : null}
@@ -397,7 +394,8 @@ function AccountSiteCard({ site, checks, onDialog }: Props) {
</div>
</dl>
<footer className="mt-4 flex flex-wrap items-center justify-end gap-2 border-t border-border/60 pt-3">
<div aria-hidden className="min-h-4 flex-1" />
<footer className="mt-auto flex flex-wrap items-center justify-end gap-2 border-t border-border/60 pt-3">
{siteCan(site, "search") ? <SearchDefaultSwitch site={site} /> : <span className="mr-auto" />}
{acc ? (
<CheckButtons site={site} checks={checks} />
@@ -441,9 +439,11 @@ export function SiteTable({
checks: Checks;
onDialog: (d: SiteDialog) => void;
}) {
const [params] = useSearchParams();
return (
<DataTable
rows={sites}
paginationKey={params.toString()}
rowKey={(r) => r.id}
columns={[
{
@@ -220,6 +220,7 @@ export function TaskStream({
</FilterBar>
<DataTable<EngineRun>
paginationKey={JSON.stringify({ tab, filters })}
rows={rows}
rowKey={(r) => r.id}
loading={list.isPending}
@@ -239,8 +240,7 @@ export function TaskStream({
: tab === "failed"
? "没有失败的任务"
: "还没有任务",
description:
filters.source && list.hasNextPage ? "按来源筛选时一页可能较少,可加载更多继续查找。" : undefined,
description: filters.source && list.hasNextPage ? "按来源筛选时一页可能较少,可翻页继续查找。" : undefined,
}}
columns={[
{
+119 -139
View File
@@ -1,154 +1,134 @@
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
import { PAGE_SIZES } from "~/components/console/use-list-pagination.ts";
import { Button } from "~/components/ui/button.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { cn } from "~/lib/utils.ts";
const PAGE_SIZE_OPTIONS = [10, 15, 20, 50, 100];
type PaginationProps = {
export type PaginationProps = {
page: number;
pageSize: number;
/** With hasMore, total is the loaded count, not a complete total. */
total: number;
hasMore?: boolean;
busy?: boolean;
onPageChange: (page: number) => void;
onPageSizeChange?: (size: number) => void;
className?: string;
};
export function Pagination({ page, pageSize, total, onPageChange, onPageSizeChange, className }: PaginationProps) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const from = total === 0 ? 0 : (page - 1) * pageSize + 1;
export function pageNumbers(page: number, pages: number): (number | "…")[] {
const values = new Set([1, pages, page - 1, page, page + 1]);
const valid = [...values].filter((n) => n >= 1 && n <= pages).sort((a, b) => a - b);
return valid.flatMap((n, i): (number | "…")[] => (i && n - valid[i - 1] > 1 ? ["…", n] : [n]));
}
/** Shared pagination for tables, cards and cursor-backed record lists. */
export function Pagination({
page,
pageSize,
total,
hasMore = false,
busy = false,
onPageChange,
onPageSizeChange,
className,
}: PaginationProps) {
const loadedPages = Math.max(1, Math.ceil(total / pageSize));
const pages = loadedPages + (hasMore ? 1 : 0);
const from = total ? (page - 1) * pageSize + 1 : 0;
const to = Math.min(page * pageSize, total);
const isFirst = page <= 1;
const isLast = page >= totalPages;
// 生成页码列表(最多显示 7 个按钮)
function buildPages(): (number | "...")[] {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
if (page <= 4) {
return [1, 2, 3, 4, 5, "...", totalPages];
}
if (page >= totalPages - 3) {
return [1, "...", totalPages - 4, totalPages - 3, totalPages - 2, totalPages - 1, totalPages];
}
return [1, "...", page - 1, page, page + 1, "...", totalPages];
}
const pages = buildPages();
return (
<div className={cn("flex flex-wrap items-center justify-between gap-3", className)}>
{/* 左侧:总数 + 每页显示 */}
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground whitespace-nowrap">
共 <span className="font-medium text-foreground tabular-nums">{total}</span> 条 · 第{" "}
<span className="font-medium text-foreground tabular-nums">{from}</span>
{" 至 "}
<span className="font-medium text-foreground tabular-nums">{to}</span> 条
</span>
{/* 每页条数 */}
{onPageSizeChange && (
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground/60 whitespace-nowrap">每页</span>
<select
value={pageSize}
onChange={(e) => {
onPageSizeChange(Number(e.target.value));
onPageChange(1); // 切换页大小时回到第 1 页
}}
className="h-7 rounded-lg border border-foreground/10 bg-foreground/5 px-2 text-xs text-foreground outline-none transition-colors focus:border-primary/50 cursor-pointer appearance-none pr-6"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E")`,
backgroundRepeat: "no-repeat",
backgroundPosition: "right 6px center",
}}
>
{PAGE_SIZE_OPTIONS.map((s) => (
<option key={s} value={s} className="bg-card text-foreground">
{s} 条
</option>
))}
</select>
</div>
)}
</div>
{/* 右侧:页码导航 */}
<div className="flex items-center gap-0.5">
{/* 首页 */}
<NavBtn onClick={() => onPageChange(1)} disabled={isFirst} title="第一页">
<ChevronsLeft className="h-3.5 w-3.5" />
</NavBtn>
{/* 上一页 */}
<NavBtn onClick={() => onPageChange(page - 1)} disabled={isFirst} title="上一页">
<ChevronLeft className="h-3.5 w-3.5" />
</NavBtn>
{/* 页码按钮 */}
<div className="flex items-center gap-0.5 mx-1">
{pages.map((p, i) =>
p === "..." ? (
<span
key={`ellipsis-${i}`}
className="flex h-7 w-6 items-center justify-center text-xs text-muted-foreground/40 select-none"
>
···
</span>
) : (
<button
key={p}
type="button"
onClick={() => onPageChange(p as number)}
className={cn(
"flex h-7 min-w-[28px] px-1.5 items-center justify-center rounded-full text-xs font-medium transition-all",
p === page ? "pill-selected" : "text-muted-foreground hover:bg-foreground/8 hover:text-foreground",
)}
>
{p}
</button>
),
)}
</div>
{/* 下一页 */}
<NavBtn onClick={() => onPageChange(page + 1)} disabled={isLast} title="下一页">
<ChevronRight className="h-3.5 w-3.5" />
</NavBtn>
{/* 末页 */}
<NavBtn onClick={() => onPageChange(totalPages)} disabled={isLast} title="最后一页">
<ChevronsRight className="h-3.5 w-3.5" />
</NavBtn>
</div>
</div>
);
}
function NavBtn({
children,
onClick,
disabled,
title,
}: {
children: React.ReactNode;
onClick: () => void;
disabled: boolean;
title?: string;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={title}
className={cn(
"flex h-7 w-7 items-center justify-center rounded-lg text-muted-foreground transition-all",
disabled ? "opacity-25 cursor-not-allowed" : "hover:bg-foreground/8 hover:text-foreground",
)}
<nav
aria-label="列表分页"
aria-busy={busy}
className={cn("flex flex-wrap items-center justify-between gap-3 border-t border-border/60 px-4 py-3", className)}
>
{children}
</button>
<div className="flex flex-wrap items-center gap-3 text-meta text-muted-foreground">
<span aria-live="polite" className="tabular-nums">
{hasMore ? `已加载 ${total} 条` : `共 ${total} 条`} · {from}–{to} 条
</span>
{onPageSizeChange ? (
<div className="flex items-center gap-2">
<span>每页</span>
<Select value={String(pageSize)} disabled={busy} onValueChange={(value) => onPageSizeChange(Number(value))}>
<SelectTrigger size="sm" aria-label="每页条数" className="min-w-24 text-caption text-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" align="start" collisionPadding={16}>
{PAGE_SIZES.map((size) => (
<SelectItem key={size} value={String(size)}>
{size} 条
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
</div>
<div className="flex max-w-full flex-wrap items-center gap-1">
<Button
type="button"
size="sm"
variant="ghost"
aria-label="第一页"
disabled={busy || page <= 1}
onClick={() => onPageChange(1)}
>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
type="button"
size="sm"
variant="outline"
aria-label="上一页"
disabled={busy || page <= 1}
onClick={() => onPageChange(page - 1)}
>
<ChevronLeft className="h-4 w-4" />
<span className="hidden sm:inline">上一页</span>
</Button>
{pageNumbers(page, pages).map((n, i) =>
n === "…" ? (
<span key={`gap-${i}`} aria-hidden className="px-1 text-muted-foreground">
…
</span>
) : (
<Button
key={n}
type="button"
size="sm"
variant={n === page ? "secondary" : "ghost"}
aria-label={`第 ${n} 页`}
aria-current={n === page ? "page" : undefined}
disabled={busy}
onClick={() => onPageChange(n)}
>
{n}
</Button>
),
)}
<Button
type="button"
size="sm"
variant="outline"
aria-label="下一页"
disabled={busy || page >= pages}
onClick={() => onPageChange(page + 1)}
>
<span className="hidden sm:inline">下一页</span>
<ChevronRight className="h-4 w-4" />
</Button>
{!hasMore ? (
<Button
type="button"
size="sm"
variant="ghost"
aria-label="最后一页"
disabled={busy || page >= pages}
onClick={() => onPageChange(pages)}
>
<ChevronsRight className="h-4 w-4" />
</Button>
) : null}
</div>
</nav>
);
}
@@ -1,156 +1,36 @@
import type { AcquisitionSlot, ManualAcquisitionResult, WorkbenchRelease } from "@umt/contracts";
import { Bot, Download } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import type { AcquisitionSlot, WorkbenchRelease } from "@umt/contracts";
import { useSubmitRelease } from "~/api/releases.ts";
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
import { FilterSelect } from "~/components/console/filter-bar.tsx";
import { Button } from "~/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatBytes } from "~/lib/utils.ts";
import { ManualDownloadDialog } from "~/components/downloads/manual-download-dialog.tsx";
const STEPS = [
"预留收录版本和预算;每个收录版本同时只有一个在途的下载",
"已有相同资源或相同 hash 的任务时直接接管,不重复提交",
"缓存卷超过硬阈值或读不到磁盘状态时无法提交",
"提交前复核免费、做种等易变字段",
];
/**
* 统一下载入口:与自动收录走同一提交流程,不在前端直接调下载器。
*/
/** 工作台使用独立手动下载,不要求先建立订阅或收录版本。 */
export function DownloadDialog({
release,
slots,
open,
onOpenChange,
}: {
release: WorkbenchRelease;
slots: AcquisitionSlot[];
open: boolean;
onOpenChange: (o: boolean) => void;
onOpenChange: (open: boolean) => void;
}) {
const submit = useSubmitRelease();
const [slotId, setSlotId] = useState("");
const [rejected, setRejected] = useState<ManualAcquisitionResult["rejected"] | null>(null);
const [done, setDone] = useState<string | null>(null);
const slotName = (id: string | null) => slots.find((s) => s.id === id)?.name ?? null;
const go = () =>
submit.mutate(
{
sourceReleaseId: release.id,
...(slotId ? { slotId } : release.entity ? { entityId: release.entity.id } : {}),
},
{
onSuccess: (r) => {
if (r.submitted && r.plan) {
setRejected(null);
setDone(r.plan.id);
toast.success(`已提交${slotName(r.slotId) ? `(${slotName(r.slotId)})` : ""},进度见「下载面板」`);
} else {
setRejected(r.rejected);
toast.warning("没有下载");
}
},
onError: (e) => toast.error(errorMessage(e)),
},
);
return (
<Dialog
<ManualDownloadDialog
key={release.id}
open={open}
onOpenChange={(o) => {
if (!o) {
setRejected(null);
setDone(null);
}
onOpenChange(o);
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>下载这个资源</DialogTitle>
<DialogDescription className="break-all">
{release.title}
{release.sizeBytes !== null ? ` · ${formatBytes(release.sizeBytes, 1)}` : ""}
</DialogDescription>
</DialogHeader>
<ol className="list-decimal space-y-1 pl-5 text-caption text-muted-foreground">
{STEPS.map((s) => (
<li key={s}>{s}</li>
))}
</ol>
{slots.length ? (
<div className="flex flex-wrap items-center gap-2">
<FilterSelect
label="收录版本"
value={slotId}
onChange={setSlotId}
allLabel="自动匹配"
options={slots.map((s) => ({ value: s.id, label: `${s.name}(${s.status})` }))}
/>
</div>
) : (
<p className="text-meta text-muted-foreground">
{release.entity
? "这个作品没有收录版本。先在订阅里新建。"
: "资源未关联本地作品。先在「身份与元数据」指定。"}
</p>
)}
{rejected ? (
<div className="rounded-xl border border-warning/40 bg-warning/10 px-3 py-2.5 text-caption text-warning">
<p className="mb-1">没有下载:</p>
<ul className="list-disc space-y-0.5 pl-5">
{rejected.map((r, i) => (
<li key={`${r.slotId}-${r.code}-${i}`} className="break-words">
{slotName(r.slotId) ? `${slotName(r.slotId)}:` : ""}
{r.message}
</li>
))}
</ul>
</div>
) : null}
{done ? (
<p className="text-caption text-success">
已加入下载。
<Link to={`/console/downloads?plan=${done}`} className="underline underline-offset-4">
查看
</Link>
</p>
) : null}
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => {
onOpenChange(false);
openAgentDrawer({
label: `资源:${release.title}`,
releaseId: release.id,
entityId: release.entity?.id,
siteId: release.siteId,
goal: "判断这个资源是否值得下载;合适就按正常流程提交",
});
}}
>
<Bot />
委托智能体
</Button>
<Button onClick={go} disabled={submit.isPending || !!done || !release.entity}>
<Download />
{submit.isPending ? "提交中…" : "提交下载"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
onOpenChange={onOpenChange}
resource={{ title: release.title, sizeBytes: release.sizeBytes, entityId: release.entity?.id }}
onDelegate={() =>
openAgentDrawer({
label: `资源:${release.title}`,
releaseId: release.id,
entityId: release.entity?.id,
siteId: release.siteId,
goal: "判断这个资源是否值得下载;合适就按手动下载流程提交",
})
}
submit={(options) => submit.mutateAsync({ ...options, sourceReleaseId: release.id })}
/>
);
}
@@ -120,25 +120,30 @@ function LocalInventory({ release, entry }: { release: WorkbenchRelease; entry:
export function ReleaseDetailSheet({
siteId,
release,
releaseId,
title,
entry,
onClose,
}: {
siteId: string;
release: WorkbenchRelease | null;
/** 搜索结果已有资源 ID,按同一工作台接口读取完整详情。 */
releaseId?: string;
title?: string;
entry: InventoryEntry | undefined;
onClose: () => void;
}) {
const detail = useReleaseDetail(siteId, release?.id);
const rid = release?.id ?? releaseId;
const detail = useReleaseDetail(siteId, rid);
const refresh = useRefreshReleaseDetail();
const entity = useEntityDetail(release?.entity?.id);
const [dl, setDl] = useState(false);
const techOpen = useUiPrefs().techInfoExpanded;
const r = detail.data?.release ?? release;
const entity = useEntityDetail(r?.entity?.id);
// 按需抓详情页:打开抽屉时没有缓存的详情页扩展内容就抓一次(服务端缓存 24 小时,同一资源本次会话只自动抓一次)
const autoTried = useRef(new Set<string>());
const needPage = !!detail.data && !detail.data.page && detail.data.detailSupported;
const rid = release?.id;
useEffect(() => {
if (!needPage || !rid || autoTried.current.has(rid) || refresh.isPending) return;
autoTried.current.add(rid);
@@ -160,10 +165,10 @@ export function ReleaseDetailSheet({
return (
<DetailSheet
open={!!release}
open={!!rid}
onOpenChange={(o) => !o && onClose()}
width="lg"
title={r?.title ?? ""}
title={r?.title ?? title ?? "资源详情"}
description={r?.subtitle ?? undefined}
footer={
r ? (
@@ -194,7 +199,13 @@ export function ReleaseDetailSheet({
) : null
}
>
{!r ? null : (
{!r ? (
detail.error ? (
<ErrorState plain error={detail.error} onRetry={() => void detail.refetch()} />
) : (
<LoadingState rows={3} />
)
) : (
<>
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-caption text-muted-foreground">
+1
View File
@@ -184,6 +184,7 @@ export default function AuditPage() {
) : null}
</FilterBar>
<DataTable<AuditEvent>
paginationKey={JSON.stringify({ actorType, action, targetType, targetId })}
rows={rows}
rowKey={(r) => r.id}
loading={q.isPending}
@@ -102,6 +102,7 @@ export default function DownloadsPage() {
<TransferLine transfer={first?.transfer} />
</div>
<DataTable
paginationKey={state}
columns={columns}
rows={rows}
rowKey={(t) => t.id}
+14 -2
View File
@@ -54,6 +54,7 @@ import {
} from "~/api/files.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { useListPagination } from "~/components/console/use-list-pagination.ts";
import { ContextMenu, type MenuAnchor, type MenuItem } from "~/components/files/context-menu.tsx";
import {
CopyPathDialog,
@@ -91,6 +92,7 @@ import type { WizardEntity } from "~/components/organize-wizard/model.ts";
import { type OrganizeSource, OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Pagination } from "~/components/ui/pagination.tsx";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import { useSse } from "~/lib/sse.ts";
@@ -113,6 +115,7 @@ type Dialog =
| { kind: "refresh"; items: FileEntry[] }
| { kind: "scrape"; items: FileEntry[] }
| { kind: "wizard"; source: OrganizeSource }
| { kind: "wizardSource"; source: Extract<OrganizeSource, { kind: "files" }> }
| { kind: "shortcuts" };
/** 宽布局:内容区不受全站最大宽度限制,目录树贴着侧边栏(layout.tsx 读 handle.layout) */
@@ -325,6 +328,13 @@ export default function FilesPage() {
const list = useFileList(listQ);
const pages = useMemo(() => list.data?.pages ?? [], [list.data]);
const entries = useMemo(() => pages.flatMap((p) => p.entries), [pages]);
const paged = useListPagination(entries, {
hasMore: list.hasNextPage,
onLoadMore: () => list.fetchNextPage(),
loadingMore: list.isFetchingNextPage,
error: list.error,
resetKey: JSON.stringify({ rootId, path, q, sort, order, filter, hidden }),
});
const first = pages[0];
const keyOf = useCallback((e: FileEntry) => refKey({ root: rootId, path: e.path }), [rootId]);
@@ -668,8 +678,9 @@ export default function FilesPage() {
} else {
body = (
<FileList
key={`${rootId}|${path}|${paged.pagination.page}|${paged.pagination.pageSize}`}
gridRef={gridRef}
entries={entries}
entries={paged.items ?? []}
keyOf={keyOf}
selection={sel}
onSelection={setSel}
@@ -682,7 +693,7 @@ export default function FilesPage() {
opState={(e, op) => (root ? opDisabledReason(op, e, root) : null)}
onDragRows={onDragRows}
onDropToDir={(dir, ev) => onDropTo({ root: rootId, path: dir.path }, ev)}
hasMore={!!list.hasNextPage}
hasMore={false}
onLoadMore={() => void list.fetchNextPage()}
loadingMore={list.isFetchingNextPage}
mobile={mobile}
@@ -1093,6 +1104,7 @@ export default function FilesPage() {
) : null}
<div className="relative flex min-h-0 flex-1 flex-col">{body}</div>
{!list.isPending && root ? <Pagination {...paged.pagination} /> : null}
{/* 状态栏 */}
{root && !offline && root.health !== "offline" && !mobile ? (
+9 -10
View File
@@ -25,6 +25,7 @@ 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 { HomeRuntime } from "~/components/dashboard/home-runtime.tsx";
import { runTitle } from "~/components/tasks/labels.ts";
import { useLiveTasks } from "~/components/tasks/task-stream.tsx";
import { Button } from "~/components/ui/button.tsx";
@@ -34,8 +35,7 @@ import { qk } from "~/lib/query-keys.ts";
import { useSse } from "~/lib/sse.ts";
import { cn } from "~/lib/utils.ts";
// 首页只回答三件事(DEAI-2):在忙什么、要我处理什么、最近入库了什么;顶部一行一句话状态。
// 运行状态、计划、缓存参数、收录版本满足率不在这里,只在出问题时写进状态行。
// 首页概览:自动化、存储与下载器状态,以及当前工作、待处理事项和最近入库。
/** 首页关心的事件:任意一个到达就(节流)刷新聚合 */
const DASHBOARD_EVENTS = [
@@ -335,6 +335,8 @@ export default function Home() {
) : null
}
/>
{q.data ? <StatusLine d={q.data} /> : null}
<HomeRuntime downloaders={q.data?.sources.downloaders} />
{q.isPending ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{["a", "b", "c"].map((k) => (
@@ -346,14 +348,11 @@ export default function Home() {
) : q.isError ? (
<ErrorState title="首页加载失败" error={q.error} onRetry={() => q.refetch()} />
) : (
<>
<StatusLine d={q.data} />
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<Busy w={q.data.currentWork} />
<NeedsMe n={q.data.needsAttention} />
<RecentImports d={q.data} />
</div>
</>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<Busy w={q.data.currentWork} />
<NeedsMe n={q.data.needsAttention} />
<RecentImports d={q.data} />
</div>
)}
</>
);
+13 -14
View File
@@ -14,6 +14,7 @@ import { consolePath } from "~/components/console/nav.ts";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { TechInfo } from "~/components/console/tech-info.tsx";
import { useListPagination } from "~/components/console/use-list-pagination.ts";
import { EntityPicker, type PickedEntity } from "~/components/library/identity-entity-picker.tsx";
import { DECISION_KIND, DECISION_STATUS, opts, SEVERITY } from "~/components/library/labels.ts";
import { Chip, relTime, Tone } from "~/components/library/parts.tsx";
@@ -21,6 +22,7 @@ import { type AssignedWork, WorkAssignDialog } from "~/components/library/work-a
import { OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Pagination } from "~/components/ui/pagination.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts";
@@ -444,6 +446,14 @@ export default function InboxPage() {
const list = useDecisions({ status: status || undefined, kind: kind || undefined, minSeverity });
const rows = list.data?.pages.flatMap((p) => p.items);
const paged = useListPagination(rows, {
hasMore: list.hasNextPage,
onLoadMore: () => list.fetchNextPage(),
loadingMore: list.isFetchingNextPage,
error: list.error,
resetKey: JSON.stringify({ status, kind, minSeverity }),
});
// SSE:decision.created / decision.updated → 刷新(合并 500ms 内的多条事件)
const qc = useQueryClient();
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -478,7 +488,7 @@ export default function InboxPage() {
// 按问题类型 + 作品 / 来源合并;组内按严重度、时间
const groups = useMemo(() => {
const m = new Map<string, Group>();
for (const d of rows ?? []) {
for (const d of paged.items ?? []) {
const entityId = entityOf(d);
const source = entityId ? null : sourceGroupOf(d);
const key = `${entityId ?? (source ? `site:${source.siteId}` : `subject:${d.subjectType ?? "none"}`)}|${d.kind}`;
@@ -488,7 +498,7 @@ export default function InboxPage() {
}
const maxSev = (g: Group) => Math.max(...g.items.map((d) => SEV_RANK[d.severity]));
return [...m.values()].sort((a, b) => maxSev(b) - maxSev(a) || b.items.length - a.items.length);
}, [rows]);
}, [paged.items]);
const isOpenView = !status;
// 折叠状态:缺省前 OPEN_GROUPS 组展开;用户点过的组按用户的来
@@ -614,20 +624,9 @@ export default function InboxPage() {
</section>
);
})}
{list.hasNextPage ? (
<div className="flex justify-center">
<Button
variant="outline"
size="sm"
onClick={() => list.fetchNextPage()}
disabled={list.isFetchingNextPage}
>
{list.isFetchingNextPage ? "加载中…" : "加载更多"}
</Button>
</div>
) : null}
</div>
)}
{rows?.length || list.hasNextPage ? <Pagination {...paged.pagination} /> : null}
</>
);
}
+87 -62
View File
@@ -43,6 +43,7 @@ import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { PaginatedList } from "~/components/console/paginated-list.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { TechInfo } from "~/components/console/tech-info.tsx";
import { DROPPED_STATUS, droppedReason, PURPOSE_LABEL } from "~/components/downloads/plan-format.ts";
@@ -143,8 +144,8 @@ function PathsPanel({ detail }: { detail: LibraryDetail }) {
) : (
<div className="space-y-4">
{bindings.length ? (
<ul className="space-y-2">
{bindings.map((b) => {
<PaginatedList rows={bindings} as="ul" label="目录绑定" resetKey={detail.entity.id} className="space-y-2">
{(b) => {
const root = rootOf(b.libraryRootId);
return (
<li key={b.id} className="rounded-lg border border-border/70 px-3 py-2">
@@ -161,39 +162,41 @@ function PathsPanel({ detail }: { detail: LibraryDetail }) {
</p>
</li>
);
})}
</ul>
}}
</PaginatedList>
) : null}
{detail.assets.map((a) => (
<div key={a.asset.id} className="rounded-lg border border-border/70 px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-foreground">{deliveryLine(a.asset) || "未知规格"}</span>
{a.asset.status !== "active" ? <StatusBadge tone="warning">{a.asset.status}</StatusBadge> : null}
{a.asset.verifiedAt ? null : <StatusBadge>未核验</StatusBadge>}
<PaginatedList rows={detail.assets} label="文件资产" resetKey={detail.entity.id} className="space-y-4">
{(a) => (
<div key={a.asset.id} className="rounded-lg border border-border/70 px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-foreground">{deliveryLine(a.asset) || "未知规格"}</span>
{a.asset.status !== "active" ? <StatusBadge tone="warning">{a.asset.status}</StatusBadge> : null}
{a.asset.verifiedAt ? null : <StatusBadge>未核验</StatusBadge>}
</div>
<ul className="mt-2 space-y-1.5">
{a.locations.map((l) => (
<li key={l.id} className="min-w-0">
<div className="flex flex-wrap items-center gap-1.5">
{l.locationKind !== "library" ? (
<Chip>{LOCATION_KIND[l.locationKind] ?? l.locationKind}</Chip>
) : null}
{l.status !== "available" ? <Tone v={LOCATION_STATUS[l.status]} /> : null}
{l.libraryRootId ? (
<span className="text-meta text-muted-foreground">
{rootOf(l.libraryRootId)?.name ?? "媒体库目录"}
</span>
) : null}
</div>
<p className="mt-0.5 font-mono text-meta break-all text-muted-foreground">
{l.absolutePath ?? l.relativePath}
</p>
</li>
))}
{a.locations.length === 0 ? <li className="text-meta text-muted-foreground">没有登记位置</li> : null}
</ul>
</div>
<ul className="mt-2 space-y-1.5">
{a.locations.map((l) => (
<li key={l.id} className="min-w-0">
<div className="flex flex-wrap items-center gap-1.5">
{l.locationKind !== "library" ? (
<Chip>{LOCATION_KIND[l.locationKind] ?? l.locationKind}</Chip>
) : null}
{l.status !== "available" ? <Tone v={LOCATION_STATUS[l.status]} /> : null}
{l.libraryRootId ? (
<span className="text-meta text-muted-foreground">
{rootOf(l.libraryRootId)?.name ?? "媒体库目录"}
</span>
) : null}
</div>
<p className="mt-0.5 font-mono text-meta break-all text-muted-foreground">
{l.absolutePath ?? l.relativePath}
</p>
</li>
))}
{a.locations.length === 0 ? <li className="text-meta text-muted-foreground">没有登记位置</li> : null}
</ul>
</div>
))}
)}
</PaginatedList>
</div>
)}
</Panel>
@@ -226,11 +229,9 @@ function IdentityPanel({ detail }: { detail: LibraryDetail }) {
className="py-8"
/>
) : (
<ul className="space-y-2">
{list.map((i) => (
<IdentityRow key={i.id} i={i} />
))}
</ul>
<PaginatedList rows={list} as="ul" label="外部身份" resetKey={detail.entity.id} className="space-y-2">
{(i) => <IdentityRow key={i.id} i={i} />}
</PaginatedList>
)}
<IdentityDialog
entityId={detail.entity.id}
@@ -530,8 +531,13 @@ function SlotsPanel({ detail, units }: { detail: LibraryDetail; units: ContentUn
const assetOf = (id: string | null) => detail.assets.find((a) => a.asset.id === id);
return (
<Panel title="收录版本" description="每个目标单独判断硬条件、软偏好和升级">
<div className="grid gap-3 xl:grid-cols-2">
{detail.slots.map((s) => {
<PaginatedList
rows={detail.slots}
label="收录版本"
resetKey={detail.entity.id}
className="grid gap-3 xl:grid-cols-2"
>
{(s) => {
const cur = assetOf(s.currentAssetId);
const unit = units.find((u) => u.id === s.contentUnitId);
return (
@@ -543,8 +549,8 @@ function SlotsPanel({ detail, units }: { detail: LibraryDetail; units: ContentUn
candidates={detail.candidates.filter((c) => c.slotId === s.id).slice(0, 5)}
/>
);
})}
</div>
}}
</PaginatedList>
</Panel>
);
}
@@ -747,8 +753,14 @@ function EpisodesPanel({ detail, units }: { detail: LibraryDetail; units: Conten
}
>
{rows.length > 1 ? (
<fieldset aria-label="按收录版本看状态" className="m-0 mb-3 flex min-w-0 flex-wrap gap-1.5 border-0 p-0">
{rows.map((r, i) => (
<PaginatedList
rows={rows}
as="fieldset"
label="按收录版本看状态"
resetKey={e.id}
className="m-0 mb-3 flex min-w-0 flex-wrap gap-1.5 border-0 p-0"
>
{(r, i) => (
<button
key={r.slotId ?? "any"}
type="button"
@@ -762,8 +774,8 @@ function EpisodesPanel({ detail, units }: { detail: LibraryDetail; units: Conten
>
{r.name}
</button>
))}
</fieldset>
)}
</PaginatedList>
) : null}
{seasonNums.length === 0 ? (
<EmptyState
@@ -774,8 +786,8 @@ function EpisodesPanel({ detail, units }: { detail: LibraryDetail; units: Conten
className="py-8"
/>
) : (
<div className="space-y-2">
{seasonNums.map((n) => {
<PaginatedList rows={seasonNums} label="季列表" resetKey={`${e.id}:${rowIdx}`} className="space-y-2">
{(n) => {
const su = seasonUnitOf(n);
const eps = episodes.filter((u) => u.seasonNumber === n);
const total = su?.episodeTotal ?? (eps.length || null);
@@ -810,8 +822,15 @@ function EpisodesPanel({ detail, units }: { detail: LibraryDetail; units: Conten
{eps.length === 0 ? (
<p className="text-meta text-muted-foreground">这一季还没有集信息。</p>
) : (
<ul className="divide-y divide-border/60">
{eps.map((u) => {
<PaginatedList
rows={eps}
as="ul"
compact
label={`第 ${n} 季集列表`}
resetKey={`${e.id}:${n}:${rowIdx}`}
className="divide-y divide-border/60"
>
{(u) => {
const v = (f: string) => finalOf(u, f)?.value;
const str = (x: unknown) => (typeof x === "string" && x ? x : null);
const num = (x: unknown) => (typeof x === "number" ? x : null);
@@ -843,13 +862,13 @@ function EpisodesPanel({ detail, units }: { detail: LibraryDetail; units: Conten
}}
/>
);
})}
</ul>
}}
</PaginatedList>
)}
</SeasonSection>
);
})}
</div>
}}
</PaginatedList>
)}
<UnplacedVersions versions={versions.data?.unplaced ?? []} />
<UnitFieldDialog entityId={e.id} unit={editing} onOpenChange={(o) => !o && setEditing(null)} />
@@ -949,8 +968,14 @@ function DownloadsPanel({ detail }: { detail: LibraryDetail }) {
{dropped.length ? (
<div className={cn("px-4 py-3 sm:px-5", detail.downloads.length ? "border-t border-border/60" : "")}>
<p className="mb-2 text-meta text-muted-foreground">没提交到下载器</p>
<ul className="space-y-2">
{dropped.map(({ plan, releaseTitle }) => {
<PaginatedList
rows={dropped}
as="ul"
label="未提交下载计划"
resetKey={detail.entity.id}
className="space-y-2"
>
{({ plan, releaseTitle }) => {
const st = DROPPED_STATUS[plan.status as keyof typeof DROPPED_STATUS] ?? DROPPED_STATUS.failed;
const why = droppedReason(plan.statusReason);
return (
@@ -977,15 +1002,15 @@ function DownloadsPanel({ detail }: { detail: LibraryDetail }) {
/>
</li>
);
})}
</ul>
}}
</PaginatedList>
</div>
) : null}
{manual.length ? (
<div className="border-t border-border/60 px-4 py-3 sm:px-5">
<p className="mb-2 text-meta text-muted-foreground">其他整理记录</p>
<ul className="space-y-1.5">
{manual.map((r) => (
<PaginatedList rows={manual} as="ul" label="其他整理记录" resetKey={detail.entity.id} className="space-y-1.5">
{(r) => (
<li key={r.id} className="flex min-w-0 flex-wrap items-center gap-2">
<Tone v={ORG_STATUS[r.status]} />
<Link
@@ -1000,8 +1025,8 @@ function DownloadsPanel({ detail }: { detail: LibraryDetail }) {
{r.fileTotal} 个文件 · {formatDateTime(r.createdAt)}
</span>
</li>
))}
</ul>
)}
</PaginatedList>
</div>
) : null}
</Panel>
+1
View File
@@ -201,6 +201,7 @@ function WorksView() {
<FilterSelect label="状态" value={filter ?? ""} onChange={(v) => set("filter", v)} options={FILTERS} />
</FilterBar>
<DataTable
paginationKey={sp.toString()}
columns={columns}
rows={rows}
rowKey={(r) => r.entity.id}
@@ -8,6 +8,7 @@ import { mediaServers } from "~/api/instances.ts";
import { useMediaServerInventory, useTmdbMedia, useTmdbSeason } from "~/api/search.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { consolePath, settingsPath } from "~/components/console/nav.ts";
import { PaginatedList } from "~/components/console/paginated-list.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { MEDIA_KIND } from "~/components/library/labels.ts";
import { Panel } from "~/components/library/parts.tsx";
@@ -284,8 +285,8 @@ function SeasonsPanel({ d }: { d: TmdbMediaDetail }) {
const seasons = [...d.seasons].sort((a, b) => (a.seasonNumber || 999) - (b.seasonNumber || 999));
return (
<Panel title="季与集" description={d.localEntityId ? "本地一列是已入库集数" : "不在媒体库"}>
<div className="space-y-2">
{seasons.map((s) => (
<PaginatedList rows={seasons} label="季列表" resetKey={d.externalId} className="space-y-2">
{(s) => (
<SeasonSection
key={s.seasonNumber}
s={{
@@ -307,8 +308,8 @@ function SeasonsPanel({ d }: { d: TmdbMediaDetail }) {
>
<SeasonEpisodes tvId={d.externalId} season={s.seasonNumber} />
</SeasonSection>
))}
</div>
)}
</PaginatedList>
</Panel>
);
}
@@ -319,8 +320,15 @@ function SeasonEpisodes({ tvId, season }: { tvId: string; season: number }) {
if (q.isError) return <ErrorState title="季详情加载失败" error={q.error} onRetry={() => q.refetch()} />;
if (!q.data.episodes.length) return <EmptyState plain title="TMDB 还没有这一季的集信息" className="py-6" />;
return (
<ul className="divide-y divide-border/60">
{q.data.episodes.map((e) => (
<PaginatedList
rows={q.data.episodes}
as="ul"
compact
label={`第 ${season} 季集列表`}
resetKey={`${tvId}:${season}`}
className="divide-y divide-border/60"
>
{(e) => (
<EpisodeRow
key={e.episodeNumber}
e={{
@@ -335,7 +343,7 @@ function SeasonEpisodes({ tvId, season }: { tvId: string; season: number }) {
voteAverage: e.voteAverage,
}}
/>
))}
</ul>
)}
</PaginatedList>
);
}
+20 -7
View File
@@ -7,11 +7,13 @@ import { openAgentDrawer } from "~/components/agent/agent-store.ts";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { PageHeader } from "~/components/console/page-header.tsx";
import { PaginatedList } from "~/components/console/paginated-list.tsx";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
import { TechInfo } from "~/components/console/tech-info.tsx";
import { CoverPlaceholder } from "~/components/library/parts.tsx";
import { DeleteRecordsDialog } from "~/components/organize/delete-records-dialog.tsx";
import { canReorganize, isRunning } from "~/components/organize/record-parts.ts";
import { OrganizeScrapeHistory } from "~/components/organize/organize-scrape-history.tsx";
import { canReorganize, isRunning, recordFileProgress } from "~/components/organize/record-parts.ts";
import { OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx";
import { Button } from "~/components/ui/button.tsx";
import { actorTypeLabel } from "~/lib/labels.ts";
@@ -130,7 +132,7 @@ export default function OrganizeRecordPage() {
actions={
<>
<Button asChild variant="ghost" size="sm">
<Link to="/console/organize">
<Link to="/console/organize?tab=records">
<ArrowLeft />
全部记录
</Link>
@@ -199,6 +201,12 @@ export default function OrganizeRecordPage() {
.join(" · ")}
</span>
</div>
{recordFileProgress(r) ? (
<p className="break-all text-caption text-muted-foreground">
已完成 {recordFileProgress(r)?.completed}/{recordFileProgress(r)?.total} 个文件
{recordFileProgress(r)?.current ? ` · 当前文件:${recordFileProgress(r)?.current}` : ""}
</p>
) : null}
{r.reason ? (
<p
className={cn(
@@ -260,17 +268,22 @@ export default function OrganizeRecordPage() {
文件明细({r.files?.length ?? 0})
</h2>
{r.files?.length ? (
<ul className="divide-y divide-border/60 border-t border-border/60">
{r.files.map((f) => (
<FileRow key={f.id} f={f} />
))}
</ul>
<PaginatedList
rows={r.files}
label="整理文件明细"
resetKey={r.id}
as="ul"
className="divide-y divide-border/60 border-t border-border/60"
>
{(f) => <FileRow key={f.id} f={f} />}
</PaginatedList>
) : (
<p className="px-4 pb-4 text-caption text-muted-foreground">
{r.status === "queued" ? "排队中,还没有文件明细。" : "没有文件级结果。"}
</p>
)}
</section>
<OrganizeScrapeHistory recordId={r.id} />
</div>
)}
{r ? (
+201 -128
View File
@@ -30,14 +30,18 @@ import { type DataColumn, DataTable } from "~/components/console/data-table.tsx"
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { useListPagination } from "~/components/console/use-list-pagination.ts";
import { useQueryState } from "~/components/console/use-query-state.ts";
import { Checkbox } from "~/components/files/file-list.tsx";
import { CoverPlaceholder, relTime } from "~/components/library/parts.tsx";
import { DeleteRecordsDialog } from "~/components/organize/delete-records-dialog.tsx";
import { canReorganize, isRunning, recordTitle } from "~/components/organize/record-parts.ts";
import { OrganizeQueue } from "~/components/organize/organize-queue.tsx";
import { canReorganize, isRunning, recordFileProgress, recordTitle } from "~/components/organize/record-parts.ts";
import { SourceSelection } from "~/components/organize/source-selection.tsx";
import { type OrganizeSource, OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
import { reasonLine } from "~/lib/reasons.ts";
import { useSse } from "~/lib/sse.ts";
import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
@@ -65,11 +69,18 @@ const when = (r: Rec) => r.finishedAt ?? r.startedAt ?? r.createdAt;
/** 状态:成功不加徽章,其余徽章 + 一句原因 */
function StatusCell({ r }: { r: Rec }) {
const progress = recordFileProgress(r);
if (r.status === "succeeded" && !r.reason) return <span className="text-muted-foreground">成功</span>;
const line = reasonLine(r.reason);
return (
<div className="flex flex-col items-start gap-0.5">
<StatusBadge tone={RECORD_STATUS_TONE[r.status]}>{RECORD_STATUS_LABEL[r.status]}</StatusBadge>
{progress && isRunning(r) ? (
<span className="max-w-52 text-meta text-muted-foreground">
已完成 {progress.completed}/{progress.total} 个文件
{progress.current ? ` · 当前:${progress.current.split("/").pop()}` : ""}
</span>
) : null}
{line ? (
<span className="line-clamp-2 max-w-52 text-meta text-muted-foreground" title={line}>
{line}
@@ -255,6 +266,17 @@ export default function OrganizePage() {
const { set: setFilter, values: v } = f;
const [qInput, setQInput] = useState(v.q);
const entityId = params.get("mediaEntityId") ?? undefined;
const legacyRecords = !!(entityId || params.get("ids") || v.q || v.trigger || v.kind || v.time || v.status);
const tab = params.get("tab") === "records" || (!params.get("tab") && legacyRecords) ? "records" : "queue";
const setTab = (next: string) =>
setParams(
(previous) => {
const updated = new URLSearchParams(previous);
updated.set("tab", next);
return updated;
},
{ replace: true },
);
// 整理向导执行多组后跳来:?ids=<记录 id,…> 只看这几条(执行中每 3 秒刷新)
const ids = (params.get("ids") ?? "")
.split(",")
@@ -270,6 +292,8 @@ export default function OrganizePage() {
// 时间筛选换成具体时刻只在切换时算一次,避免每次渲染查询键都变
const since = useMemo(() => sinceOf(v.time), [v.time]);
const base: Omit<RecordFilters, "status"> = {
phase: v.status === "running" || v.status === "queued" ? undefined : "history",
organizeOnly: true,
trigger: (v.trigger || undefined) as OrganizationTrigger | undefined,
kind: (v.kind || undefined) as RecordFilters["kind"],
since,
@@ -284,18 +308,35 @@ export default function OrganizePage() {
if (ids.length) void qc.invalidateQueries({ queryKey: ["files", "records"] });
});
const pinned = ids.length > 0;
const rows = pinned ? byIds.data?.items : records.data?.pages.flatMap((p) => p.items);
const allRows = pinned ? byIds.data?.items : records.data?.pages.flatMap((p) => p.items);
const paged = useListPagination(allRows, {
hasMore: !pinned && records.hasNextPage,
onLoadMore: () => records.fetchNextPage(),
loadingMore: records.isFetchingNextPage,
error: pinned ? byIds.error : records.error,
resetKey: JSON.stringify({ ...base, status, ids }),
});
const rows = paged.items;
const filtered = !!(v.status || v.trigger || v.kind || v.time || v.q) || !!entityId || pinned;
const [selecting, setSelecting] = useState(false);
const [sel, setSel] = useState<Set<string>>(new Set());
const [choosingSource, setChoosingSource] = useState(false);
const [wizard, setWizard] = useState<OrganizeSource | null>(null);
const [toDelete, setToDelete] = useState<Rec[] | null>(null);
const grouped = v.group === "entity";
const selRows = (rows ?? []).filter((r) => sel.has(r.id));
const allPicked = !!rows?.length && selRows.length === rows.length;
const pickAll = () => setSel(allPicked ? new Set() : new Set(rows?.map((r) => r.id)));
const selRows = (allRows ?? []).filter((r) => sel.has(r.id));
const allPicked = !!rows?.length && rows.every((r) => sel.has(r.id));
const pickAll = () =>
setSel((prev) => {
const next = new Set(prev);
for (const row of rows ?? []) {
if (allPicked) next.delete(row.id);
else next.add(row.id);
}
return next;
});
const toggle = (id: string) =>
setSel((prev) => {
const next = new Set(prev);
@@ -327,7 +368,14 @@ export default function OrganizePage() {
const pickColumn: DataColumn<Rec> = {
key: "pick",
header: <Checkbox checked={allPicked} partial={selRows.length > 0 && !allPicked} label="全选" onToggle={pickAll} />,
header: (
<Checkbox
checked={allPicked}
partial={!!rows?.some((r) => sel.has(r.id)) && !allPicked}
label="全选本页"
onToggle={pickAll}
/>
),
className: "w-10",
cell: (r) => <Checkbox checked={sel.has(r.id)} label={`选择 ${recordTitle(r)}`} onToggle={() => toggle(r.id)} />,
};
@@ -413,7 +461,7 @@ export default function OrganizePage() {
onOpen={() => navigate(`/console/organize/${r.id}`)}
onReorganize={() => reorganize([r])}
onDelete={() => setToDelete([r])}
onOnlyEntity={() => r.media && setParams({ mediaEntityId: r.media.entityId })}
onOnlyEntity={() => r.media && setParams({ tab: "records", mediaEntityId: r.media.entityId })}
/>
),
},
@@ -425,138 +473,163 @@ export default function OrganizePage() {
return (
<>
<PageHeader
title="整理记录"
title="整理"
actions={
<>
<Button
variant={selecting ? "secondary" : "ghost"}
size="sm"
onClick={() => (selecting ? exitSelect() : setSelecting(true))}
>
{selecting ? <X /> : <SquareCheck />}
{selecting ? "退出批量" : "批量"}
</Button>
<Button size="sm" onClick={() => navigate("/console/files")}>
{tab === "records" ? (
<Button
variant={selecting ? "secondary" : "ghost"}
size="sm"
onClick={() => (selecting ? exitSelect() : setSelecting(true))}
>
{selecting ? <X /> : <SquareCheck />}
{selecting ? "退出批量" : "批量"}
</Button>
) : null}
<Button size="sm" onClick={() => setChoosingSource(true)}>
<FolderInput />
手动整理
</Button>
</>
}
/>
<FilterBar
search={{ value: qInput, onChange: setQInput, placeholder: "作品、路径或 hash,支持 * ?" }}
onReset={
filtered
? () => {
setQInput("");
setParams(v.group ? { group: v.group } : {});
}
: undefined
}
actions={
<Button
variant={grouped ? "secondary" : "ghost"}
size="sm"
aria-pressed={grouped}
onClick={() => setFilter("group", grouped ? "" : "entity")}
<Tabs value={tab} onValueChange={setTab}>
<TabsList aria-label="整理视图">
<TabsTrigger value="queue">整理队列</TabsTrigger>
<TabsTrigger value="records">整理记录</TabsTrigger>
</TabsList>
<TabsContent value="queue">
<OrganizeQueue />
</TabsContent>
<TabsContent value="records">
<FilterBar
search={{ value: qInput, onChange: setQInput, placeholder: "作品、路径或 hash,支持 * ?" }}
onReset={
filtered
? () => {
setQInput("");
setParams(v.group ? { tab: "records", group: v.group } : { tab: "records" });
}
: undefined
}
actions={
<Button
variant={grouped ? "secondary" : "ghost"}
size="sm"
aria-pressed={grouped}
onClick={() => setFilter("group", grouped ? "" : "entity")}
>
<ListTree />
按作品分组
</Button>
}
>
<ListTree />
按作品分组
</Button>
}
>
<FilterSelect
label="状态"
value={v.status}
onChange={(x) => setFilter("status", x)}
options={Object.entries(RECORD_STATUS_LABEL).map(([value, label]) => ({ value, label }))}
<FilterSelect
label="状态"
value={v.status}
onChange={(x) => setFilter("status", x)}
options={Object.entries(RECORD_STATUS_LABEL)
.filter(([value]) => value !== "queued" && value !== "running")
.map(([value, label]) => ({ value, label }))}
/>
<FilterSelect
label="类型"
value={v.kind}
onChange={(x) => setFilter("kind", x)}
options={Object.entries(KIND_LABEL).map(([value, label]) => ({ value, label }))}
/>
<FilterSelect
label="来源"
value={v.trigger}
onChange={(x) => setFilter("trigger", x)}
options={Object.entries(TRIGGER_LABEL).map(([value, label]) => ({ value, label }))}
/>
<FilterSelect
label="时间"
value={v.time}
allLabel="不限"
onChange={(x) => setFilter("time", x)}
options={Object.entries(TIME_LABEL).map(([value, label]) => ({ value, label }))}
/>
{entityId ? (
<Button variant="outline" size="sm" onClick={() => setParams({ tab: "records" })}>
只看该作品
<X />
</Button>
) : null}
{pinned ? (
<Button variant="outline" size="sm" onClick={() => setParams({ tab: "records" })}>
本次整理的 {ids.length} 条
<X />
</Button>
) : null}
</FilterBar>
{pinned ? null : <StatsLine filters={base} status={v.status} onStatus={(s) => setFilter("status", s)} />}
{selecting ? (
<div className="pg-slab sticky top-2 z-20 mb-3 flex flex-wrap items-center gap-2 rounded-xl px-3 py-2">
<span className="text-sm text-foreground tabular-nums">已选 {selRows.length}</span>
<Button variant="ghost" size="sm" onClick={pickAll}>
{allPicked ? "取消全选" : "全选"}
</Button>
<span className="ml-auto flex flex-wrap gap-2">
<Button size="sm" variant="outline" disabled={!selReorg} onClick={() => reorganize(selRows)}>
<Wand2 />
{selReorg && selReorg < selRows.length ? `重新整理 ${selReorg} 条` : "重新整理"}
</Button>
<Button
size="sm"
variant="outline"
className="text-destructive"
disabled={!selDeletable.length}
onClick={() => setToDelete(selDeletable)}
>
<Trash2 />
删除
</Button>
</span>
</div>
) : null}
<DataTable
columns={columns}
rows={rows}
pagination={paged.pagination}
rowKey={(r) => r.id}
loading={pinned ? byIds.isPending : records.isPending}
error={pinned ? byIds.error : records.error}
onRetry={() => void (pinned ? byIds.refetch() : records.refetch())}
onRowClick={(r) => (selecting ? toggle(r.id) : navigate(`/console/organize/${r.id}`))}
selectedKey={null}
hasMore={!pinned && records.hasNextPage}
onLoadMore={() => void records.fetchNextPage()}
loadingMore={records.isFetchingNextPage}
groupBy={
grouped
? {
key: (r) => r.media?.entityId ?? "",
label: (k, g) => {
const m = g[0]?.media;
return k && m ? `${m.title}${m.year ? ` (${m.year})` : ""}` : "未识别";
},
}
: undefined
}
empty={{
icon: FileStack,
title: filtered ? "没有符合筛选的整理记录" : "还没有整理记录",
description: filtered ? undefined : "下载完成后会自动整理,也可以在文件管理里选中文件整理。",
}}
/>
</TabsContent>
</Tabs>
{choosingSource ? (
<SourceSelection
onClose={() => setChoosingSource(false)}
onSelect={(source) => {
setChoosingSource(false);
setWizard(source);
}}
/>
<FilterSelect
label="类型"
value={v.kind}
onChange={(x) => setFilter("kind", x)}
options={Object.entries(KIND_LABEL).map(([value, label]) => ({ value, label }))}
/>
<FilterSelect
label="来源"
value={v.trigger}
onChange={(x) => setFilter("trigger", x)}
options={Object.entries(TRIGGER_LABEL).map(([value, label]) => ({ value, label }))}
/>
<FilterSelect
label="时间"
value={v.time}
allLabel="不限"
onChange={(x) => setFilter("time", x)}
options={Object.entries(TIME_LABEL).map(([value, label]) => ({ value, label }))}
/>
{entityId ? (
<Button variant="outline" size="sm" onClick={() => setParams({})}>
只看该作品
<X />
</Button>
) : null}
{pinned ? (
<Button variant="outline" size="sm" onClick={() => setParams({})}>
本次整理的 {ids.length} 条
<X />
</Button>
) : null}
</FilterBar>
{pinned ? null : <StatsLine filters={base} status={v.status} onStatus={(s) => setFilter("status", s)} />}
{selecting ? (
<div className="pg-slab sticky top-2 z-20 mb-3 flex flex-wrap items-center gap-2 rounded-xl px-3 py-2">
<span className="text-sm text-foreground tabular-nums">已选 {selRows.length}</span>
<Button variant="ghost" size="sm" onClick={pickAll}>
{allPicked ? "取消全选" : "全选"}
</Button>
<span className="ml-auto flex flex-wrap gap-2">
<Button size="sm" variant="outline" disabled={!selReorg} onClick={() => reorganize(selRows)}>
<Wand2 />
{selReorg && selReorg < selRows.length ? `重新整理 ${selReorg} 条` : "重新整理"}
</Button>
<Button
size="sm"
variant="outline"
className="text-destructive"
disabled={!selDeletable.length}
onClick={() => setToDelete(selDeletable)}
>
<Trash2 />
删除
</Button>
</span>
</div>
) : null}
<DataTable
columns={columns}
rows={rows}
rowKey={(r) => r.id}
loading={pinned ? byIds.isPending : records.isPending}
error={pinned ? byIds.error : records.error}
onRetry={() => void (pinned ? byIds.refetch() : records.refetch())}
onRowClick={(r) => (selecting ? toggle(r.id) : navigate(`/console/organize/${r.id}`))}
selectedKey={null}
hasMore={!pinned && records.hasNextPage}
onLoadMore={() => void records.fetchNextPage()}
loadingMore={records.isFetchingNextPage}
groupBy={
grouped
? {
key: (r) => r.media?.entityId ?? "",
label: (k, g) => {
const m = g[0]?.media;
return k && m ? `${m.title}${m.year ? ` (${m.year})` : ""}` : "未识别";
},
}
: undefined
}
empty={{
icon: FileStack,
title: filtered ? "没有符合筛选的整理记录" : "还没有整理记录",
description: filtered ? undefined : "下载完成后会自动整理,也可以在文件管理里选中文件整理。",
}}
/>
{wizard ? (
<OrganizeWizard
source={wizard}
+13
View File
@@ -10,6 +10,7 @@ import {
type ReleaseFilterState,
ReleaseResults,
} from "~/components/search/release-results.tsx";
import { SearchHistory, useSearchHistory } from "~/components/search/search-history.tsx";
import { ActionsSection, WorksSection } from "~/components/search/search-sections.tsx";
import { SitePickerDialog } from "~/components/search/site-picker-dialog.tsx";
import { releaseSearchPath } from "~/components/search/work-card.tsx";
@@ -30,6 +31,11 @@ export default function SearchPage() {
const siteIds = params.get("in") ?? "";
const target = useMemo(() => (work ? parseWorkRef(work) : null), [work]);
const [input, setInput] = useState(q);
const history = useSearchHistory();
const remember = history.add;
useEffect(() => {
if (q) remember(q);
}, [q, remember]);
const [picking, setPicking] = useState(false);
useEffect(() => setInput(q), [q]);
const f = useQueryState(RELEASE_FILTER_KEYS);
@@ -61,6 +67,13 @@ export default function SearchPage() {
</Button>
</form>
<SearchHistory
onSearch={(term) => {
setInput(term);
setParams(new URLSearchParams({ q: term }));
}}
/>
{!q ? (
<EmptyState icon={Search} title="输入作品名开始搜索" description="也可以在任意页面按 Ctrl/⌘ K 打开搜索。" />
) : target ? (
+12 -14
View File
@@ -25,6 +25,7 @@ import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { useListPagination } from "~/components/console/use-list-pagination.ts";
import { QuickFilterBar } from "~/components/search/quick-filter-bar.tsx";
import { formatQuickFilter, selectedCount, useQuickFilters } from "~/components/search/quick-filter-state.ts";
import { SiteAccountsTab } from "~/components/sites/site-accounts-tab.tsx";
@@ -42,6 +43,7 @@ import {
} from "~/components/sites/site-fields.ts";
import { SiteOverview } from "~/components/sites/site-overview.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Pagination } from "~/components/ui/pagination.tsx";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
import { FEED_HEALTH, FeedTable } from "~/components/workbench/feed-table.tsx";
import { ReleaseDetailSheet } from "~/components/workbench/release-detail-sheet.tsx";
@@ -166,6 +168,13 @@ export default function SiteWorkbenchPage() {
const quickHidden = selectedCount(quick) > 0 && (pages[0]?.unfilteredTotal ?? 0) > 0 && quickTotal === 0;
const now = Date.now();
const rows = all.filter((r) => localMatch(r, local, now));
const paged = useListPagination(rows, {
hasMore: releases.hasNextPage,
onLoadMore: () => releases.fetchNextPage(),
loadingMore: releases.isFetchingNextPage,
error: releases.error,
resetKey: JSON.stringify({ siteId, filters, local, live }),
});
const localActive = Object.values(local).some(Boolean);
const inventory = useInventory(all.flatMap((r) => (r.entity ? [r.entity.id] : [])));
const categories = useMemo(() => {
@@ -407,7 +416,7 @@ export default function SiteWorkbenchPage() {
}
description={
all.length
? "上传时间只筛选已加载的结果。加载更多或放宽条件。"
? "上传时间只筛选已加载的结果。翻页加载后续资源或放宽条件。"
: quickHidden
? "减少快速筛选条件再看。"
: coverage?.liveError
@@ -437,7 +446,7 @@ export default function SiteWorkbenchPage() {
{releases.isFetching && !releases.isFetchingNextPage ? <span>刷新中…</span> : null}
</div>
<ul className="divide-y divide-border/60">
{rows.map((r) => (
{paged.items?.map((r) => (
<ReleaseRow
key={r.id}
r={r}
@@ -451,18 +460,7 @@ export default function SiteWorkbenchPage() {
</ul>
</>
)}
{releases.hasNextPage ? (
<div className="flex justify-center border-t border-border/60 px-4 py-3">
<Button
variant="outline"
size="sm"
onClick={() => void releases.fetchNextPage()}
disabled={releases.isFetchingNextPage}
>
{releases.isFetchingNextPage ? "加载中…" : "加载更多"}
</Button>
</div>
) : null}
{all.length > 0 || releases.hasNextPage ? <Pagination {...paged.pagination} /> : null}
</div>
</TabsContent>
+9 -1
View File
@@ -6,12 +6,14 @@ import { useSites } from "~/api/sites.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { FilterSelect } from "~/components/console/filter-bar.tsx";
import { PageHeader } from "~/components/console/page-header.tsx";
import { useListPagination } from "~/components/console/use-list-pagination.ts";
import { SearchDefaultDialog } from "~/components/sites/search-default-dialog.tsx";
import { SiteAddDialog } from "~/components/sites/site-add-dialog.tsx";
import { SiteCard, SiteTable } from "~/components/sites/site-card.tsx";
import { type SiteDialog, SiteDialogHost, useSiteChecks } from "~/components/sites/site-dialogs.tsx";
import { needsAttention, type SortKey, sortSites } from "~/components/sites/site-fields.ts";
import { Button } from "~/components/ui/button.tsx";
import { Pagination } from "~/components/ui/pagination.tsx";
import { cn } from "~/lib/utils.ts";
// 获取 → 站点:站点、凭证、健康、账号数据、RSS 的唯一入口(docs/design/site-center.md §4);
@@ -115,6 +117,8 @@ export default function SitesPage() {
sort,
);
const paged = useListPagination(shown, { resetKey: JSON.stringify({ filter, kindFilter, sort }) });
return (
<>
<PageHeader
@@ -205,12 +209,16 @@ export default function SitesPage() {
<SiteTable sites={shown} checks={checks} onDialog={setDialog} />
) : (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{shown.map((s) => (
{paged.items?.map((s) => (
<SiteCard key={s.id} site={s} checks={checks} onDialog={setDialog} />
))}
</div>
)}
{view !== "list" && shown.length > 0 ? (
<Pagination {...paged.pagination} className="mt-2 border-t-0 px-0" />
) : null}
{adding ? <SiteAddDialog initialKind={adding === "any" ? null : adding} onClose={() => setAdding(null)} /> : null}
<SiteDialogHost dialog={dialog} onClose={() => setDialog(null)} />
<SearchDefaultDialog open={searchDefaults} onOpenChange={setSearchDefaults} sites={all} />
+25 -11
View File
@@ -22,6 +22,7 @@ import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { PageHeader } from "~/components/console/page-header.tsx";
import { useListPagination } from "~/components/console/use-list-pagination.ts";
import { useQueryState } from "~/components/console/use-query-state.ts";
import { opts, SUB_ORIGIN } from "~/components/library/labels.ts";
import { DeleteSubscriptionsDialog, useSubscriptionOps } from "~/components/subscriptions/actions.tsx";
@@ -30,6 +31,7 @@ import { GROUP_LABEL, GROUP_ORDER, SUB_KIND_LABEL } from "~/components/subscript
import { SubscriptionCard, SubscriptionRow } from "~/components/subscriptions/overview-item.tsx";
import { SubscriptionFormDialog } from "~/components/subscriptions/subscription-form.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Pagination } from "~/components/ui/pagination.tsx";
import { cn } from "~/lib/utils.ts";
import { legacyRedirectLoader } from "./redirects.ts";
@@ -143,6 +145,14 @@ export default function SubscriptionsPage() {
};
const query = useSubscriptionOverview(filters);
const items = useMemo(() => query.data?.pages.flatMap((p) => p.items) ?? [], [query.data]);
const paged = useListPagination(items, {
hasMore: query.hasNextPage,
onLoadMore: () => query.fetchNextPage(),
loadingMore: query.isFetchingNextPage,
error: query.error,
resetKey: JSON.stringify(filters),
});
const visible = paged.items ?? [];
const first = query.data?.pages[0];
const counts = first?.counts;
const allCount = counts ? GROUP_ORDER.reduce((n, g) => n + (counts[g] ?? 0), 0) : undefined;
@@ -344,9 +354,19 @@ export default function SubscriptionsPage() {
<Button
variant="ghost"
size="sm"
onClick={() => setSel(new Set(sel.size === items.length ? [] : items.map((x) => x.id)))}
onClick={() =>
setSel((previous) => {
const next = new Set(previous);
const selected = visible.every((x) => previous.has(x.id));
for (const x of visible) {
if (selected) next.delete(x.id);
else next.add(x.id);
}
return next;
})
}
>
{sel.size === items.length && items.length ? "取消全选" : "全选"}
{visible.length > 0 && visible.every((x) => sel.has(x.id)) ? "取消本页全选" : "全选本页"}
</Button>
<span className="ml-auto flex flex-wrap gap-2">
<Button
@@ -404,7 +424,7 @@ export default function SubscriptionsPage() {
) : grouped ? (
<div className="space-y-6">
{GROUP_ORDER.map((g) => {
const list = items.filter((x) => x.group === g);
const list = visible.filter((x) => x.group === g);
if (!list.length) return null;
const closed = collapsed.has(g);
return (
@@ -437,15 +457,9 @@ export default function SubscriptionsPage() {
})}
</div>
) : (
renderItems(items)
renderItems(visible)
)}
{query.hasNextPage ? (
<div className="mt-4 flex justify-center">
<Button variant="outline" onClick={() => query.fetchNextPage()} disabled={query.isFetchingNextPage}>
{query.isFetchingNextPage ? "加载中…" : "加载更多"}
</Button>
</div>
) : null}
{items.length > 0 || query.hasNextPage ? <Pagination {...paged.pagination} /> : null}
</>
)}
+25 -25
View File
@@ -1,6 +1,6 @@
{
"name": "@umt/web",
"version": "0.3.7",
"version": "0.3.14",
"private": true,
"type": "module",
"scripts": {
@@ -8,42 +8,42 @@
"build": "react-router build",
"preview": "vite preview --host 0.0.0.0 --port 5180",
"typecheck": "react-router typegen && tsc -p tsconfig.json",
"test": "vitest run",
"test": "deno run -A npm:vitest run",
"gen:tokens": "deno run -A scripts/gen-design-tokens.mjs",
"check:tokens": "deno run -A scripts/gen-design-tokens.mjs --check && sh scripts/check-design-tokens.sh"
},
"dependencies": {
"@fontsource-variable/inter-tight": "^5.2.7",
"@fontsource-variable/space-grotesk": "^5.2.10",
"@react-router/node": "7.12.0",
"@simplewebauthn/browser": "^13.3.0",
"@tanstack/react-query": "^5.90.21",
"@fontsource-variable/inter-tight": "^5.3.0",
"@fontsource-variable/space-grotesk": "^5.3.0",
"@react-router/node": "8.4.0",
"@simplewebauthn/browser": "^14.0.0",
"@tanstack/react-query": "^5.104.0",
"@umt/contracts": "workspace:*",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"isbot": "^5.1.31",
"lucide-react": "^0.576.0",
"date-fns": "^4.4.0",
"isbot": "^5.2.2",
"lucide-react": "^1.49.0",
"next-themes": "^0.4.6",
"qr-code-styling": "^1.9.2",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-day-picker": "^10.0.1",
"react-dom": "^19.2.4",
"react-router": "7.12.0",
"radix-ui": "^1.6.7",
"react": "^19.3.0",
"react-day-picker": "^10.0.2",
"react-dom": "^19.3.0",
"react-router": "8.4.0",
"recharts": "^3.10.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"zod": "^4.3.6"
"sonner": "^2.0.8",
"tailwind-merge": "^3.7.0",
"zod": "^4.6.5"
},
"devDependencies": {
"@react-router/dev": "7.12.0",
"@tailwindcss/vite": "^4.1.13",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"tailwindcss": "^4.1.13",
"@react-router/dev": "8.4.0",
"@tailwindcss/vite": "^4.3.3",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0",
"vite": "^7.1.7",
"vite-tsconfig-paths": "^5.1.4"
"vite": "^8.3.1",
"vite-tsconfig-paths": "^6.1.1"
}
}
+5 -2
View File
@@ -97,13 +97,13 @@ const DETAIL_ROUTES = [
const REDIRECT_ROUTES = [
{ from: "/console/tasks?tab=downloads", to: "/console/downloads" },
{ from: "/console/tasks?tab=downloads&status=paused", to: "/console/downloads?status=paused" },
{ from: "/console/tasks?tab=plans", to: "/console/downloads/plans" },
{ from: "/console/tasks?tab=plans", to: "/console/downloads" },
{ from: "/console/tasks?tab=tasks", to: "/console/tasks" },
{ from: "/console/subscriptions?tab=policies", to: "/console/rules" },
// 偏好标签并入规则页(origin 筛选透传)
{ from: "/console/preferences", to: "/console/rules" },
{ from: "/console/preferences?origin=agent_inferred", to: "/console/rules?origin=agent_inferred" },
{ from: "/console/sources?tab=snapshots", to: "/console/sources" },
{ from: "/console/sources?tab=snapshots", to: "/console/sites" },
{ from: "/console/sources?tab=snapshots&siteId=:id", to: "/console/sites/:id?tab=snapshots", list: "/api/v1/sites" },
// 旧的下载详情地址:先查业务任务(404 属预期),再跳到下载详情
{ from: "/console/tasks/:id", to: "/console/downloads/:id", list: "/api/v1/download-tasks", expect404: true },
@@ -273,9 +273,12 @@ async function main() {
.filter({ hasNotText: /^(全部|等你处理|失败)$/ })
.first()
.click();
// 等历史弹层退场后再按 Escape,避免按键被仍在退场的 Popover 消耗。
await list.waitFor({ state: "hidden", timeout: 5_000 });
await page.getByRole("button", { name: "新对话" }).and(page.locator(":enabled")).waitFor({ timeout: 10_000 });
if (new URL(page.url()).pathname !== "/console/audit") problems.push("点历史会话跳走了页面");
await page.keyboard.press("Escape");
await page.locator('[data-slot="sheet-content"]').waitFor({ state: "hidden", timeout: 5_000 });
await launcher.click();
if (!(await page.getByRole("button", { name: "新对话" }).isEnabled())) problems.push("再次打开没有回到那条会话");
await page.getByRole("button", { name: "新对话" }).click();
+2 -2
View File
@@ -3,7 +3,6 @@ import path from "node:path";
import { reactRouter } from "@react-router/dev/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig, loadEnv, type Plugin } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
/** 读取 git 短哈希(不依赖 git 命令) */
function readGitShortSha(cwd: string): string | null {
@@ -53,7 +52,8 @@ export default defineConfig(({ mode }) => {
const apiTarget = env.VITE_API_TARGET || "http://localhost:4380";
return {
plugins: [tailwindcss(), reactRouter(), tsconfigPaths(), appVersion()],
plugins: [tailwindcss(), reactRouter(), appVersion()],
resolve: { tsconfigPaths: true },
server: {
port: 5180,
strictPort: true,
+1 -1
View File
@@ -3,6 +3,6 @@ import { defineConfig } from "vitest/config";
// 单测不加载 react-router / tailwind 插件
export default defineConfig({
plugins: [tsconfigPaths()],
plugins: [tsconfigPaths({ projects: ["./tsconfig.json"] })],
test: { include: ["app/**/*.test.ts"], environment: "node" },
});