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:
1 parent
8013196e3f
commit
ceb66f8c77
265 files changed
+17751
-2483
No files matched your search
@@ -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 }),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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])),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export type EngineRunFilters = {
|
||||
status?: string;
|
||||
/** 触发来源,单值或逗号分隔多值(user,api_key) */
|
||||
triggeredBy?: string;
|
||||
/** 单值或逗号分隔多个工作流名称 */
|
||||
workflowName?: string;
|
||||
businessKind?: EngineBusinessKind;
|
||||
businessRefId?: string;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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)),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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"] });
|
||||
|
||||
@@ -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)),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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={[
|
||||
{
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
Reference in new issue
Block a user