Merge branch 'ia2'
Co-Authored-By: Claude Fable 5.1 <[email protected]> # Conflicts: # CHANGELOG.md # docs/plan/00-first-version-plan.md # packages/agent/src/system-prompt.ts
This commit is contained in:
commit
7a24d23bbf
46 files changed
+1748
-1406
No files matched your search
@@ -12,6 +12,17 @@
|
||||
### 变更
|
||||
- `WORKER_CONCURRENCY` 从 io 组总并发改为映射到网络道;compose 不再给它缺省值(缺省走设置页)。`worker` / `worker:agent` / `all` 角色与镜像入口不变。
|
||||
|
||||
### 变更(IA-2 导航与站点类型)
|
||||
- 侧栏改为 4 组:概览 / 获取 / 整理 / 系统。「任务」移到系统组;站点、订阅、规则合成「获取」组(组名是 `nav.ts` 的 `ACQUIRE_GROUP_TITLE` 常量,改名只改一处);独立的 RSS 导航项取消。
|
||||
- RSS 属于站点:站点详情新增「RSS」Tab,承载原 RSS 页的新建 / 编辑 / 启停 / 立即抓取 / 抓取状态,详情里加「最近条目」;原「抓取与 RSS」拆成「抓取」和「RSS」。旧 `/console/sources*` 链接跳到对应站点的 RSS Tab(`?feed=` 查所属站点后打开该 RSS)或站点列表;任务 / 业务任务里的 RSS 链接改指站点。
|
||||
- 站点类型:站点新增 `kind`(PT 站 / 公开 BT 站 / 自定义 RSS 源;Torznab 索引器只定义结构,未实现),每种类型声明获取方式(搜索 / 列表抓取 / RSS / 详情页 / 登录 / 账号数据),实际可用 = 类型 ∩ 站点定义。站点列表可按类型筛选,详情按类型显示 Tab 与字段(自定义 RSS 源没有登录、账号、等级);添加站点第一步选类型,PT 站仍从站点定义库选。
|
||||
- 服务端按获取方式调度:快照只排能抓列表的站点,RSS 只排能抓 RSS 的站点,账号数据 / 健康检测只扫有登录的站点,搜索站点只含能搜索的;不支持的登录 / 凭证 / 抓取请求返回 400。
|
||||
- 不带站点新建 RSS(旧 API)时归入同主机的自定义 RSS 源站点;RSS 不能再移出站点。智能体 `site.list` 带站点类型与获取方式,系统提示同步"RSS 属于站点"。
|
||||
- 迁移 0055:`site.kind`;旧的通用 RSS 站点 → 自定义 RSS 源;不属于任何站点的 RSS 按主机归入新建的自定义 RSS 源站点,下次抓取把条目写成资源。
|
||||
|
||||
### 修复(IA-2)
|
||||
- 同一站点添加第二个 RSS 且都用缺省名称时,secret 名冲突导致 500。
|
||||
|
||||
## [0.3.0] - 2026-09-29
|
||||
|
||||
### 新增
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SearchAction } from "@umt/contracts";
|
||||
|
||||
// 页面与操作跳转(命令面板静态清单),按 IA-3 定稿导航:概览 / 资源 / 订阅 / 整理 / 系统(定时任务在任务页"计划"Tab)。
|
||||
// 页面与操作跳转(命令面板静态清单),按 IA-2 导航:概览 / 获取 / 整理 / 系统(任务归系统;RSS 在站点详情里)。
|
||||
// href 是控制台内路径;action 类带查询参数,由前端页面识别后打开对应表单。
|
||||
|
||||
const page = (id: string, group: string, title: string, href: string, keywords: string[] = []): SearchAction => ({
|
||||
@@ -22,28 +22,28 @@ const action = (id: string, group: string, title: string, href: string, keywords
|
||||
|
||||
export const SEARCH_ACTIONS: readonly SearchAction[] = [
|
||||
page("home", "概览", "首页", "/console", ["home", "dashboard", "概览"]),
|
||||
page("tasks", "概览", "任务", "/console/tasks", ["task", "运行", "历史"]),
|
||||
page("agent", "概览", "智能体会话", "/console/agent", ["agent", "会话"]),
|
||||
page("inbox", "概览", "待处理", "/console/inbox", ["inbox", "收件箱", "decision"]),
|
||||
page("sites", "资源", "站点", "/console/sites", ["site", "pt", "站点账号"]),
|
||||
page("sources", "资源", "RSS 订阅", "/console/sources", ["rss", "feed"]),
|
||||
page("subscriptions", "订阅", "订阅", "/console/subscriptions", ["subscription", "追剧"]),
|
||||
page("rules", "订阅", "规则", "/console/rules", ["rules", "收录策略", "policy"]),
|
||||
page("schedules", "概览", "计划", "/console/schedules", ["schedule", "cron", "巡检", "计划", "定时任务"]),
|
||||
page("sites", "获取", "站点", "/console/sites", ["site", "pt", "站点账号", "rss", "feed", "bt"]),
|
||||
page("subscriptions", "获取", "订阅", "/console/subscriptions", ["subscription", "追剧"]),
|
||||
page("rules", "获取", "规则", "/console/rules", ["rules", "收录策略", "policy"]),
|
||||
page("downloads", "整理", "下载面板", "/console/downloads", ["download", "qb", "下载任务"]),
|
||||
page("download-plans", "整理", "下载计划", "/console/downloads/plans", ["plan", "计划"]),
|
||||
page("organize", "整理", "整理记录", "/console/organize", ["organize", "入库", "历史"]),
|
||||
page("files", "整理", "文件管理", "/console/files", ["files", "文件"]),
|
||||
page("trash", "整理", "回收站", "/console/files/trash", ["trash", "删除"]),
|
||||
page("library", "整理", "媒体库", "/console/library", ["library", "媒体库", "作品"]),
|
||||
page("tasks", "系统", "任务", "/console/tasks", ["task", "运行", "历史"]),
|
||||
page("settings", "系统", "设置", "/console/settings", ["settings", "配置", "tmdb", "下载器"]),
|
||||
page("schedules", "概览", "定时任务", "/console/tasks?tab=plan", ["schedule", "cron", "巡检", "计划"]),
|
||||
page("audit", "系统", "审计", "/console/audit", ["audit", "日志"]),
|
||||
page("preferences", "系统", "偏好", "/console/preferences", ["preferences", "主题"]),
|
||||
action("new-subscription", "订阅", "新建订阅", "/console/subscriptions?new=1", ["subscribe", "订阅"]),
|
||||
action("new-subscription", "获取", "新建订阅", "/console/subscriptions?new=1", ["subscribe", "订阅"]),
|
||||
action("new-agent-run", "概览", "新建智能体会话", "/console/agent?new=1", ["agent", "委托"]),
|
||||
action("add-site", "资源", "添加站点", "/console/sites?new=1", ["site", "站点"]),
|
||||
action("add-feed", "资源", "添加 RSS 订阅", "/console/sources?new=1", ["rss", "feed"]),
|
||||
action("new-schedule", "概览", "新建巡检", "/console/tasks?tab=plan&new=1", ["巡检", "agent_check"]),
|
||||
action("add-site", "获取", "添加站点", "/console/sites?new=1", ["site", "站点", "rss", "feed", "bt"]),
|
||||
// RSS 属于站点(IA-2):添加 RSS 源 = 添加「自定义 RSS 源」类型的站点
|
||||
action("add-feed", "获取", "添加自定义 RSS 源", "/console/sites?new=rss_feed", ["rss", "feed"]),
|
||||
action("new-schedule", "概览", "新建巡检", "/console/schedules?new=1", ["巡检", "agent_check"]),
|
||||
];
|
||||
|
||||
/** 标题 / 关键词 / 路径包含查询词(不区分大小写);标题前缀命中排前 */
|
||||
|
||||
@@ -14,6 +14,7 @@ import { type SiteRow, sql } from "@umt/db";
|
||||
import { applyFacets, type FacetInput, normalizeTitle, parseFacetParam, parseRelease } from "@umt/domain";
|
||||
import type { SourcesRuntime } from "../sources-identity/runtime.ts";
|
||||
import { upsertSiteRelease } from "../sources-identity/service/snapshot.ts";
|
||||
import { siteCan } from "../sources-identity/site-kinds.ts";
|
||||
import { raceAbort } from "./media.ts";
|
||||
|
||||
// 跨站资源搜索:选中的已启用站点逐站调适配器 search(每站共享限流器 + 单站超时 + 站点级并发上限);
|
||||
@@ -63,15 +64,16 @@ export class ReleaseFanout {
|
||||
private readonly opts: FanoutOptions,
|
||||
) {}
|
||||
|
||||
/** 参与搜索的候选站点:启用且未删除(按名称排序) */
|
||||
/** 参与搜索的候选站点:启用、未删除且支持搜索(IA-2 按站点类型与定义的获取方式;按名称排序) */
|
||||
async enabledSites(): Promise<SiteRow[]> {
|
||||
return this.rt()
|
||||
const rows = await this.rt()
|
||||
.ctx.db.selectFrom("site")
|
||||
.selectAll()
|
||||
.where("enabled", "=", true)
|
||||
.where("deleted_at", "is", null)
|
||||
.orderBy("name")
|
||||
.execute();
|
||||
return rows.filter((s) => siteCan(s, "search"));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,7 @@ import { capabilitySpec, type SiteAccountUserData, type SiteLastHealth } from "@
|
||||
import type { SiteAccountRow, SiteRow } from "@umt/db";
|
||||
import type { SourcesRuntime } from "./runtime.ts";
|
||||
import { checkSiteHealth, overallAccountStatus, refreshSiteAccount, userDataHistory } from "./service/account.ts";
|
||||
import { capabilitiesOf, requireCapability } from "./site-kinds.ts";
|
||||
|
||||
// W10-pre2 站点能力:site.list / site.health_check / site.refresh_login / site.user_data,处理器调 W9-A 站点中心的 service。
|
||||
// 脱敏:输出按白名单逐字段拼装——不含 secret 引用、Cookie、密码、登录名、UA 与站点 config。
|
||||
@@ -62,6 +63,8 @@ export function registerSiteCapabilities(registry: CapabilityRegistry, rt: () =>
|
||||
id: s.id,
|
||||
key: s.key,
|
||||
name: s.name,
|
||||
kind: s.kind,
|
||||
capabilities: capabilitiesOf(s),
|
||||
adapter: s.adapter,
|
||||
baseUrl: s.base_url,
|
||||
enabled: s.enabled,
|
||||
@@ -83,6 +86,7 @@ export function registerSiteCapabilities(registry: CapabilityRegistry, rt: () =>
|
||||
registry.register(capabilitySpec("site.refresh_login"), async (input, ctx) => {
|
||||
const r = rt();
|
||||
const s = await liveSite(r, input.siteId);
|
||||
requireCapability(s, "login");
|
||||
let q = r.ctx.db.selectFrom("site_account").select(["id"]).where("site_id", "=", s.id);
|
||||
q = input.accountId
|
||||
? q.where("id", "=", input.accountId)
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
OkResult,
|
||||
ReleaseIdentityAssign,
|
||||
type SecretPurpose,
|
||||
SITE_KINDS,
|
||||
Site,
|
||||
type SiteAccount,
|
||||
type SiteAdapter,
|
||||
@@ -25,7 +26,7 @@ import {
|
||||
SiteUserDataQuery,
|
||||
type SourceRelease,
|
||||
} from "@umt/contracts";
|
||||
import { sql } from "@umt/db";
|
||||
import { type FeedSourceRow, sql } from "@umt/db";
|
||||
import { z } from "zod";
|
||||
import { ApiException } from "../../../http/errors.ts";
|
||||
import type { ModuleApp } from "../../types.ts";
|
||||
@@ -44,9 +45,10 @@ import {
|
||||
setSiteCredentials,
|
||||
userDataHistory,
|
||||
} from "../service/account.ts";
|
||||
import { audit, closeDecisions } from "../service/common.ts";
|
||||
import { audit, closeDecisions, type Exec } from "../service/common.ts";
|
||||
import { feedDefaultsForSite } from "../service/feeds.ts";
|
||||
import { assignReleaseIdentity } from "../service/identity-admin.ts";
|
||||
import { ensureRssSite, requireCapability, siteToApi, uniqueSiteKey } from "../site-kinds.ts";
|
||||
|
||||
// 站点、站点账号、Feed、来源资源、观察的管理 API。凭证只写(存 secret,对外只给 secret_ref / 脱敏 URL)。
|
||||
|
||||
@@ -88,6 +90,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
items: listSiteDefinitions().map((d) => ({
|
||||
key: d.id,
|
||||
name: d.umt.kind === "nexusphp" ? (d.umt.displayName ?? d.name) : d.umt.displayName,
|
||||
kind: "pt" as const,
|
||||
adapter: adapterOf(d),
|
||||
baseUrl: d.domain,
|
||||
shippedEnabled: d.enabled !== false,
|
||||
@@ -113,7 +116,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
.limit(query.limit + 1)
|
||||
.execute();
|
||||
const p = page(rows, off, query.limit);
|
||||
const items = toApiList<Record<string, unknown>>(p.items);
|
||||
const items = p.items.map((r) => siteToApi<Record<string, unknown>>(r));
|
||||
if (query.include === "accounts" && p.items.length) {
|
||||
const accounts = await db()
|
||||
.selectFrom("site_account")
|
||||
@@ -133,37 +136,70 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
}
|
||||
return { items, nextCursor: p.nextCursor };
|
||||
});
|
||||
route(app, D.sites.get, async ({ params }) => toApi(await getSite(params.id)));
|
||||
route(app, D.sites.get, async ({ params }) => siteToApi(await getSite(params.id)));
|
||||
route(app, D.sites.create, async ({ body, actor }) => {
|
||||
const def = listSiteDefinitions().find((d) => d.id === body.key);
|
||||
const def = body.key ? listSiteDefinitions().find((d) => d.id === body.key) : undefined;
|
||||
// 类型缺省:注册表里有定义 = PT 站;否则按旧口径(generic_rss = 自定义 RSS 站点)
|
||||
const kind = body.kind ?? (def ? "pt" : "rss_feed");
|
||||
if (!SITE_KINDS[kind].implemented) throw new ApiException("bad_request", `还不支持添加${SITE_KINDS[kind].label}`);
|
||||
if (kind === "pt") {
|
||||
if (!body.key) throw new ApiException("bad_request", "PT 站要从站点定义里选(缺 key)");
|
||||
if (def && adapterOf(def) !== body.adapter) {
|
||||
throw new ApiException("bad_request", `站点 ${body.key} 的适配器类型应为 ${adapterOf(def)}`);
|
||||
}
|
||||
if (!def && body.adapter !== "generic_rss") {
|
||||
throw new ApiException("bad_request", `注册表中没有站点 ${body.key};未适配站点只能以 generic_rss 登记`);
|
||||
}
|
||||
} else if (body.adapter !== "generic_rss") {
|
||||
throw new ApiException("bad_request", `${SITE_KINDS[kind].label}只能以 generic_rss 登记`);
|
||||
}
|
||||
const key = body.key ?? (await uniqueSiteKey(db(), body.rssUrl ?? body.baseUrl));
|
||||
try {
|
||||
const row = await db()
|
||||
.transaction()
|
||||
.execute(async (trx) => {
|
||||
const row = await trx
|
||||
.insertInto("site")
|
||||
.values({
|
||||
key: body.key,
|
||||
key,
|
||||
name: body.name,
|
||||
kind,
|
||||
adapter: body.adapter,
|
||||
base_url: body.baseUrl,
|
||||
enabled: body.enabled ?? true,
|
||||
rate_limit: jsonb(body.rateLimit ?? {}),
|
||||
config: jsonb(body.config ?? {}),
|
||||
snapshot_enabled: body.snapshotEnabled ?? false,
|
||||
snapshot_enabled: kind === "pt" ? (body.snapshotEnabled ?? false) : false,
|
||||
snapshot_interval_sec: body.snapshotIntervalSec ?? null,
|
||||
proxy: body.proxy ?? false,
|
||||
search_default: body.searchDefault ?? true,
|
||||
// 不能搜索的站点不进默认搜索集合
|
||||
search_default: kind === "pt" ? (body.searchDefault ?? true) : false,
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
await audit(db(), actor, "site.created", { type: "site", id: row.id }, { key: body.key });
|
||||
return toApi(row);
|
||||
if (body.rssUrl) {
|
||||
const inferred = await feedDefaultsForSite(trx, row, body.rssUrl, null);
|
||||
const feed = await insertFeed(trx, {
|
||||
siteId: row.id,
|
||||
url: body.rssUrl,
|
||||
name: inferred.name,
|
||||
parserVersion: inferred.parserVersion,
|
||||
pollIntervalSec: inferred.pollIntervalSec,
|
||||
});
|
||||
await audit(
|
||||
trx,
|
||||
actor,
|
||||
"feed_source.created",
|
||||
{ type: "feed_source", id: feed.id },
|
||||
{ url: feed.url_display },
|
||||
);
|
||||
}
|
||||
return row;
|
||||
});
|
||||
await audit(db(), actor, "site.created", { type: "site", id: row.id }, { key, kind });
|
||||
return siteToApi(row);
|
||||
} catch (e) {
|
||||
if ((e as { code?: string }).code === "23505") throw new ApiException("conflict", `站点 ${body.key} 已登记`);
|
||||
if ((e as { code?: string }).code === "23505") throw new ApiException("conflict", `站点 ${key} 已登记`);
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
@@ -184,9 +220,9 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
await db().updateTable("site").set(patch).where("id", "=", params.id).execute();
|
||||
await audit(db(), actor, "site.updated", { type: "site", id: params.id }, { fields: Object.keys(patch) });
|
||||
}
|
||||
return toApi(await setSiteEnabled(params.id, body.enabled, actor));
|
||||
return siteToApi(await setSiteEnabled(params.id, body.enabled, actor));
|
||||
}
|
||||
if (!Object.keys(patch).length) return toApi(await getSite(params.id));
|
||||
if (!Object.keys(patch).length) return siteToApi(await getSite(params.id));
|
||||
const row = await db()
|
||||
.updateTable("site")
|
||||
.set(patch)
|
||||
@@ -194,7 +230,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
await audit(db(), actor, "site.updated", { type: "site", id: params.id }, { fields: Object.keys(patch) });
|
||||
return toApi(row);
|
||||
return siteToApi(row);
|
||||
});
|
||||
for (const enabled of [true, false]) {
|
||||
route(
|
||||
@@ -208,7 +244,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
params: IdParam,
|
||||
response: Site,
|
||||
},
|
||||
async ({ params, actor }) => toApi(await setSiteEnabled(params.id, enabled, actor)),
|
||||
async ({ params, actor }) => siteToApi(await setSiteEnabled(params.id, enabled, actor)),
|
||||
);
|
||||
}
|
||||
route(
|
||||
@@ -225,6 +261,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
},
|
||||
async ({ params, body }) => {
|
||||
const s = await getSite(params.id);
|
||||
requireCapability(s, "list");
|
||||
if (!s.enabled) throw new ApiException("precondition_failed", "站点已关闭");
|
||||
const r = await rt().ctx.engine.enqueue(
|
||||
"snapshotSite",
|
||||
@@ -349,6 +386,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
},
|
||||
async ({ params, body, actor }) => {
|
||||
const s = await getSite(params.id);
|
||||
requireCapability(s, "login");
|
||||
let q = db().selectFrom("site_account").select(["id", "enabled", "auth_kind"]).where("site_id", "=", s.id);
|
||||
q = body.accountId
|
||||
? q.where("id", "=", body.accountId)
|
||||
@@ -409,7 +447,11 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
body: SiteCredentialsPut,
|
||||
response: SiteCredentialsResult,
|
||||
},
|
||||
async ({ params, body, actor }) => setSiteCredentials(rt(), await getSite(params.id), body, actor),
|
||||
async ({ params, body, actor }) => {
|
||||
const s = await getSite(params.id);
|
||||
requireCapability(s, "login");
|
||||
return setSiteCredentials(rt(), s, body, actor);
|
||||
},
|
||||
);
|
||||
route(
|
||||
app,
|
||||
@@ -562,37 +604,33 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
});
|
||||
route(app, D.feedSources.get, async ({ params }) => feedToApi(await getFeed(params.id)));
|
||||
route(app, D.feedSources.create, async ({ body, actor }) => {
|
||||
const site = body.siteId ? await getSite(body.siteId) : null;
|
||||
if (body.siteAccountId) await checkAccount(body.siteAccountId, body.siteId ?? null);
|
||||
// RSS 属于站点(IA-2):没给站点时归入同主机的「自定义 RSS 源」站点,没有就建一个
|
||||
const site = body.siteId ? await getSite(body.siteId) : await ensureRssSite(db(), body.url);
|
||||
requireCapability(site, "rss");
|
||||
if (body.siteAccountId) await checkAccount(body.siteAccountId, site.id);
|
||||
// 站点内添加:账号 / 解析器 / 名称按站点推断,域名不符只提示
|
||||
const inferred = site ? await feedDefaultsForSite(db(), site, body.url, body.siteAccountId ?? null) : null;
|
||||
const name = body.name?.trim() || inferred?.name || new URL(body.url).hostname;
|
||||
const ref = await rt().ctx.secrets.create({ purpose: "feed_url", plaintext: body.url, label: `feed:${name}` });
|
||||
const row = await db()
|
||||
.insertInto("feed_source")
|
||||
.values({
|
||||
site_id: body.siteId ?? null,
|
||||
site_account_id: inferred?.siteAccountId ?? body.siteAccountId ?? null,
|
||||
name,
|
||||
...(inferred ? { parser_version: inferred.parserVersion } : {}),
|
||||
purpose: body.purpose ?? "generic",
|
||||
kind: body.kind ?? "rss",
|
||||
url_secret_ref: ref,
|
||||
url_display: redactUrl(body.url),
|
||||
enabled: body.enabled ?? true,
|
||||
poll_interval_sec: body.pollIntervalSec ?? inferred?.pollIntervalSec ?? 900,
|
||||
filter: jsonb(body.filter ?? {}),
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
const inferred = await feedDefaultsForSite(db(), site, body.url, body.siteAccountId ?? null);
|
||||
const row = await insertFeed(db(), {
|
||||
siteId: site.id,
|
||||
url: body.url,
|
||||
name: body.name?.trim() || inferred.name,
|
||||
siteAccountId: inferred.siteAccountId,
|
||||
parserVersion: inferred.parserVersion,
|
||||
purpose: body.purpose,
|
||||
kind: body.kind,
|
||||
enabled: body.enabled,
|
||||
pollIntervalSec: body.pollIntervalSec ?? inferred.pollIntervalSec,
|
||||
filter: body.filter,
|
||||
});
|
||||
await audit(db(), actor, "feed_source.created", { type: "feed_source", id: row.id }, { url: row.url_display });
|
||||
return inferred?.warnings.length ? { ...feedToApi<FeedSource>(row), warnings: inferred.warnings } : feedToApi(row);
|
||||
return inferred.warnings.length ? { ...feedToApi<FeedSource>(row), warnings: inferred.warnings } : feedToApi(row);
|
||||
});
|
||||
route(app, D.feedSources.update, async ({ params, body, actor }) => {
|
||||
const feed = await getFeed(params.id);
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (body.siteId !== undefined) {
|
||||
if (body.siteId) await getSite(body.siteId);
|
||||
if (!body.siteId) throw new ApiException("bad_request", "RSS 必须属于一个站点");
|
||||
requireCapability(await getSite(body.siteId), "rss");
|
||||
patch.site_id = body.siteId;
|
||||
}
|
||||
if (body.siteAccountId !== undefined) {
|
||||
@@ -831,6 +869,45 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
if (!row) throw new ApiException("not_found", "站点不存在");
|
||||
return row;
|
||||
}
|
||||
async function insertFeed(
|
||||
exec: Exec,
|
||||
f: {
|
||||
siteId: string;
|
||||
url: string;
|
||||
name: string;
|
||||
pollIntervalSec: number;
|
||||
siteAccountId?: string | null;
|
||||
parserVersion?: string;
|
||||
purpose?: FeedSourceRow["purpose"] | undefined;
|
||||
kind?: FeedSourceRow["kind"] | undefined;
|
||||
enabled?: boolean | undefined;
|
||||
filter?: Record<string, unknown> | undefined;
|
||||
},
|
||||
) {
|
||||
// secret.name 唯一:同站多个 RSS 都叫「站点名 RSS」时不能撞名
|
||||
const ref = await rt().ctx.secrets.create({
|
||||
purpose: "feed_url",
|
||||
plaintext: f.url,
|
||||
label: `feed:${f.name}:${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
return exec
|
||||
.insertInto("feed_source")
|
||||
.values({
|
||||
site_id: f.siteId,
|
||||
site_account_id: f.siteAccountId ?? null,
|
||||
name: f.name,
|
||||
...(f.parserVersion ? { parser_version: f.parserVersion } : {}),
|
||||
purpose: f.purpose ?? "generic",
|
||||
kind: f.kind ?? "rss",
|
||||
url_secret_ref: ref,
|
||||
url_display: redactUrl(f.url),
|
||||
enabled: f.enabled ?? true,
|
||||
poll_interval_sec: f.pollIntervalSec,
|
||||
filter: jsonb(f.filter ?? {}),
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
async function getFeed(id: string) {
|
||||
const row = await db().selectFrom("feed_source").selectAll().where("id", "=", id).executeTakeFirst();
|
||||
if (!row) throw new ApiException("not_found", "Feed 不存在");
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getSiteDefinition } from "@umt/adapters";
|
||||
import { sql } from "@umt/db";
|
||||
import { type SourcesRuntime, siteAccessFor } from "./runtime.ts";
|
||||
import { archiveStalePool } from "./service/identity-decisions.ts";
|
||||
import { RSS_KINDS, siteCan } from "./site-kinds.ts";
|
||||
|
||||
// worker 后台调度:按 Feed 轮询策略发 feed.due(outbox → ingestFeed)、按站点快照周期入队 snapshotSite、
|
||||
// 按待识别池 next_review_at 入队复查。幂等键按时间槽生成,多 worker 并发也不会重复。
|
||||
@@ -39,7 +40,7 @@ export async function tickFeeds(rt: SourcesRuntime): Promise<number> {
|
||||
const feedsDue = await ctx.db.transaction().execute(async (trx) => {
|
||||
const due = await sql<{ id: string; poll_interval_sec: number }>`
|
||||
select f.id, f.poll_interval_sec from feed_source f left join site s on s.id = f.site_id
|
||||
where f.enabled and (s.id is null or s.enabled)
|
||||
where f.enabled and (s.id is null or (s.enabled and s.deleted_at is null and s.kind = any(${RSS_KINDS})))
|
||||
and (f.next_fetch_at is null or f.next_fetch_at <= ${now})
|
||||
and (f.backoff_until is null or f.backoff_until <= ${now})
|
||||
and f.health_status <> 'circuit_open'
|
||||
@@ -72,13 +73,15 @@ export async function tickSnapshots(rt: SourcesRuntime): Promise<number> {
|
||||
const defaults = await rt.siteDefaults();
|
||||
const rows = await ctx.db
|
||||
.selectFrom("site")
|
||||
.select(["id", "key", "snapshot_interval_sec", "last_snapshot_at"])
|
||||
.select(["id", "key", "kind", "adapter", "snapshot_interval_sec", "last_snapshot_at"])
|
||||
.where("enabled", "=", true)
|
||||
.where("snapshot_enabled", "=", true)
|
||||
.where("deleted_at", "is", null)
|
||||
.execute();
|
||||
const due: Array<{ id: string; interval: number }> = [];
|
||||
for (const r of rows) {
|
||||
// 只给支持列表抓取的站点排快照(IA-2:按站点类型与定义的获取方式)
|
||||
if (!siteCan(r, "list")) continue;
|
||||
const interval =
|
||||
r.snapshot_interval_sec ?? siteAccessFor(getSiteDefinition(r.key) ?? null, defaults).snapshotIntervalSec;
|
||||
if (!r.last_snapshot_at || r.last_snapshot_at.getTime() + interval * 1000 <= now.getTime()) {
|
||||
|
||||
@@ -26,6 +26,7 @@ import { type SiteAccountRow, type SiteRow, sql } from "@umt/db";
|
||||
import { ApiException } from "../../../http/errors.ts";
|
||||
import { toApi } from "../rows.ts";
|
||||
import type { SourcesRuntime } from "../runtime.ts";
|
||||
import { capabilitiesOf, LOGIN_KINDS } from "../site-kinds.ts";
|
||||
import { type Actor, audit, closeDecisions, type Exec, openDecision, SYSTEM } from "./common.ts";
|
||||
import { cookieFromBrowser, writeBrowserCookie } from "./credentials.ts";
|
||||
|
||||
@@ -929,6 +930,7 @@ export async function tickSiteUserData(rt: SourcesRuntime, slot: string): Promis
|
||||
.select(["a.id"])
|
||||
.where("s.enabled", "=", true)
|
||||
.where("s.deleted_at", "is", null)
|
||||
.where("s.kind", "in", LOGIN_KINDS)
|
||||
.where("a.enabled", "=", true)
|
||||
.where("a.status", "=", "active")
|
||||
.where("a.auth_kind", "!=", "passkey")
|
||||
@@ -950,11 +952,12 @@ export async function tickSiteHealth(rt: SourcesRuntime): Promise<number> {
|
||||
.selectAll()
|
||||
.where("enabled", "=", true)
|
||||
.where("deleted_at", "is", null)
|
||||
.where("adapter", "!=", "generic_rss")
|
||||
.execute();
|
||||
let n = 0;
|
||||
const now = rt.now().getTime();
|
||||
for (const s of sites) {
|
||||
// 没有站点适配器的(自定义 RSS 源、未适配站点)没有首页检测;只剩 RSS 的站点跳过
|
||||
if (!capabilitiesOf(s).some((c) => c !== "rss")) continue;
|
||||
// 站点访问默认的最短间隔内检测过(手动或定时)就跳过;留 2 分钟余量,免得和定时任务周期错开一轮
|
||||
const { healthCheckIntervalSec } = await rt.siteAccess(s.key);
|
||||
if (s.last_health_at && now - s.last_health_at.getTime() < healthCheckIntervalSec * 1000 - 120_000) continue;
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { FeedHealth } from "@umt/contracts";
|
||||
import { type FeedSourceRow, type SiteRow, sql } from "@umt/db";
|
||||
import { jsonb } from "../rows.ts";
|
||||
import type { SourcesRuntime } from "../runtime.ts";
|
||||
import { siteCan } from "../site-kinds.ts";
|
||||
import { clip, type Exec, openDecision } from "./common.ts";
|
||||
|
||||
// ingestFeed:RSS 抓取 → observation(feed+guid 唯一,摘要变化 = 更新)→ source_release(site+站点资源 ID 聚合)。
|
||||
@@ -154,6 +155,7 @@ export async function ingestFeed(
|
||||
? await db.selectFrom("site").selectAll().where("id", "=", feed.site_id).executeTakeFirst()
|
||||
: undefined;
|
||||
if (site && !site.enabled) return { status: "skipped", reason: "site_disabled", ...empty };
|
||||
if (site && !siteCan(site, "rss")) return { status: "skipped", reason: "unsupported", ...empty };
|
||||
if (!feed.url_secret_ref) return { status: "skipped", reason: "no_url", ...empty };
|
||||
|
||||
const url = await secrets.resolve(feed.url_secret_ref);
|
||||
@@ -216,7 +218,8 @@ export async function ingestFeed(
|
||||
? {
|
||||
detectedAt: now.toISOString(),
|
||||
previousFetchedAt: cursor.window?.lastFetchedAt ?? null,
|
||||
backfillQueued: !!site?.id,
|
||||
// 补漏靠列表抓取:站点支持 list 才会排补漏(自定义 RSS 源没有列表)
|
||||
backfillQueued: !!site && siteCan(site, "list"),
|
||||
}
|
||||
: undefined
|
||||
: cursor.gap;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { parseRelease } from "@umt/domain";
|
||||
import { siteReleaseToSourceRelease } from "@umt/mapping";
|
||||
import { jsonb } from "../rows.ts";
|
||||
import type { SourcesRuntime } from "../runtime.ts";
|
||||
import { siteCan } from "../site-kinds.ts";
|
||||
import { enqueueRelogin } from "./account.ts";
|
||||
import { type Exec, openDecision } from "./common.ts";
|
||||
import { hasDetailPage, saveDetailPage } from "./detail-page.ts";
|
||||
@@ -134,6 +135,10 @@ export async function snapshotSite(rt: SourcesRuntime, input: SnapshotInput): Pr
|
||||
const site = await db.selectFrom("site").selectAll().where("id", "=", input.siteId).executeTakeFirst();
|
||||
if (!site) return { status: "skipped", reason: "not_found", ...none };
|
||||
if (!site.enabled) return { status: "skipped", reason: "site_disabled", ...none };
|
||||
// 按站点类型的获取方式(IA-2):实时搜索要 search,列表快照 / 补漏要 list
|
||||
if (!siteCan(site, input.kind === "live_search" ? "search" : "list")) {
|
||||
return { status: "skipped", reason: "unsupported", ...none };
|
||||
}
|
||||
if (site.health_status === "failing" && input.kind !== "manual" && input.kind !== "live_search") {
|
||||
const invalid = await db
|
||||
.selectFrom("site_account")
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { getSiteDefinition } from "@umt/adapters";
|
||||
import { SITE_KINDS, type SiteCapability, type SiteKind, siteCapabilities } from "@umt/contracts";
|
||||
import type { SiteRow } from "@umt/db";
|
||||
import { ApiException } from "../../http/errors.ts";
|
||||
import { toApi } from "./rows.ts";
|
||||
import type { Exec } from "./service/common.ts";
|
||||
|
||||
// IA-2 站点类型:服务端统一按 capabilities 判断能不能搜索 / 抓列表 / 抓 RSS / 看详情 / 登录 / 取账号数据。
|
||||
// 实际可用 = 类型允许的 ∩ 站点定义实现的(contracts siteCapabilities)。generic_rss 站点不查定义。
|
||||
|
||||
type SiteLike = Pick<SiteRow, "key" | "adapter"> & { kind: SiteKind };
|
||||
|
||||
export function capabilitiesOf(site: SiteLike): SiteCapability[] {
|
||||
const def = site.adapter === "generic_rss" ? null : getSiteDefinition(site.key);
|
||||
return siteCapabilities(site.kind, def ? def.umt.capabilities : null);
|
||||
}
|
||||
|
||||
export function siteCan(site: SiteLike, cap: SiteCapability): boolean {
|
||||
return capabilitiesOf(site).includes(cap);
|
||||
}
|
||||
|
||||
const CAP_TEXT: Record<SiteCapability, string> = {
|
||||
search: "搜索",
|
||||
list: "列表抓取",
|
||||
rss: "RSS",
|
||||
detail: "详情页",
|
||||
login: "登录",
|
||||
userinfo: "账号数据",
|
||||
};
|
||||
|
||||
/** 站点不支持该获取方式时抛 400:「名称」是自定义 RSS 源,不支持登录 */
|
||||
export function requireCapability(site: SiteLike & Pick<SiteRow, "name">, cap: SiteCapability): void {
|
||||
if (siteCan(site, cap)) return;
|
||||
throw new ApiException("bad_request", `「${site.name}」是${SITE_KINDS[site.kind].label},不支持${CAP_TEXT[cap]}`);
|
||||
}
|
||||
|
||||
/** site 行 → Site(补派生字段 capabilities) */
|
||||
export function siteToApi<T>(row: SiteLike & object): T {
|
||||
return { ...toApi<Record<string, unknown>>(row), capabilities: capabilitiesOf(row) } as T;
|
||||
}
|
||||
|
||||
/** 有登录类能力的类型(健康检测、刷新登录、账号数据的定时任务按这个过滤) */
|
||||
export const LOGIN_KINDS: SiteKind[] = (Object.keys(SITE_KINDS) as SiteKind[]).filter(
|
||||
(k) => SITE_KINDS[k].implemented && SITE_KINDS[k].capabilities.includes("login"),
|
||||
);
|
||||
/** 能抓 RSS 的类型(Feed 调度按这个过滤) */
|
||||
export const RSS_KINDS: SiteKind[] = (Object.keys(SITE_KINDS) as SiteKind[]).filter(
|
||||
(k) => SITE_KINDS[k].implemented && SITE_KINDS[k].capabilities.includes("rss"),
|
||||
);
|
||||
|
||||
/** 主机名 → key 片段(与迁移 0055 一致):小写,非字母数字换成 -,去掉 www. */
|
||||
export function hostSlug(host: string): string {
|
||||
const s = host
|
||||
.toLowerCase()
|
||||
.replace(/^www\./, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 40)
|
||||
.replace(/-+$/, "");
|
||||
return s || "feed";
|
||||
}
|
||||
|
||||
function hostOfUrl(url: string): string | null {
|
||||
try {
|
||||
return new URL(url).host || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 非 PT 站的 key:rss-<主机>,已占用(含已删除的站点)时加序号 */
|
||||
export async function uniqueSiteKey(db: Exec, url: string): Promise<string> {
|
||||
const base = `rss-${hostSlug(hostOfUrl(url) ?? "")}`;
|
||||
const taken = new Set(
|
||||
(await db.selectFrom("site").select("key").where("key", "like", `${base}%`).execute()).map((r) => r.key),
|
||||
);
|
||||
let key = base;
|
||||
for (let n = 2; taken.has(key); n++) key = `${base}-${n}`;
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* 不带站点新建 RSS(旧 API 调用方):归入同主机的「自定义 RSS 源」站点,没有就建一个。
|
||||
* 与迁移 0055 同口径:名称 = 主机名,地址 = https://<主机>,不参与默认搜索。
|
||||
*/
|
||||
export async function ensureRssSite(db: Exec, url: string): Promise<SiteRow> {
|
||||
const host = hostOfUrl(url);
|
||||
if (!host) throw new ApiException("bad_request", "RSS 地址格式不对");
|
||||
const existing = await db
|
||||
.selectFrom("site")
|
||||
.selectAll()
|
||||
.where("kind", "=", "rss_feed")
|
||||
.where("deleted_at", "is", null)
|
||||
.where("base_url", "=", `https://${host}`)
|
||||
.orderBy("created_at")
|
||||
.executeTakeFirst();
|
||||
if (existing) return existing;
|
||||
return db
|
||||
.insertInto("site")
|
||||
.values({
|
||||
key: await uniqueSiteKey(db, url),
|
||||
name: host,
|
||||
kind: "rss_feed",
|
||||
adapter: "generic_rss",
|
||||
base_url: `https://${host}`,
|
||||
search_default: false,
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
@@ -252,6 +252,7 @@ export const sourcesOutboxRoutes: Record<string, OutboxRouteFn> = {
|
||||
"feed.gap_suspected": (ev) => {
|
||||
const p = ev.payload as P;
|
||||
if (!p.siteId) return null;
|
||||
// 站点不支持列表抓取时 snapshotSite 自己跳过(reason=unsupported)
|
||||
return { task: "snapshotSite", input: { siteId: p.siteId, kind: "backfill", maxPages: BACKFILL_MAX_PAGES } };
|
||||
},
|
||||
};
|
||||
@@ -137,13 +137,23 @@ describe.skipIf(!up)("来源管理与站点工作台 API", () => {
|
||||
expect(other.status).toBe(200);
|
||||
expect(other.body.data.name).toBe("促销");
|
||||
expect(other.body.data.warnings?.[0]).toContain("elsewhere.example");
|
||||
// IA-2:RSS 属于站点。不带站点新建 → 归入同主机的「自定义 RSS 源」站点(没有就建一个)
|
||||
const bare = await json(await h.req("/feed-sources", { body: { url: "https://any.example/feed.xml" } }));
|
||||
expect(bare.body.data).toMatchObject({
|
||||
expect(bare.body.data).toMatchObject({ name: "any.example RSS", siteAccountId: null, parserVersion: "rss@1" });
|
||||
const bareSite = await json(await h.req(`/sites/${bare.body.data.siteId}`));
|
||||
expect(bareSite.body.data).toMatchObject({
|
||||
key: "rss-any-example",
|
||||
name: "any.example",
|
||||
siteId: null,
|
||||
siteAccountId: null,
|
||||
parserVersion: "1",
|
||||
kind: "rss_feed",
|
||||
adapter: "generic_rss",
|
||||
capabilities: ["rss"],
|
||||
searchDefault: false,
|
||||
});
|
||||
const again = await json(await h.req("/feed-sources", { body: { url: "https://any.example/other.xml" } }));
|
||||
expect(again.body.data.siteId).toBe(bare.body.data.siteId);
|
||||
// 不能把 RSS 移出站点
|
||||
const detach = await h.req(`/feed-sources/${bare.body.data.id}`, { method: "PATCH", body: { siteId: null } });
|
||||
expect(detach.status).toBe(400);
|
||||
});
|
||||
|
||||
it("快照:列表页 upsert 到 source_release,记录覆盖范围;站点级限流器每站一个实例", async () => {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { createMigrator, type DB, migrateToLatest, sql } from "@umt/db";
|
||||
import { Kysely, PostgresDialect } from "kysely";
|
||||
import pg from "pg";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { PG_URL, pgAvailable, withSchemaLock } from "../auth-settings/harness.ts";
|
||||
|
||||
// 0055:site.kind;generic_rss 站点 → rss_feed;不属于任何站点的 RSS 按主机归入「自定义 RSS 源」站点。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
|
||||
describe.skipIf(!hasPg)("0055 站点类型迁移", () => {
|
||||
const schema = `ia2_mig_${process.pid.toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
const admin = async (q: string) => {
|
||||
const c = new pg.Client({ connectionString: PG_URL });
|
||||
await c.connect();
|
||||
await c.query(q).finally(() => c.end());
|
||||
};
|
||||
let db: Kysely<DB>;
|
||||
|
||||
afterAll(async () => {
|
||||
await db?.destroy();
|
||||
await withSchemaLock(() => admin(`drop schema if exists ${schema} cascade`));
|
||||
});
|
||||
|
||||
it("存量站点定类型;孤立 RSS 按主机建站点、清游标窗口;回滚只删列", async () => {
|
||||
await admin(`create schema ${schema}`);
|
||||
db = new Kysely<DB>({
|
||||
dialect: new PostgresDialect({
|
||||
pool: new pg.Pool({ connectionString: PG_URL, max: 2, options: `-c search_path=${schema},public` }),
|
||||
}),
|
||||
});
|
||||
await withSchemaLock(async () => {
|
||||
const r = await createMigrator(db, { migrationTableSchema: schema }).migrateTo("0054_download_task_entity");
|
||||
if (r.error) throw r.error;
|
||||
});
|
||||
await sql`insert into site (key, name, adapter, base_url) values
|
||||
('audiences', '观众', 'nexusphp', 'https://audiences.example/'),
|
||||
('myrss', '旧自定义', 'generic_rss', 'https://old.example/'),
|
||||
('rss-nyaa-si', '占用', 'generic_rss', 'https://taken.example/')`.execute(db);
|
||||
const cursor = JSON.stringify({ window: { lastGuidSet: ["a"], lastFetchedAt: "2026-09-01T00:00:00Z" } });
|
||||
await sql`insert into feed_source (site_id, name, url_display, etag, cursor) values
|
||||
(null, 'A', 'https://nyaa.si/?page=rss&q=frieren', 'W/"1"', ${cursor}::jsonb),
|
||||
(null, 'B', 'https://nyaa.si/?page=rss&q=dune', null, ${cursor}::jsonb),
|
||||
(null, 'C', 'https://www.example.org/feed?passkey=[REDACTED]', null, '{}'::jsonb),
|
||||
((select id from site where key = 'audiences'), 'D', 'https://audiences.example/rss', 'W/"2"', ${cursor}::jsonb)`.execute(
|
||||
db,
|
||||
);
|
||||
|
||||
await withSchemaLock(() => migrateToLatest(db, { migrationTableSchema: schema }));
|
||||
|
||||
const sites = await sql<{ key: string; name: string; kind: string; base_url: string; search_default: boolean }>`
|
||||
select key, name, kind, base_url, search_default from site order by key`.execute(db);
|
||||
expect(sites.rows).toEqual([
|
||||
{ key: "audiences", name: "观众", kind: "pt", base_url: "https://audiences.example/", search_default: true },
|
||||
{ key: "myrss", name: "旧自定义", kind: "rss_feed", base_url: "https://old.example/", search_default: true },
|
||||
{
|
||||
key: "rss-example-org",
|
||||
name: "www.example.org",
|
||||
kind: "rss_feed",
|
||||
base_url: "https://www.example.org",
|
||||
search_default: false,
|
||||
},
|
||||
{ key: "rss-nyaa-si", name: "占用", kind: "rss_feed", base_url: "https://taken.example/", search_default: true },
|
||||
{ key: "rss-nyaa-si-2", name: "nyaa.si", kind: "rss_feed", base_url: "https://nyaa.si", search_default: false },
|
||||
]);
|
||||
const feeds = await sql<{ name: string; key: string; etag: string | null; cursor: Record<string, unknown> }>`
|
||||
select f.name, s.key, f.etag, f.cursor from feed_source f join site s on s.id = f.site_id order by f.name`.execute(
|
||||
db,
|
||||
);
|
||||
expect(feeds.rows.map((f) => [f.name, f.key])).toEqual([
|
||||
["A", "rss-nyaa-si-2"],
|
||||
["B", "rss-nyaa-si-2"],
|
||||
["C", "rss-example-org"],
|
||||
["D", "audiences"],
|
||||
]);
|
||||
// 迁移过来的 RSS 清掉窗口与条件请求头(下次抓取重新写成资源);原本属于站点的不动
|
||||
expect(feeds.rows[0]).toMatchObject({ etag: null, cursor: {} });
|
||||
expect(feeds.rows[3]!.etag).toBe('W/"2"');
|
||||
expect(feeds.rows[3]!.cursor).toHaveProperty("window");
|
||||
|
||||
await withSchemaLock(async () => {
|
||||
const r = await createMigrator(db, { migrationTableSchema: schema }).migrateTo("0054_download_task_entity");
|
||||
if (r.error) throw r.error;
|
||||
});
|
||||
const cols = await sql<{ n: number }>`select count(*)::int as n from information_schema.columns
|
||||
where table_schema = ${schema} and table_name = 'site' and column_name = 'kind'`.execute(db);
|
||||
expect(cols.rows[0]!.n).toBe(0);
|
||||
await withSchemaLock(() => migrateToLatest(db, { migrationTableSchema: schema }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import { SITE_KINDS, siteCapabilities } from "@umt/contracts";
|
||||
import { createMockRssServer, type MockRssServer } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { tickFeeds, tickSnapshots } from "../../src/modules/sources-identity/scheduler.ts";
|
||||
import { ingestFeed } from "../../src/modules/sources-identity/service/feeds.ts";
|
||||
import { snapshotSite } from "../../src/modules/sources-identity/service/snapshot.ts";
|
||||
import { capabilitiesOf, hostSlug } from "../../src/modules/sources-identity/site-kinds.ts";
|
||||
import { createHarness, type Harness, json, pgAvailable, seedFeed, seedSite } from "./harness.ts";
|
||||
|
||||
// IA-2 站点类型:类型声明获取方式,实际可用 = 类型 ∩ 站点定义;调度 / 搜索 / 登录按 capabilities 判断。
|
||||
|
||||
describe("站点类型与获取方式(纯函数)", () => {
|
||||
it("实际可用 = 类型允许的 ∩ 定义实现的;RSS 不依赖定义;torznab 未实现为空", () => {
|
||||
const def = { search: true, list: true, detail: false };
|
||||
expect(siteCapabilities("pt", def)).toEqual(["search", "list", "rss", "login", "userinfo"]);
|
||||
expect(siteCapabilities("pt", null)).toEqual(["rss"]);
|
||||
expect(siteCapabilities("public_bt", def)).toEqual(["search", "list", "rss"]);
|
||||
expect(siteCapabilities("public_bt", null)).toEqual(["rss"]);
|
||||
expect(siteCapabilities("rss_feed", def)).toEqual(["rss"]);
|
||||
expect(siteCapabilities("torznab", def)).toEqual([]);
|
||||
expect(SITE_KINDS.torznab.implemented).toBe(false);
|
||||
expect(SITE_KINDS.rss_feed.capabilities).toEqual(["rss"]);
|
||||
});
|
||||
|
||||
it("generic_rss 站点不查定义;key 片段", () => {
|
||||
expect(capabilitiesOf({ kind: "pt", key: "audiences", adapter: "generic_rss" })).toEqual(["rss"]);
|
||||
expect(capabilitiesOf({ kind: "pt", key: "audiences", adapter: "nexusphp" })).toContain("login");
|
||||
expect(hostSlug("www.Nyaa.si:8443")).toBe("nyaa-si-8443");
|
||||
expect(hostSlug("")).toBe("feed");
|
||||
});
|
||||
});
|
||||
|
||||
const up = await pgAvailable();
|
||||
|
||||
describe.skipIf(!up)("站点类型:API、调度与 RSS", () => {
|
||||
let h: Harness;
|
||||
let rss: MockRssServer;
|
||||
|
||||
beforeAll(async () => {
|
||||
h = await createHarness("kinds");
|
||||
rss = await createMockRssServer({
|
||||
items: [
|
||||
{
|
||||
guid: "nyaa-1",
|
||||
title: "[SubsPlease] Frieren - 12 (1080p) [ABCDEF12].mkv",
|
||||
link: "https://nyaa.example/view/1812345",
|
||||
enclosureUrl: "https://nyaa.example/download/1812345.torrent",
|
||||
size: 1_400_000_000,
|
||||
pubDate: new Date("2026-09-20T08:00:00Z"),
|
||||
categories: ["Anime"],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
afterAll(async () => {
|
||||
await rss?.close();
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
it("添加自定义 RSS 源:一个事务建站点 + RSS,key 按主机生成,不参与默认搜索", async () => {
|
||||
const r = await json(
|
||||
await h.req("/sites", {
|
||||
body: {
|
||||
kind: "rss_feed",
|
||||
adapter: "generic_rss",
|
||||
name: "Nyaa",
|
||||
baseUrl: "https://nyaa.example/",
|
||||
rssUrl: rss.feedUrl,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.data).toMatchObject({
|
||||
kind: "rss_feed",
|
||||
capabilities: ["rss"],
|
||||
searchDefault: false,
|
||||
snapshotEnabled: false,
|
||||
});
|
||||
expect(r.body.data.key).toMatch(/^rss-/);
|
||||
const feeds = await json(await h.req(`/feed-sources?siteId=${r.body.data.id}`));
|
||||
expect(feeds.body.data.items).toHaveLength(1);
|
||||
expect(feeds.body.data.items[0]).toMatchObject({ name: "Nyaa RSS", siteId: r.body.data.id });
|
||||
|
||||
// 抓取:自定义 RSS 源的条目按站点聚合成资源(以前无站点的 Feed 只留观察)
|
||||
const res = await ingestFeed(h.rt, feeds.body.data.items[0].id);
|
||||
expect(res).toMatchObject({ status: "ok", newReleases: 1 });
|
||||
const rel = await h.db.selectFrom("source_release").selectAll().where("site_id", "=", r.body.data.id).execute();
|
||||
expect(rel).toHaveLength(1);
|
||||
expect(rel[0]!.title).toContain("Frieren");
|
||||
});
|
||||
|
||||
it("不支持的获取方式 400:自定义 RSS 源不能登录 / 填凭证 / 抓列表;torznab 不能添加;PT 站缺 key 400", async () => {
|
||||
const list = await json(await h.req("/sites"));
|
||||
const feedSite = list.body.data.items.find((s: { kind: string }) => s.kind === "rss_feed");
|
||||
for (const [path, body] of [
|
||||
[`/sites/${feedSite.id}/refresh-login`, {}],
|
||||
[`/sites/${feedSite.id}/snapshot`, {}],
|
||||
] as const) {
|
||||
const res = await json(await h.req(path, { body }));
|
||||
expect(res.status, path).toBe(400);
|
||||
expect(res.body.error.message).toContain("自定义 RSS 源");
|
||||
}
|
||||
const cred = await h.req(`/sites/${feedSite.id}/credentials`, {
|
||||
method: "PUT",
|
||||
body: { kind: "cookie", cookie: "c=1" },
|
||||
});
|
||||
expect(cred.status).toBe(400);
|
||||
const tz = await h.req("/sites", {
|
||||
body: { kind: "torznab", adapter: "generic_rss", name: "Jackett", baseUrl: "https://jackett.example/" },
|
||||
});
|
||||
expect(tz.status).toBe(400);
|
||||
const noKey = await h.req("/sites", {
|
||||
body: { kind: "pt", adapter: "nexusphp", name: "x", baseUrl: "https://x.example/" },
|
||||
});
|
||||
expect(noKey.status).toBe(400);
|
||||
const pubWrongAdapter = await h.req("/sites", {
|
||||
body: { kind: "public_bt", adapter: "nexusphp", name: "x", key: "audiences", baseUrl: "https://x.example/" },
|
||||
});
|
||||
expect(pubWrongAdapter.status).toBe(400);
|
||||
});
|
||||
|
||||
it("站点定义都是 PT 站;PT 站带登录与账号数据", async () => {
|
||||
const defs = await json(await h.req("/site-definitions"));
|
||||
expect(defs.body.data.items.every((d: { kind: string }) => d.kind === "pt")).toBe(true);
|
||||
const pt = await json(
|
||||
await h.req("/sites", {
|
||||
body: { key: "audiences", name: "观众", adapter: "nexusphp", baseUrl: "https://audiences.invalid/" },
|
||||
}),
|
||||
);
|
||||
expect(pt.status).toBe(200);
|
||||
expect(pt.body.data.kind).toBe("pt");
|
||||
expect(pt.body.data.capabilities).toEqual(expect.arrayContaining(["search", "list", "rss", "login", "userinfo"]));
|
||||
});
|
||||
|
||||
it("调度按获取方式:快照只排能抓列表的站点;Feed 只排能抓 RSS 的站点", async () => {
|
||||
const pt = await seedSite(h, { key: "hdhome", baseUrl: "https://hdhome.invalid/" });
|
||||
// 自定义 RSS 源即使 snapshot_enabled 被写成 true 也不排快照
|
||||
const feedOnly = await h.db
|
||||
.insertInto("site")
|
||||
.values({
|
||||
key: "rss-only",
|
||||
name: "only",
|
||||
kind: "rss_feed",
|
||||
adapter: "generic_rss",
|
||||
base_url: "https://only.invalid/",
|
||||
snapshot_enabled: true,
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
const tz = await h.db
|
||||
.insertInto("site")
|
||||
.values({ key: "tz", name: "tz", kind: "torznab", adapter: "generic_rss", base_url: "https://tz.invalid/" })
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
const tzFeed = await seedFeed(h, { siteId: tz.id, url: "https://tz.invalid/api?t=search" });
|
||||
const okFeed = await seedFeed(h, { siteId: feedOnly.id, url: "https://only.invalid/rss" });
|
||||
|
||||
h.engine.enqueued.length = 0;
|
||||
await tickSnapshots(h.rt);
|
||||
const snapSites = h.engine.enqueued
|
||||
.filter((e) => e.task === "snapshotSite")
|
||||
.map((e) => (e.input as { siteId: string }).siteId);
|
||||
expect(snapSites).toContain(pt.site.id);
|
||||
expect(snapSites).not.toContain(feedOnly.id);
|
||||
|
||||
await tickFeeds(h.rt);
|
||||
const due = (await h.outbox("feed.due")).map((e) => e.payload.feedId);
|
||||
expect(due).toContain(okFeed.id);
|
||||
expect(due).not.toContain(tzFeed.id);
|
||||
expect((await ingestFeed(h.rt, tzFeed.id)).reason).toBe("unsupported");
|
||||
|
||||
// 实时搜索 / 列表快照在不支持的站点上直接跳过,不请求站点
|
||||
expect(await snapshotSite(h.rt, { siteId: feedOnly.id, kind: "live_search", query: "x" })).toMatchObject({
|
||||
status: "skipped",
|
||||
reason: "unsupported",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type FeedSourceCreate,
|
||||
type FeedSourceUpdate,
|
||||
Id,
|
||||
Observation,
|
||||
} from "@umt/contracts";
|
||||
import { z } from "zod";
|
||||
import { TTL_SECOND } from "~/lib/api.ts";
|
||||
@@ -40,6 +41,18 @@ export function useFeed(id: string | null, poll = false) {
|
||||
});
|
||||
}
|
||||
|
||||
const ObservationPage = cursorPage(Observation);
|
||||
|
||||
/** RSS 最近条目(观察记录,新的在前);只在详情打开时请求 */
|
||||
export function useFeedObservations(feedId: string | null, limit = 20) {
|
||||
return useQuery({
|
||||
queryKey: [...feedKeys.all, "observations", feedId, limit] as const,
|
||||
enabled: !!feedId,
|
||||
queryFn: ({ signal }) =>
|
||||
apiParsed(ObservationPage, "/api/v1/observations", { params: { feedId: feedId ?? "", limit }, signal }),
|
||||
});
|
||||
}
|
||||
|
||||
/** 同站判定的主机键(与服务端 feedDefaultsForSite 一致):去 www.,取最后两级;IP 原样 */
|
||||
export function feedHostKey(u: string): string | null {
|
||||
try {
|
||||
|
||||
@@ -151,7 +151,11 @@ export function useCreateSite() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: SiteCreate) => apiMutate("/api/v1/sites", { body }).then((d) => Site.parse(d)),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: siteKeys.all }),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: siteKeys.all });
|
||||
// 非 PT 站添加时可能一并建了 RSS
|
||||
void qc.invalidateQueries({ queryKey: ["feeds"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,11 @@ const RULES: Rule[] = [
|
||||
},
|
||||
{
|
||||
match: /^\/console\/sites\/[^/]+/,
|
||||
goals: ["从这个站点找 ", "看看这个站点最近有哪些新的 4K 资源", "检查这个站点的登录和抓取是否正常"],
|
||||
goals: ["从这个站点找 ", "看看这个站点最近有哪些新的 4K 资源", "检查这个站点的登录和 RSS 抓取是否正常"],
|
||||
},
|
||||
{
|
||||
match: /^\/console\/sites\/?$/,
|
||||
goals: ["在所有站点里找 ", "哪个站点最近抓取失败了,原因是什么", "对比各站点最近一周的新资源数量"],
|
||||
goals: ["在所有站点里找 ", "哪些站点或 RSS 最近抓取失败了,原因是什么", "对比各站点最近一周的新资源数量"],
|
||||
},
|
||||
{
|
||||
match: /^\/console\/tasks\/[^/]+/,
|
||||
@@ -48,10 +48,6 @@ const RULES: Rule[] = [
|
||||
match: /^\/console\/subscriptions/,
|
||||
goals: ["帮我新建一个订阅:", "哪些订阅很久没有新收录了", "检查订阅的收录版本是否合理"],
|
||||
},
|
||||
{
|
||||
match: /^\/console\/sources/,
|
||||
goals: ["哪些来源不新鲜了,原因是什么", "检查所有 RSS 的抓取状态", "最近一天各来源抓到了什么"],
|
||||
},
|
||||
{
|
||||
match: /^\/console\/engine/,
|
||||
goals: ["这个任务为什么失败", "哪些工作流一直在重试", "汇总最近一小时失败的运行"],
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { LEGACY_REDIRECTS, legacyRedirect } from "../../routes/console/redirects.ts";
|
||||
import { consolePath, findNavItem, NAV_GROUPS, NAV_ITEMS, pageTitle, settingsPath } from "./nav.ts";
|
||||
import {
|
||||
ACQUIRE_GROUP_TITLE,
|
||||
consolePath,
|
||||
findNavItem,
|
||||
NAV_GROUPS,
|
||||
NAV_ITEMS,
|
||||
pageTitle,
|
||||
settingsPath,
|
||||
} from "./nav.ts";
|
||||
|
||||
/** routes.ts 里登记的控制台路由(相对 /console) */
|
||||
const ROUTE_PATTERNS = [
|
||||
@@ -16,17 +24,17 @@ const matchesRoute = (pathname: string) => {
|
||||
};
|
||||
|
||||
describe("控制台导航", () => {
|
||||
it("IA-3:5 组,按定稿清单逐项列出,路径唯一且都在 /console 下", () => {
|
||||
expect(NAV_GROUPS.map((g) => g.title)).toEqual(["概览", "资源", "订阅", "整理", "系统"]);
|
||||
it("IA-2:4 组,任务归系统,站点 / 订阅 / 规则合为「获取」,没有独立 RSS 项", () => {
|
||||
expect(NAV_GROUPS.map((g) => g.title)).toEqual(["概览", ACQUIRE_GROUP_TITLE, "整理", "系统"]);
|
||||
expect(ACQUIRE_GROUP_TITLE).toBe("获取");
|
||||
expect(NAV_GROUPS.map((g) => g.items.map((i) => i.label))).toEqual([
|
||||
["首页", "任务", "计划", "智能体会话"],
|
||||
["站点", "RSS"],
|
||||
["订阅", "规则"],
|
||||
["首页", "计划", "智能体会话"],
|
||||
["站点", "订阅", "规则"],
|
||||
["下载面板", "整理记录", "文件管理", "媒体库"],
|
||||
["设置", "审计", "运行状态"],
|
||||
["任务", "设置", "审计", "运行状态"],
|
||||
]);
|
||||
// IA-3 逐项清单 14 项 + FE-6 计划页
|
||||
expect(NAV_ITEMS).toHaveLength(15);
|
||||
expect(NAV_ITEMS).toHaveLength(14);
|
||||
expect(NAV_ITEMS.map((i) => i.to)).not.toContain("/console/sources");
|
||||
expect(new Set(NAV_ITEMS.map((i) => i.to)).size).toBe(NAV_ITEMS.length);
|
||||
expect(NAV_ITEMS.every((i) => i.to.startsWith("/console"))).toBe(true);
|
||||
});
|
||||
@@ -87,6 +95,7 @@ describe("旧链接重定向", () => {
|
||||
expect(legacyRedirect("/console/tasks", "?tab=failed")).toBeNull();
|
||||
expect(legacyRedirect("/console/library", "?filter=unidentified")).toBeNull();
|
||||
expect(legacyRedirect("/console/subscriptions", "")).toBeNull();
|
||||
expect(legacyRedirect("/console/sources", "?health=failing")).toBeNull();
|
||||
// 旧 RSS 页的 ?feed= 由 sources.tsx 查所属站点后跳转
|
||||
expect(legacyRedirect("/console/sources", "?feed=f1")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
ListChecks,
|
||||
ListTodo,
|
||||
type LucideIcon,
|
||||
Rss,
|
||||
ScrollText,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
@@ -35,7 +34,13 @@ export function consolePath(...segments: (string | number)[]): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 侧栏 5 组 15 项(IA-3 定稿清单 14 项 + FE-6 从任务页拆出的"计划",docs/plan/00-first-version-plan.md §8):概览 → 资源 → 订阅 → 整理 → 系统。
|
||||
* 「获取」组的组名(IA-2):站点、订阅、规则合成一组。用户原话"都算做规则",组名可能再改,只改这一处。
|
||||
*/
|
||||
export const ACQUIRE_GROUP_TITLE = "获取";
|
||||
|
||||
/**
|
||||
* 侧栏 4 组 14 项(IA-2,docs/plan/00-first-version-plan.md 末尾 IA-2 条目):概览 → 获取 → 整理 → 系统。
|
||||
* 任务归系统;RSS 属于站点(站点详情的 RSS Tab),不再单独占一项。
|
||||
* 待处理不进侧栏,由顶栏收件箱打开;移动端抽屉用同一份表。
|
||||
*/
|
||||
export const NAV_GROUPS: NavGroup[] = [
|
||||
@@ -48,12 +53,6 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
icon: House,
|
||||
description: "在忙什么、要我处理什么、最近入库了什么",
|
||||
},
|
||||
{
|
||||
to: "/console/tasks",
|
||||
label: "任务",
|
||||
icon: ListTodo,
|
||||
description: "最近处理了什么、有没有失败",
|
||||
},
|
||||
{
|
||||
to: "/console/schedules",
|
||||
label: "计划",
|
||||
@@ -69,20 +68,14 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "资源",
|
||||
title: ACQUIRE_GROUP_TITLE,
|
||||
items: [
|
||||
{
|
||||
to: "/console/sites",
|
||||
label: "站点",
|
||||
icon: Globe,
|
||||
description: "站点账号、资源与 RSS",
|
||||
description: "PT 站、公开 BT 站与 RSS 源:账号、资源与 RSS",
|
||||
},
|
||||
{ to: "/console/sources", label: "RSS", icon: Rss, description: "全部 RSS 与抓取状态" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "订阅",
|
||||
items: [
|
||||
{ to: "/console/subscriptions", label: "订阅", icon: CalendarClock, description: "单次收录与持续追踪" },
|
||||
{ to: "/console/rules", label: "规则", icon: ListChecks, description: "收录策略与偏好标签" },
|
||||
],
|
||||
@@ -114,6 +107,12 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
title: "系统",
|
||||
items: [
|
||||
{
|
||||
to: "/console/tasks",
|
||||
label: "任务",
|
||||
icon: ListTodo,
|
||||
description: "最近处理了什么、有没有失败",
|
||||
},
|
||||
{
|
||||
to: "/console/settings",
|
||||
label: "设置",
|
||||
|
||||
@@ -81,7 +81,8 @@ export function subjectLink(t: Pick<Task, "subjectType" | "subjectId">): string
|
||||
case "site":
|
||||
return consolePath("sites", t.subjectId);
|
||||
case "feed_source":
|
||||
return consolePath("sources");
|
||||
// RSS 属于站点(IA-2):按 ?feed= 查所属站点后跳到站点的 RSS Tab
|
||||
return `${consolePath("sources")}?feed=${encodeURIComponent(t.subjectId)}`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -75,7 +75,10 @@ export function SitePickerDialog({
|
||||
}) {
|
||||
const sitesQ = useSiteOptions();
|
||||
const settings = useSettings();
|
||||
const sites = useMemo(() => (sitesQ.data?.items ?? []).filter((s) => s.enabled), [sitesQ.data]);
|
||||
const sites = useMemo(
|
||||
() => (sitesQ.data?.items ?? []).filter((s) => s.enabled && s.capabilities.includes("search")),
|
||||
[sitesQ.data],
|
||||
);
|
||||
const [pick, setPick] = useState<SitePick | null>(null);
|
||||
// 打开时取初始值:本机上次 > 全局设置 > 默认
|
||||
useEffect(() => {
|
||||
|
||||
@@ -42,7 +42,7 @@ export function SearchDefaultDialog({
|
||||
setMode(settings.data.search?.defaultSiteMode ?? "default");
|
||||
setIds(settings.data.search?.siteIds ?? []);
|
||||
}, [open, settings.data]);
|
||||
const enabled = sites.filter((s) => s.enabled);
|
||||
const enabled = sites.filter((s) => s.enabled && s.capabilities.includes("search"));
|
||||
|
||||
const save = () =>
|
||||
update.mutate(
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import type { SiteDefinitionView, SiteHealthResult, SiteListItem, SiteLoginResult } from "@umt/contracts";
|
||||
import { ArrowLeft, Check, LoaderCircle, Search } from "lucide-react";
|
||||
import {
|
||||
cursorPage,
|
||||
FeedSource,
|
||||
SITE_KINDS,
|
||||
type SiteDefinitionView,
|
||||
type SiteHealthResult,
|
||||
type SiteKind,
|
||||
type SiteListItem,
|
||||
type SiteLoginResult,
|
||||
} from "@umt/contracts";
|
||||
import { ArrowLeft, Check, Globe, LoaderCircle, Rss, Search, Server, ShieldCheck } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useFetchFeedNow } from "~/api/feeds.ts";
|
||||
import { apiParsed } from "~/api/parse.ts";
|
||||
import {
|
||||
useCreateSite,
|
||||
useHealthCheck,
|
||||
@@ -31,12 +42,26 @@ import { type CredentialDraft, CredentialFields, credentialBody, emptyCredential
|
||||
import { LoginResultView } from "./site-dialogs.tsx";
|
||||
import { ADAPTER_LABEL, hostOf, type LoginKind, supportedKinds } from "./site-fields.ts";
|
||||
|
||||
// 添加站点向导(site-center §4.3):① 选定义 → ② 名称 / 域名 / 凭证 → ③ 测试(保存站点与凭证,刷新登录 + 检测健康)
|
||||
// 添加站点向导(site-center §4.3 + IA-2 站点类型):
|
||||
// ⓪ 选类型 → PT 站:① 选定义 → ② 名称 / 域名 / 凭证 → ③ 测试(保存站点与凭证,刷新登录 + 检测健康);
|
||||
// 公开 BT 站 / 自定义 RSS 源:① 名称 / 地址 / RSS 地址 → 保存后立即抓一次,进站点的 RSS Tab。
|
||||
// 旧的"未适配站点(自定义 RSS 站点)"入口并入「自定义 RSS 源」类型。
|
||||
|
||||
const CUSTOM = "__custom_rss";
|
||||
type Step = 1 | 2 | 3;
|
||||
const STEP_LABEL = ["选择站点", "填写凭证", "测试"] as const;
|
||||
type Step = 0 | 1 | 2 | 3;
|
||||
const PT_STEPS = ["选择类型", "选择站点", "填写凭证", "测试"] as const;
|
||||
const FEED_STEPS = ["选择类型", "填写地址"] as const;
|
||||
const GROUP_ORDER = ["nexusphp", "mteam_api", "api", "generic_rss"] as const;
|
||||
const FeedPage = cursorPage(FeedSource);
|
||||
const KIND_ORDER: SiteKind[] = ["pt", "public_bt", "rss_feed", "torznab"];
|
||||
const KIND_ICON = { pt: ShieldCheck, public_bt: Globe, rss_feed: Rss, torznab: Server } as const;
|
||||
|
||||
function isHttpUrl(v: string): boolean {
|
||||
try {
|
||||
return /^https?:$/.test(new URL(v.trim()).protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 馒头默认 API Key,其余默认 Cookie(验证码站点账密大概率失败) */
|
||||
function defaultKind(kinds: LoginKind[]): LoginKind {
|
||||
@@ -44,10 +69,13 @@ function defaultKind(kinds: LoginKind[]): LoginKind {
|
||||
return kinds.includes("cookie") ? "cookie" : (kinds[0] ?? "cookie");
|
||||
}
|
||||
|
||||
export function SiteAddDialog({ onClose }: { onClose: () => void }) {
|
||||
export function SiteAddDialog({ onClose, initialKind }: { onClose: () => void; initialKind?: SiteKind | null }) {
|
||||
const defs = useSiteDefinitions();
|
||||
const navigate = useNavigate();
|
||||
const [step, setStep] = useState<Step>(1);
|
||||
const startKind = initialKind && SITE_KINDS[initialKind]?.implemented ? initialKind : null;
|
||||
const [kind, setKind] = useState<SiteKind | null>(startKind);
|
||||
const [step, setStep] = useState<Step>(startKind ? 1 : 0);
|
||||
const [rssUrl, setRssUrl] = useState("");
|
||||
const [q, setQ] = useState("");
|
||||
const [defKey, setDefKey] = useState<string | null>(null);
|
||||
const [form, setForm] = useState({ name: "", baseUrl: "", key: "" });
|
||||
@@ -63,11 +91,55 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) {
|
||||
const put = usePutCredentials();
|
||||
const refresh = useRefreshLogin();
|
||||
const check = useHealthCheck();
|
||||
const fetchFeed = useFetchFeedNow();
|
||||
const feedKind = kind !== null && kind !== "pt";
|
||||
const stepLabels: readonly string[] = feedKind ? FEED_STEPS : PT_STEPS;
|
||||
|
||||
const chooseKind = (k: SiteKind) => {
|
||||
setKind(k);
|
||||
setErrors({});
|
||||
if (k !== "pt") setForm({ name: "", baseUrl: "", key: "" });
|
||||
setStep(1);
|
||||
};
|
||||
|
||||
/** 公开 BT 站 / 自定义 RSS 源:建站点 + RSS(服务端一个事务),立即抓一次,进 RSS Tab */
|
||||
const saveFeedSite = async () => {
|
||||
const fe: Record<string, string> = {};
|
||||
const url = rssUrl.trim();
|
||||
const base = form.baseUrl.trim();
|
||||
if (!form.name.trim()) fe.name = "填写名称";
|
||||
if (kind === "rss_feed" && !isHttpUrl(url)) fe.rssUrl = "填写完整的 RSS 地址(http / https)";
|
||||
if (kind === "public_bt" && url && !isHttpUrl(url)) fe.rssUrl = "填写完整的 RSS 地址(http / https)";
|
||||
if (kind === "public_bt" && !isHttpUrl(base)) fe.baseUrl = "填完整地址,如 https://example.org";
|
||||
if (kind === "rss_feed" && base && !isHttpUrl(base)) fe.baseUrl = "填完整地址,如 https://example.org";
|
||||
setErrors(fe);
|
||||
if (Object.keys(fe).length || !kind) return;
|
||||
try {
|
||||
setPhase("保存中…");
|
||||
const site = await create.mutateAsync({
|
||||
kind,
|
||||
adapter: "generic_rss",
|
||||
name: form.name.trim(),
|
||||
baseUrl: base || new URL(url).origin,
|
||||
...(url ? { rssUrl: url } : {}),
|
||||
});
|
||||
if (url) {
|
||||
const feeds = await apiParsed(FeedPage, "/api/v1/feed-sources", { params: { siteId: site.id } });
|
||||
for (const f of feeds.items) fetchFeed.mutate(f.id);
|
||||
}
|
||||
toast.success(`已添加「${site.name}」`);
|
||||
onClose();
|
||||
navigate(`${consolePath("sites", site.id)}?tab=rss`);
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "保存失败"));
|
||||
} finally {
|
||||
setPhase(null);
|
||||
}
|
||||
};
|
||||
|
||||
const items = defs.data?.items ?? [];
|
||||
const def = items.find((d) => d.key === defKey);
|
||||
const custom = defKey === CUSTOM;
|
||||
const kinds: LoginKind[] = custom ? [] : supportedKinds(def);
|
||||
const kinds: LoginKind[] = supportedKinds(def);
|
||||
const busy = phase !== null;
|
||||
|
||||
const groups = useMemo(() => {
|
||||
@@ -88,6 +160,93 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) {
|
||||
setErrors({});
|
||||
setStep(2);
|
||||
};
|
||||
const kindBody = (
|
||||
<ul className="grid gap-2 sm:grid-cols-2">
|
||||
{KIND_ORDER.map((k) => {
|
||||
const spec = SITE_KINDS[k];
|
||||
const Icon = KIND_ICON[k];
|
||||
return (
|
||||
<li key={k}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!spec.implemented}
|
||||
onClick={() => chooseKind(k)}
|
||||
className="flex h-full w-full min-w-0 items-start gap-3 rounded-lg border border-border/70 px-3 py-3 text-left transition-colors hover:border-foreground/40 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent"
|
||||
>
|
||||
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0">
|
||||
<span className="flex items-center gap-2 text-caption font-medium text-foreground">
|
||||
{spec.label}
|
||||
{!spec.implemented ? <StatusBadge className="shrink-0">未支持</StatusBadge> : null}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-meta text-muted-foreground">{spec.description}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
const feedBody = (
|
||||
<form
|
||||
id="site-add-feed"
|
||||
noValidate
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void saveFeedSite();
|
||||
}}
|
||||
className="min-w-0"
|
||||
>
|
||||
<FormField label="名称" htmlFor="add-feed-name" required error={errors.name}>
|
||||
<Input
|
||||
id="add-feed-name"
|
||||
autoFocus
|
||||
value={form.name}
|
||||
aria-invalid={!!errors.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
disabled={busy}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="RSS 地址"
|
||||
htmlFor="add-feed-rss"
|
||||
required={kind === "rss_feed"}
|
||||
error={errors.rssUrl}
|
||||
hint={kind === "public_bt" ? "可选;之后可在站点的 RSS 里添加" : "地址加密保存,页面上只显示去掉密钥参数的地址"}
|
||||
>
|
||||
<Input
|
||||
id="add-feed-rss"
|
||||
type="url"
|
||||
inputMode="url"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="https://"
|
||||
value={rssUrl}
|
||||
aria-invalid={!!errors.rssUrl}
|
||||
onChange={(e) => setRssUrl(e.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="网址"
|
||||
htmlFor="add-feed-url"
|
||||
required={kind === "public_bt"}
|
||||
error={errors.baseUrl}
|
||||
hint={kind === "rss_feed" ? "可选;留空时用 RSS 地址的域名" : undefined}
|
||||
>
|
||||
<Input
|
||||
id="add-feed-url"
|
||||
inputMode="url"
|
||||
placeholder="https://"
|
||||
value={form.baseUrl}
|
||||
aria-invalid={!!errors.baseUrl}
|
||||
onChange={(e) => setForm((f) => ({ ...f, baseUrl: e.target.value }))}
|
||||
disabled={busy}
|
||||
/>
|
||||
</FormField>
|
||||
<p className="text-meta text-muted-foreground">不需要账号。保存后立即抓一次 RSS。</p>
|
||||
</form>
|
||||
);
|
||||
|
||||
const validate = () => {
|
||||
const fe: Record<string, string> = {};
|
||||
@@ -98,11 +257,10 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) {
|
||||
} catch {
|
||||
fe.baseUrl = "填完整地址,如 https://example.org";
|
||||
}
|
||||
if (custom && !/^[a-z][a-z0-9_-]*$/.test(form.key.trim())) fe.key = "小写字母开头,只含小写字母、数字、- 和 _";
|
||||
let body = null;
|
||||
if (kinds.length) {
|
||||
// 账号标签用站点标识:服务端 secret 名按标签生成且全局唯一,多站都叫 default 会冲突
|
||||
const b = credentialBody(cred, (custom ? form.key.trim() : def?.key) || "default");
|
||||
const b = credentialBody(cred, def?.key || "default");
|
||||
if (b.errors) Object.assign(fe, b.errors);
|
||||
body = b.body;
|
||||
}
|
||||
@@ -122,9 +280,10 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) {
|
||||
const base = { name: form.name.trim(), baseUrl: form.baseUrl.trim() };
|
||||
if (!site) {
|
||||
site = await create.mutateAsync({
|
||||
key: custom ? form.key.trim() : (def?.key ?? form.key),
|
||||
adapter: custom ? "generic_rss" : (def?.adapter ?? "nexusphp"),
|
||||
snapshotEnabled: !custom,
|
||||
kind: "pt",
|
||||
key: def?.key ?? form.key,
|
||||
adapter: def?.adapter ?? "nexusphp",
|
||||
snapshotEnabled: true,
|
||||
...base,
|
||||
});
|
||||
setCreated(site);
|
||||
@@ -161,25 +320,25 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) {
|
||||
<DialogTitle>添加站点</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<ol className="flex flex-wrap items-center gap-x-3 gap-y-1 text-meta">
|
||||
{STEP_LABEL.map((l, i) => (
|
||||
{stepLabels.map((l, i) => (
|
||||
<li
|
||||
key={l}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5",
|
||||
step === i + 1 ? "font-medium text-foreground" : "text-muted-foreground",
|
||||
step === i ? "font-medium text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-5 w-5 place-items-center rounded-full border text-micro",
|
||||
step > i + 1
|
||||
step > i
|
||||
? "border-foreground bg-foreground text-background"
|
||||
: step === i + 1
|
||||
: step === i
|
||||
? "border-foreground"
|
||||
: "border-border",
|
||||
)}
|
||||
>
|
||||
{step > i + 1 ? <Check className="h-3 w-3" /> : i + 1}
|
||||
{step > i ? <Check className="h-3 w-3" /> : i + 1}
|
||||
</span>
|
||||
{l}
|
||||
</li>
|
||||
@@ -188,7 +347,11 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{step === 1 ? (
|
||||
{step === 0 ? (
|
||||
kindBody
|
||||
) : feedKind ? (
|
||||
feedBody
|
||||
) : step === 1 ? (
|
||||
<div className="min-w-0">
|
||||
<div className="relative mb-3">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
@@ -251,14 +414,14 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) {
|
||||
) : null}
|
||||
<section>
|
||||
<h3 className="mb-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
|
||||
未适配站点
|
||||
没有这个站点
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pick(CUSTOM)}
|
||||
onClick={() => chooseKind("rss_feed")}
|
||||
className="w-full rounded-lg border border-dashed border-border px-3 py-2 text-left text-caption text-muted-foreground hover:border-foreground/40 hover:text-foreground"
|
||||
>
|
||||
自定义 RSS 站点:只抓 RSS,不登录
|
||||
按「自定义 RSS 源」添加:只抓 RSS,不登录
|
||||
</button>
|
||||
</section>
|
||||
</>
|
||||
@@ -288,13 +451,7 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) {
|
||||
disabled={busy}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="地址"
|
||||
htmlFor="add-url"
|
||||
required
|
||||
error={errors.baseUrl}
|
||||
hint={custom ? undefined : "站点换了域名时改这里"}
|
||||
>
|
||||
<FormField label="地址" htmlFor="add-url" required error={errors.baseUrl} hint="站点换了域名时改这里">
|
||||
<Input
|
||||
id="add-url"
|
||||
inputMode="url"
|
||||
@@ -305,18 +462,6 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) {
|
||||
disabled={busy}
|
||||
/>
|
||||
</FormField>
|
||||
{custom ? (
|
||||
<FormField label="标识" htmlFor="add-key" required error={errors.key} hint="添加后不能改">
|
||||
<Input
|
||||
id="add-key"
|
||||
className="font-mono"
|
||||
value={form.key}
|
||||
aria-invalid={!!errors.key}
|
||||
onChange={(e) => setForm((f) => ({ ...f, key: e.target.value }))}
|
||||
disabled={busy}
|
||||
/>
|
||||
</FormField>
|
||||
) : null}
|
||||
</div>
|
||||
{kinds.length ? (
|
||||
<>
|
||||
@@ -335,7 +480,7 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) {
|
||||
</>
|
||||
) : (
|
||||
<p className="text-caption text-muted-foreground">
|
||||
不需要登录凭证。添加后在站点详情的"抓取与 RSS"里新建 RSS。
|
||||
不需要登录凭证。添加后在站点详情的「RSS」里新建 RSS。
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
@@ -362,7 +507,22 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) {
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
{step === 2 ? (
|
||||
{step === 0 ? (
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
) : feedKind ? (
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setStep(0)} disabled={busy}>
|
||||
<ArrowLeft />
|
||||
重选类型
|
||||
</Button>
|
||||
<Button type="submit" form="site-add-feed" disabled={busy}>
|
||||
{busy ? <LoaderCircle className="animate-spin" /> : null}
|
||||
保存
|
||||
</Button>
|
||||
</>
|
||||
) : step === 2 ? (
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setStep(1)} disabled={busy || !!created}>
|
||||
<ArrowLeft />
|
||||
@@ -384,8 +544,9 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) {
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
取消
|
||||
<Button variant="ghost" onClick={() => setStep(0)}>
|
||||
<ArrowLeft />
|
||||
重选类型
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useFeeds } from "~/api/feeds.ts";
|
||||
import { snapshotStale, useSnapshotSite, useToggleSite, useUpdateSite } from "~/api/sites.ts";
|
||||
import { DataTable } from "~/components/console/data-table.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
@@ -29,13 +30,16 @@ import {
|
||||
ACCOUNT_OVERALL,
|
||||
bytes,
|
||||
dataAccount,
|
||||
hasLogin,
|
||||
healthLine,
|
||||
healthMessage,
|
||||
hostOf,
|
||||
kindLabel,
|
||||
loginFailureText,
|
||||
overallStatus,
|
||||
primaryAccount,
|
||||
ratioText,
|
||||
siteCan,
|
||||
} from "./site-fields.ts";
|
||||
|
||||
type Checks = ReturnType<typeof useSiteChecks>;
|
||||
@@ -135,9 +139,12 @@ export function SiteMoreMenu({ site, onDialog }: { site: SiteListItem; onDialog:
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-44 p-1.5" role="menu">
|
||||
{item(<KeyRound />, updateCredLabel(site), () => onDialog({ kind: "credentials", site }))}
|
||||
{hasLogin(site)
|
||||
? item(<KeyRound />, updateCredLabel(site), () => onDialog({ kind: "credentials", site }))
|
||||
: null}
|
||||
{item(<Pencil />, "编辑站点", () => onDialog({ kind: "edit", site }))}
|
||||
{item(
|
||||
{siteCan(site, "list")
|
||||
? item(
|
||||
<Camera />,
|
||||
"抓取最新资源",
|
||||
() =>
|
||||
@@ -146,7 +153,8 @@ export function SiteMoreMenu({ site, onDialog }: { site: SiteListItem; onDialog:
|
||||
{ onSuccess: () => toast.success("已开始抓取"), onError: (e) => toast.error(errorMessage(e)) },
|
||||
),
|
||||
{ disabled: !site.enabled },
|
||||
)}
|
||||
)
|
||||
: null}
|
||||
{item(<Power />, site.enabled ? "停用站点" : "启用站点", () =>
|
||||
toggle.mutate(
|
||||
{ id: site.id, enabled: !site.enabled },
|
||||
@@ -194,6 +202,7 @@ function CheckButtons({ site, checks, compact }: Omit<Props, "onDialog"> & { com
|
||||
}
|
||||
|
||||
function AccountBadges({ site }: { site: SiteListItem }) {
|
||||
if (!hasLogin(site)) return null;
|
||||
const st = ACCOUNT_OVERALL[overallStatus(site)];
|
||||
// 正常不标;登录方式不做徽章(DEAI-2)
|
||||
return st.tone === "success" ? null : <StatusBadge tone={st.tone}>账号{st.label}</StatusBadge>;
|
||||
@@ -208,7 +217,73 @@ function Stat({ label, children, className }: { label: string; children: ReactNo
|
||||
);
|
||||
}
|
||||
|
||||
/** 没有登录的站点(公开 BT、自定义 RSS 源):卡片只看 RSS */
|
||||
function useSiteFeeds(siteId: string) {
|
||||
// 列表页所有卡片共用一次 /feed-sources 请求
|
||||
const feeds = useFeeds();
|
||||
const mine = (feeds.data?.items ?? []).filter((f) => f.siteId === siteId);
|
||||
const enabled = mine.filter((f) => f.enabled);
|
||||
const failing = enabled.filter((f) => f.healthStatus === "failing" || f.healthStatus === "circuit_open").length;
|
||||
const lastOk = mine
|
||||
.map((f) => f.lastSuccessAt)
|
||||
.filter((x): x is string => !!x)
|
||||
.sort()
|
||||
.at(-1);
|
||||
return { loaded: !!feeds.data, count: mine.length, enabled: enabled.length, failing, lastOk };
|
||||
}
|
||||
|
||||
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>
|
||||
<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}
|
||||
</div>
|
||||
<dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 rounded-lg border border-border/60 px-3 py-2.5 text-caption">
|
||||
<div className="min-w-0">
|
||||
<dt className="text-meta text-muted-foreground">RSS</dt>
|
||||
<dd className="font-medium text-foreground tabular-nums">
|
||||
{!f.loaded
|
||||
? "—"
|
||||
: f.count
|
||||
? `${f.count} 个${f.enabled !== f.count ? `,${f.enabled} 个启用` : ""}`
|
||||
: "还没有"}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<dt className="text-meta text-muted-foreground">上次成功</dt>
|
||||
<dd className="text-foreground">{relTime(f.lastOk)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<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">
|
||||
<Link to={`${consolePath("sites", site.id)}?tab=rss`}>{f.loaded && !f.count ? "添加 RSS" : "管理 RSS"}</Link>
|
||||
</Button>
|
||||
<SiteMoreMenu site={site} onDialog={onDialog} />
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function SiteCard({ site, checks, onDialog }: Props) {
|
||||
if (!hasLogin(site)) return <FeedSiteCard site={site} onDialog={onDialog} />;
|
||||
return <AccountSiteCard site={site} checks={checks} onDialog={onDialog} />;
|
||||
}
|
||||
|
||||
function AccountSiteCard({ site, checks, onDialog }: Props) {
|
||||
const acc = primaryAccount(site.accounts);
|
||||
const dAcc = dataAccount(site.accounts);
|
||||
const u = dAcc?.userData ?? null;
|
||||
@@ -242,6 +317,7 @@ export function SiteCard({ site, checks, onDialog }: Props) {
|
||||
</header>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||
{site.kind && site.kind !== "pt" ? <StatusBadge>{kindLabel(site.kind)}</StatusBadge> : null}
|
||||
<AccountBadges site={site} />
|
||||
{u?.vip ? <StatusBadge tone="neutral">VIP</StatusBadge> : null}
|
||||
{circuit ? <StatusBadge tone="warning">{circuit}</StatusBadge> : null}
|
||||
@@ -311,7 +387,7 @@ export function SiteCard({ site, checks, onDialog }: Props) {
|
||||
</dl>
|
||||
|
||||
<footer className="mt-4 flex flex-wrap items-center justify-end gap-2 border-t border-border/60 pt-3">
|
||||
<SearchDefaultSwitch site={site} />
|
||||
{siteCan(site, "search") ? <SearchDefaultSwitch site={site} /> : <span className="mr-auto" />}
|
||||
{acc ? (
|
||||
<CheckButtons site={site} checks={checks} />
|
||||
) : (
|
||||
@@ -371,6 +447,12 @@ export function SiteTable({
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "kind",
|
||||
header: "类型",
|
||||
hideBelow: "sm",
|
||||
cell: (s) => <span className="whitespace-nowrap text-muted-foreground">{kindLabel(s.kind)}</span>,
|
||||
},
|
||||
{
|
||||
key: "account",
|
||||
header: "账号",
|
||||
@@ -425,6 +507,7 @@ export function SiteTable({
|
||||
header: "状态",
|
||||
hideBelow: "md",
|
||||
cell: (s) => {
|
||||
if (!hasLogin(s)) return <span className="text-muted-foreground">—</span>;
|
||||
const h = healthLine(s.lastHealth, s.lastHealthAt);
|
||||
return (
|
||||
<span className="text-caption" title={healthMessage(s.lastHealth) ?? undefined}>
|
||||
@@ -440,7 +523,13 @@ export function SiteTable({
|
||||
hideBelow: "lg",
|
||||
cell: (s) => (
|
||||
<span className={s.snapshotEnabled && snapshotStale(s) ? "text-warning" : "text-muted-foreground"}>
|
||||
{!s.snapshotEnabled ? "未开启" : s.lastSnapshotAt ? relTime(s.lastSnapshotAt) : "还没有"}
|
||||
{!siteCan(s, "list")
|
||||
? "只有 RSS"
|
||||
: !s.snapshotEnabled
|
||||
? "未开启"
|
||||
: s.lastSnapshotAt
|
||||
? relTime(s.lastSnapshotAt)
|
||||
: "还没有"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
@@ -451,7 +540,7 @@ export function SiteTable({
|
||||
cell: (s) => (
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<EnableSwitch site={s} />
|
||||
{primaryAccount(s.accounts) ? <CheckButtons site={s} checks={checks} compact /> : null}
|
||||
{hasLogin(s) && primaryAccount(s.accounts) ? <CheckButtons site={s} checks={checks} compact /> : null}
|
||||
<SiteMoreMenu site={s} onDialog={onDialog} />
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -137,8 +137,10 @@ export function SiteFeedDialog({
|
||||
<span className="break-all">
|
||||
保存后显示为 <span className="font-mono">{shown}</span>
|
||||
</span>
|
||||
) : (
|
||||
) : site.kind === "pt" ? (
|
||||
"站点「控制面板 → RSS」里生成的链接"
|
||||
) : (
|
||||
"RSS 或 Atom 地址"
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -149,11 +151,13 @@ export function SiteFeedDialog({
|
||||
spellCheck={false}
|
||||
value={url}
|
||||
aria-invalid={!!error}
|
||||
placeholder={`${site.baseUrl.replace(/\/$/, "")}/torrentrss.php?passkey=…`}
|
||||
placeholder={
|
||||
site.kind === "pt" ? `${site.baseUrl.replace(/\/$/, "")}/torrentrss.php?passkey=…` : "https://"
|
||||
}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
{mismatch ? (
|
||||
{mismatch && site.kind !== "rss_feed" ? (
|
||||
<p className="-mt-1 mb-2 text-caption text-warning">
|
||||
地址域名与 {site.name}({new URL(site.baseUrl).hostname})不一致,仍可保存;确认没有填错站点。
|
||||
</p>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import type {
|
||||
SiteAccount,
|
||||
SiteAccountOverall,
|
||||
SiteAdapter,
|
||||
SiteAuthKind,
|
||||
SiteDefinitionView,
|
||||
SiteLastHealth,
|
||||
SiteListItem,
|
||||
SiteLoginFailureReason,
|
||||
SiteRateLimit,
|
||||
import {
|
||||
SITE_KINDS,
|
||||
type SiteAccount,
|
||||
type SiteAccountOverall,
|
||||
type SiteAdapter,
|
||||
type SiteAuthKind,
|
||||
type SiteCapability,
|
||||
type SiteDefinitionView,
|
||||
type SiteKind,
|
||||
type SiteLastHealth,
|
||||
type SiteListItem,
|
||||
type SiteLoginFailureReason,
|
||||
type SiteRateLimit,
|
||||
} from "@umt/contracts";
|
||||
import type { StatusTone } from "~/components/console/status-badge.tsx";
|
||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||
@@ -22,6 +25,21 @@ export const ADAPTER_LABEL: Record<SiteAdapter, string> = {
|
||||
generic_rss: "通用 RSS",
|
||||
};
|
||||
|
||||
/** 站点类型名(IA-2);旧数据缺 kind 时按 PT 站 */
|
||||
export function kindLabel(kind: SiteKind | undefined): string {
|
||||
return SITE_KINDS[kind ?? "pt"].label;
|
||||
}
|
||||
|
||||
/** 站点支持某种获取方式(服务端按类型 ∩ 站点定义算好的 capabilities) */
|
||||
export function siteCan(site: Pick<SiteListItem, "capabilities">, cap: SiteCapability): boolean {
|
||||
return (site.capabilities ?? []).includes(cap);
|
||||
}
|
||||
|
||||
/** 有登录与账号(凭证、账号数据、刷新登录、检测健康)的站点 */
|
||||
export function hasLogin(site: Pick<SiteListItem, "capabilities">): boolean {
|
||||
return siteCan(site, "login");
|
||||
}
|
||||
|
||||
export const ACCOUNT_OVERALL: Record<SiteAccountOverall, { label: string; tone: StatusTone }> = {
|
||||
active: { label: "正常", tone: "success" },
|
||||
expired: { label: "已失效", tone: "destructive" },
|
||||
@@ -80,8 +98,9 @@ export function overallStatus(site: SiteListItem): SiteAccountOverall {
|
||||
return acc?.enabled ? acc.status : "unconfigured";
|
||||
}
|
||||
|
||||
/** 需处理:账号非正常(已配置)或健康故障 */
|
||||
/** 需处理:账号非正常(已配置)或健康故障;没有登录的站点只看健康 */
|
||||
export function needsAttention(site: SiteListItem): boolean {
|
||||
if (!hasLogin(site)) return site.enabled && site.healthStatus === "failing";
|
||||
const st = overallStatus(site);
|
||||
return (
|
||||
site.enabled && (st === "expired" || st === "invalid" || st === "challenge" || site.healthStatus === "failing")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SiteListItem } from "@umt/contracts";
|
||||
import { useFeeds } from "~/api/feeds.ts";
|
||||
import { accessSummary, useSiteRequestStats, useSiteUserData } from "~/api/sites.ts";
|
||||
import { DetailSection } from "~/components/console/detail-sheet.tsx";
|
||||
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
@@ -11,17 +12,77 @@ import {
|
||||
AUTH_KIND_LABEL,
|
||||
bytes,
|
||||
dataAccount,
|
||||
hasLogin,
|
||||
healthLine,
|
||||
healthMessage,
|
||||
kindLabel,
|
||||
overallStatus,
|
||||
primaryAccount,
|
||||
rateLimitText,
|
||||
ratioText,
|
||||
siteCan,
|
||||
} from "./site-fields.ts";
|
||||
import { UserDataChart } from "./user-data-chart.tsx";
|
||||
|
||||
/** 站点详情"概览":账号数据磁贴 + 30 天趋势 + 状态 */
|
||||
const CAP_LABEL = [
|
||||
["search", "搜索"],
|
||||
["list", "列表抓取"],
|
||||
["rss", "RSS"],
|
||||
["detail", "详情页"],
|
||||
["login", "登录"],
|
||||
["userinfo", "账号数据"],
|
||||
] as const;
|
||||
|
||||
/** 可用的获取方式:"搜索 · 列表抓取 · RSS" */
|
||||
export function capabilityText(site: Pick<SiteListItem, "capabilities">): string {
|
||||
const on = CAP_LABEL.filter(([c]) => siteCan(site, c)).map(([, l]) => l);
|
||||
return on.length ? on.join(" · ") : "—";
|
||||
}
|
||||
|
||||
/** 站点详情"概览":账号数据磁贴 + 30 天趋势 + 状态;没有登录的站点(公开 BT、自定义 RSS 源)只看 RSS */
|
||||
export function SiteOverview({ site }: { site: SiteListItem }) {
|
||||
return hasLogin(site) ? <AccountSiteOverview site={site} /> : <FeedSiteOverview site={site} />;
|
||||
}
|
||||
|
||||
function FeedSiteOverview({ site }: { site: SiteListItem }) {
|
||||
const feeds = useFeeds({ siteId: site.id });
|
||||
const stats = useSiteRequestStats(site.id);
|
||||
const items = feeds.data?.items ?? [];
|
||||
const enabled = items.filter((f) => f.enabled);
|
||||
const failing = enabled.filter((f) => f.healthStatus === "failing" || f.healthStatus === "circuit_open").length;
|
||||
const lastOk = items
|
||||
.map((f) => f.lastSuccessAt)
|
||||
.filter((x): x is string => !!x)
|
||||
.sort()
|
||||
.at(-1);
|
||||
return (
|
||||
<div className="space-y-6 pt-2">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<StatTile
|
||||
label="RSS"
|
||||
value={feeds.data ? items.length : "—"}
|
||||
hint={items.length !== enabled.length ? `${enabled.length} 个启用` : undefined}
|
||||
/>
|
||||
<StatTile label="抓取失败" value={feeds.data ? failing || "—" : "—"} />
|
||||
<StatTile label="上次成功" value={relTime(lastOk)} />
|
||||
<StatTile label="近 24 小时请求" value={stats.data ? stats.data.last24h : "—"} />
|
||||
</div>
|
||||
<DetailSection title="站点">
|
||||
<KvList
|
||||
columns={1}
|
||||
items={[
|
||||
{ label: "类型", value: kindLabel(site.kind) },
|
||||
{ label: "获取方式", value: capabilityText(site) },
|
||||
{ label: "限流", value: rateLimitText(site.rateLimit) },
|
||||
{ label: "代理", value: site.proxy ? "使用" : "不使用" },
|
||||
]}
|
||||
/>
|
||||
</DetailSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountSiteOverview({ site }: { site: SiteListItem }) {
|
||||
const dAcc = dataAccount(site.accounts);
|
||||
const acc = primaryAccount(site.accounts);
|
||||
const u = dAcc?.userData ?? null;
|
||||
@@ -117,6 +178,8 @@ export function SiteOverview({ site }: { site: SiteListItem }) {
|
||||
),
|
||||
},
|
||||
{ label: "代理", value: site.proxy ? "使用" : "不使用" },
|
||||
...(site.kind && site.kind !== "pt" ? [{ label: "类型", value: kindLabel(site.kind) }] : []),
|
||||
{ label: "获取方式", value: capabilityText(site) },
|
||||
]}
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
@@ -119,7 +119,8 @@ export function businessLink(b: EngineRun["business"], input?: unknown): { to: s
|
||||
case "schedule":
|
||||
return { to: `/console/schedules?job=${id}`, label: "定时任务" };
|
||||
case "feed":
|
||||
return { to: consolePath("sources"), label: "RSS" };
|
||||
// RSS 属于站点(IA-2):旧 RSS 页按 ?feed= 查所属站点后跳到站点的 RSS Tab
|
||||
return { to: `${consolePath("sources")}?feed=${encodeURIComponent(id)}`, label: "RSS" };
|
||||
case "site":
|
||||
return { to: `/console/sites/${id}`, label: "站点" };
|
||||
case "decision":
|
||||
|
||||
@@ -111,7 +111,8 @@ export function FeedFormDialog({
|
||||
const raw = {
|
||||
...(d.name.trim() ? { name: d.name.trim() } : {}),
|
||||
purpose: d.purpose,
|
||||
siteId: d.siteId || null,
|
||||
// RSS 属于站点(IA-2):不能改成"不关联";没选站点时由服务端归入同主机的自定义 RSS 源
|
||||
...(d.siteId ? { siteId: d.siteId } : {}),
|
||||
siteAccountId: d.siteAccountId || null,
|
||||
pollIntervalSec: Math.round(Number(d.pollMin) * 60),
|
||||
enabled: d.enabled,
|
||||
@@ -242,8 +243,10 @@ export function FeedFormDialog({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value={NONE}>不关联站点</SelectItem>
|
||||
{sites.map((s) => (
|
||||
{!d.siteId ? <SelectItem value={NONE}>按地址归入自定义 RSS 源</SelectItem> : null}
|
||||
{sites
|
||||
.filter((s) => s.capabilities.includes("rss"))
|
||||
.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { EventType, type FeedHealth, type FeedSource } from "@umt/contracts";
|
||||
import { EventType, type FeedHealth, type FeedSource, type Observation } from "@umt/contracts";
|
||||
import { RotateCcw, Rss, Zap } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { feedGapHint, feedKeys, useFeedAction, useFeeds, useFetchFeedNow } from "~/api/feeds.ts";
|
||||
import { feedGapHint, feedKeys, useFeedAction, useFeedObservations, useFeeds, useFetchFeedNow } from "~/api/feeds.ts";
|
||||
import { useSiteAccounts, useSites } from "~/api/sites.ts";
|
||||
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
||||
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
|
||||
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { JsonView } from "~/components/console/json-view.tsx";
|
||||
import { KvList } from "~/components/console/kv-list.tsx";
|
||||
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
|
||||
import { SiteFeedDialog } from "~/components/sites/site-feed-dialog.tsx";
|
||||
import { hasLogin } from "~/components/sites/site-fields.ts";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
@@ -78,8 +80,42 @@ function FeedActions({ f }: { f: FeedSource }) {
|
||||
);
|
||||
}
|
||||
|
||||
const OBS_STATUS: Record<Observation["status"], { label: string; tone: StatusTone } | null> = {
|
||||
new: { label: "待识别", tone: "neutral" },
|
||||
linked: null,
|
||||
ignored: { label: "已忽略", tone: "neutral" },
|
||||
error: { label: "出错", tone: "warning" },
|
||||
};
|
||||
|
||||
/** RSS 详情里的最近条目:标题 + 发布时间;已关联资源的正常态不加徽章 */
|
||||
function RecentItems({ feedId }: { feedId: string }) {
|
||||
const obs = useFeedObservations(feedId);
|
||||
if (obs.isPending) return <LoadingState rows={3} />;
|
||||
if (obs.error) return <ErrorState plain title="读取条目失败" error={obs.error} onRetry={() => void obs.refetch()} />;
|
||||
// 按发布时间(没有时用首次发现)新的在前;接口按首次发现排序,同一次抓取的条目发现时间相同
|
||||
const at = (o: Observation) => o.sourcePublishedAt ?? o.firstSeenAt;
|
||||
const items = [...(obs.data?.items ?? [])].sort((a, b) => at(b).localeCompare(at(a)));
|
||||
if (!items.length) return <p className="text-caption text-muted-foreground">还没有抓到条目。</p>;
|
||||
return (
|
||||
<ul className="divide-y divide-border/60">
|
||||
{items.map((o) => {
|
||||
const st = OBS_STATUS[o.status];
|
||||
return (
|
||||
<li key={o.id} className="flex min-w-0 items-start justify-between gap-3 py-2">
|
||||
<p className="min-w-0 text-caption break-words text-foreground">{o.title}</p>
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-meta whitespace-nowrap text-muted-foreground">
|
||||
{st ? <StatusBadge tone={st.tone}>{st.label}</StatusBadge> : null}
|
||||
{relTime(o.sourcePublishedAt ?? o.firstSeenAt)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed 列表 + 详情抽屉 + 新建/编辑对话框。来源健康页列全部,站点详情按 siteId 过滤。
|
||||
* RSS 列表 + 详情抽屉 + 新建/编辑对话框。站点详情的 RSS Tab 按 siteId 过滤(IA-2 起没有跨站点的 RSS 页)。
|
||||
*/
|
||||
/** 抓取完成 / 健康变化(outbox → SSE 桥)到达时节流刷新 Feed 列表 */
|
||||
function useLiveFeeds() {
|
||||
@@ -106,7 +142,10 @@ export function FeedTable({
|
||||
creating,
|
||||
onCreatingChange,
|
||||
filtered = false,
|
||||
openFeedId,
|
||||
}: {
|
||||
/** 深链打开的 RSS(?feed=<id>) */
|
||||
openFeedId?: string | null;
|
||||
siteId?: string;
|
||||
health?: FeedHealth;
|
||||
creating: boolean;
|
||||
@@ -119,7 +158,10 @@ export function FeedTable({
|
||||
const sites = useSites();
|
||||
const accounts = useSiteAccounts();
|
||||
const [editingFeed, setEditingFeed] = useState<FeedSource | null>(null);
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [openId, setOpenId] = useState<string | null>(openFeedId ?? null);
|
||||
useEffect(() => {
|
||||
if (openFeedId) setOpenId(openFeedId);
|
||||
}, [openFeedId]);
|
||||
const action = useFeedAction();
|
||||
const siteName = new Map((sites.data?.items ?? []).map((s) => [s.id, s.name]));
|
||||
const accountName = new Map((accounts.data?.items ?? []).map((a) => [a.id, a.label]));
|
||||
@@ -167,17 +209,25 @@ export function FeedTable({
|
||||
hideBelow: "md",
|
||||
cell: (f) => (f.purpose === "generic" ? null : PURPOSE_LABEL[f.purpose]),
|
||||
},
|
||||
// 站点详情里只列账号(站点已知);没有登录的站点不显示这一列
|
||||
...(scopedSite && !hasLogin(scopedSite)
|
||||
? []
|
||||
: [
|
||||
{
|
||||
key: "site",
|
||||
header: "站点 / 账号",
|
||||
hideBelow: "lg",
|
||||
cell: (f) => (
|
||||
header: siteId ? "账号" : "站点 / 账号",
|
||||
hideBelow: "lg" as const,
|
||||
cell: (f: FeedSource) => (
|
||||
<span className="text-muted-foreground">
|
||||
{f.siteId ? (siteName.get(f.siteId) ?? "未知站点") : "—"}
|
||||
{f.siteAccountId ? ` · ${accountName.get(f.siteAccountId) ?? "账号"}` : ""}
|
||||
{siteId
|
||||
? f.siteAccountId
|
||||
? (accountName.get(f.siteAccountId) ?? "账号")
|
||||
: "—"
|
||||
: `${f.siteId ? (siteName.get(f.siteId) ?? "未知站点") : "—"}${f.siteAccountId ? ` · ${accountName.get(f.siteAccountId) ?? "账号"}` : ""}`}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]),
|
||||
{
|
||||
key: "poll",
|
||||
header: "轮询",
|
||||
@@ -287,6 +337,9 @@ export function FeedTable({
|
||||
]}
|
||||
/>
|
||||
</DetailSection>
|
||||
<DetailSection title="最近条目">
|
||||
<RecentItems feedId={open.id} />
|
||||
</DetailSection>
|
||||
<JsonView label="过滤条件" value={open.filter} />
|
||||
<JsonView label="游标原文" value={open.cursor} />
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { redirect } from "react-router";
|
||||
|
||||
// 控制台 IA 重构后的旧链接 → 新路由(IA-1:docs/design/console-ia.md §3.3;IA-3:§7)。
|
||||
// 控制台 IA 重构后的旧链接 → 新路由(IA-1:docs/design/console-ia.md §3.3;IA-3:§7;IA-2 站点类型:RSS 并入站点)。
|
||||
// 纯函数,各路由模块的 clientLoader 调用;nav.test 与冒烟都读 LEGACY_REDIRECTS。
|
||||
// 设置页的 ?tab= 旧链接由设置页自己处理(IA-2)。
|
||||
|
||||
@@ -26,6 +26,15 @@ export function legacyRedirect(pathname: string, search: string): string | null
|
||||
// IA-3:身份与元数据取消,待识别 / 外部身份 / 别名并入媒体概览
|
||||
if (path === "/console/identity") return withRest(`/console/library?${IDENTITY_TAB[tab ?? ""] ?? IDENTITY_TAB.pool}`);
|
||||
|
||||
// IA-2:独立 RSS 页取消,RSS 在站点详情的 RSS Tab。?feed=<id> 要查所属站点,由 sources.tsx 的 clientLoader 处理
|
||||
if (path === "/console/sources") {
|
||||
const siteId = q.get("siteId");
|
||||
if (q.get("feed")) return null;
|
||||
if (siteId) return `/console/sites/${encodeURIComponent(siteId)}?tab=${tab === "snapshots" ? "snapshots" : "rss"}`;
|
||||
if (q.get("new")) return "/console/sites?new=rss_feed";
|
||||
return "/console/sites";
|
||||
}
|
||||
|
||||
if (!tab) return null;
|
||||
if (path === "/console/tasks") {
|
||||
// FE-6:计划从任务页拆出,回到独立的计划页(?job= 透传)
|
||||
@@ -37,11 +46,6 @@ export function legacyRedirect(pathname: string, search: string): string | null
|
||||
return null;
|
||||
}
|
||||
if (path === "/console/subscriptions" && tab === "policies") return withRest("/console/rules");
|
||||
if (path === "/console/sources" && (tab === "snapshots" || tab === "feeds")) {
|
||||
const siteId = q.get("siteId");
|
||||
if (tab === "snapshots" && siteId) return `/console/sites/${encodeURIComponent(siteId)}?tab=snapshots`;
|
||||
return withRest("/console/sources");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -53,8 +57,13 @@ export const LEGACY_REDIRECTS: { from: string; to: string }[] = [
|
||||
{ from: "/console/tasks?tab=tasks", to: "/console/tasks" },
|
||||
{ from: "/console/tasks?tab=business", to: "/console/tasks" },
|
||||
{ from: "/console/subscriptions?tab=policies", to: "/console/rules" },
|
||||
{ from: "/console/sources?tab=snapshots", to: "/console/sources" },
|
||||
{ from: "/console/sources?tab=snapshots", to: "/console/sites" },
|
||||
{ from: "/console/sources?tab=snapshots&siteId=abc", to: "/console/sites/abc?tab=snapshots" },
|
||||
{ from: "/console/sources", to: "/console/sites" },
|
||||
{ from: "/console/sources?health=failing", to: "/console/sites" },
|
||||
{ from: "/console/sources?siteId=abc", to: "/console/sites/abc?tab=rss" },
|
||||
{ from: "/console/sources?tab=feeds&siteId=abc", to: "/console/sites/abc?tab=rss" },
|
||||
{ from: "/console/sources?new=1", to: "/console/sites?new=rss_feed" },
|
||||
{ from: "/console/engine", to: "/console/tasks" },
|
||||
{ from: "/console/engine?status=failed", to: "/console/tasks?status=failed" },
|
||||
{ from: "/console/engine/run-1", to: "/console/tasks?run=run-1" },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ResolutionStatus, WorkbenchRelease } from "@umt/contracts";
|
||||
import type { FeedHealth, ResolutionStatus, SiteListItem, WorkbenchRelease } from "@umt/contracts";
|
||||
import {
|
||||
Activity,
|
||||
ArrowLeft,
|
||||
@@ -20,7 +20,6 @@ import { type ReleaseFilters, useSiteReleases } from "~/api/releases.ts";
|
||||
import { accessSummary, snapshotStale, useSiteRequestStats, useSites, useSnapshotSite } from "~/api/sites.ts";
|
||||
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
|
||||
import { SiteOriginPanel } from "~/components/browser/desktop-view.tsx";
|
||||
import { DetailSection } from "~/components/console/detail-sheet.tsx";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
@@ -31,11 +30,20 @@ import { formatQuickFilter, selectedCount, useQuickFilters } from "~/components/
|
||||
import { SiteAccountsTab } from "~/components/sites/site-accounts-tab.tsx";
|
||||
import { SiteMoreMenu } from "~/components/sites/site-card.tsx";
|
||||
import { type SiteDialog, SiteDialogHost, useSiteChecks } from "~/components/sites/site-dialogs.tsx";
|
||||
import { ACCOUNT_OVERALL, healthLine, hostOf, overallStatus, primaryAccount } from "~/components/sites/site-fields.ts";
|
||||
import {
|
||||
ACCOUNT_OVERALL,
|
||||
hasLogin,
|
||||
healthLine,
|
||||
hostOf,
|
||||
kindLabel,
|
||||
overallStatus,
|
||||
primaryAccount,
|
||||
siteCan,
|
||||
} from "~/components/sites/site-fields.ts";
|
||||
import { SiteOverview } from "~/components/sites/site-overview.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
|
||||
import { FeedTable } from "~/components/workbench/feed-table.tsx";
|
||||
import { FEED_HEALTH, FeedTable } from "~/components/workbench/feed-table.tsx";
|
||||
import { ReleaseDetailSheet } from "~/components/workbench/release-detail-sheet.tsx";
|
||||
import { categoryLabel, circuitLabel, isFree, relTime } from "~/components/workbench/release-fields.ts";
|
||||
import { ReleaseRow } from "~/components/workbench/release-list.tsx";
|
||||
@@ -91,24 +99,53 @@ function useDebounced<T>(value: T, ms = 400) {
|
||||
return v;
|
||||
}
|
||||
|
||||
const TABS = ["overview", "resources", "origin", "snapshots", "accounts"] as const;
|
||||
/** 旧页签(整理视图)落到"资源" */
|
||||
const LEGACY_TAB: Record<string, string> = { organized: "resources" };
|
||||
const TABS = ["overview", "resources", "origin", "snapshots", "rss", "accounts"] as const;
|
||||
type Tab = (typeof TABS)[number];
|
||||
/** 旧页签(整理视图)落到"资源";旧 RSS 页的 ?tab=feeds 落到 RSS */
|
||||
const LEGACY_TAB: Record<string, string> = { organized: "resources", feeds: "rss" };
|
||||
|
||||
/** 按站点类型与可用获取方式显示的 Tab(IA-2):自定义 RSS 源没有原站视图、抓取、账号 */
|
||||
function visibleTabs(s: SiteListItem | undefined): Tab[] {
|
||||
if (!s) return [...TABS];
|
||||
return TABS.filter((t) => {
|
||||
if (t === "origin") return s.kind !== "rss_feed";
|
||||
if (t === "snapshots") return siteCan(s, "list");
|
||||
if (t === "rss") return siteCan(s, "rss");
|
||||
if (t === "accounts") return hasLogin(s);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export const meta = () => [{ title: "站点 · UHD 媒体自动化控制台" }];
|
||||
|
||||
const TAB_LABEL: Record<Tab, string> = {
|
||||
overview: "概览",
|
||||
resources: "资源",
|
||||
origin: "原站视图",
|
||||
snapshots: "抓取",
|
||||
rss: "RSS",
|
||||
accounts: "账号",
|
||||
};
|
||||
|
||||
export default function SiteWorkbenchPage() {
|
||||
const { siteId = "" } = useParams();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const rawTab = params.get("tab") ?? "";
|
||||
const mapped = LEGACY_TAB[rawTab] ?? rawTab;
|
||||
const tab = (TABS as readonly string[]).includes(mapped) ? mapped : "overview";
|
||||
const [creatingFeed, setCreatingFeed] = useState(false);
|
||||
const [feedHealth, setFeedHealth] = useState("");
|
||||
const [dialog, setDialog] = useState<SiteDialog | null>(null);
|
||||
const checks = useSiteChecks(setDialog);
|
||||
const sites = useSites();
|
||||
const snap = useSnapshotSite();
|
||||
const site = sites.data?.items.find((x) => x.id === siteId);
|
||||
const tabs = visibleTabs(site);
|
||||
const rawTab = params.get("tab") ?? "";
|
||||
let mapped = LEGACY_TAB[rawTab] ?? rawTab;
|
||||
// 只有 RSS 的站点:旧的"抓取与 RSS"深链落到 RSS
|
||||
if (mapped === "snapshots" && site && !siteCan(site, "list")) mapped = "rss";
|
||||
const tab: Tab = (tabs as string[]).includes(mapped) ? (mapped as Tab) : "overview";
|
||||
/** 旧 RSS 页 / 任务页的 ?feed=<id>:打开该 RSS 的详情 */
|
||||
const feedParam = params.get("feed");
|
||||
|
||||
const [qInput, setQInput] = useState("");
|
||||
const q = useDebounced(qInput.trim());
|
||||
@@ -152,7 +189,7 @@ export default function SiteWorkbenchPage() {
|
||||
return [...m].map(([value, label]) => ({ value, label }));
|
||||
}, [all, category]);
|
||||
|
||||
const s = sites.data?.items.find((x) => x.id === siteId);
|
||||
const s = site;
|
||||
const refreshFailed = !!releases.error && !!releases.data;
|
||||
// 刷新失败时整页数据都标过期;否则早于 2 个快照周期确认的条目标过期
|
||||
const staleBefore = refreshFailed ? now + 1 : s ? now - (s.snapshotIntervalSec ?? 3600) * 2000 : null;
|
||||
@@ -187,8 +224,11 @@ export default function SiteWorkbenchPage() {
|
||||
/>
|
||||
);
|
||||
|
||||
const account = s ? ACCOUNT_OVERALL[overallStatus(s)] : null;
|
||||
const health = s ? healthLine(s.lastHealth, s.lastHealthAt) : null;
|
||||
const login = !!s && hasLogin(s);
|
||||
/** 有站点适配器(不只 RSS)才有首页健康检测 */
|
||||
const checkable = !!s && s.capabilities.some((c) => c !== "rss");
|
||||
const account = s && login ? ACCOUNT_OVERALL[overallStatus(s)] : null;
|
||||
const health = s && checkable ? healthLine(s.lastHealth, s.lastHealthAt) : null;
|
||||
const circuit = s ? circuitLabel(s) : null;
|
||||
const hasAccount = !!s?.accounts?.some((a) => a.enabled && a.authKind !== "passkey");
|
||||
|
||||
@@ -207,6 +247,7 @@ export default function SiteWorkbenchPage() {
|
||||
>
|
||||
{hostOf(s.baseUrl)}
|
||||
</a>
|
||||
{s.kind !== "pt" ? <StatusBadge>{kindLabel(s.kind)}</StatusBadge> : null}
|
||||
{account ? <StatusBadge tone={account.tone}>账号{account.label}</StatusBadge> : null}
|
||||
{health ? <StatusBadge tone={health.tone}>健康 {health.text}</StatusBadge> : null}
|
||||
{!s.enabled ? <StatusBadge tone="warning">站点已停用,只显示历史数据</StatusBadge> : null}
|
||||
@@ -225,7 +266,7 @@ export default function SiteWorkbenchPage() {
|
||||
站点
|
||||
</Link>
|
||||
</Button>
|
||||
{s ? (
|
||||
{s && checkable ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -236,6 +277,7 @@ export default function SiteWorkbenchPage() {
|
||||
{checks.isBusy(s.id, "health") ? <LoaderCircle className="animate-spin" /> : <Activity />}
|
||||
<span className="sr-only sm:not-sr-only">检测健康</span>
|
||||
</Button>
|
||||
{login ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -246,9 +288,10 @@ export default function SiteWorkbenchPage() {
|
||||
{checks.isBusy(s.id, "login") ? <LoaderCircle className="animate-spin" /> : <RefreshCw />}
|
||||
<span className="sr-only sm:not-sr-only">刷新登录</span>
|
||||
</Button>
|
||||
<SiteMoreMenu site={s} onDialog={setDialog} />
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{s ? <SiteMoreMenu site={s} onDialog={setDialog} /> : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -268,6 +311,7 @@ export default function SiteWorkbenchPage() {
|
||||
(p) => {
|
||||
if (v === "overview") p.delete("tab");
|
||||
else p.set("tab", v);
|
||||
p.delete("feed");
|
||||
return p;
|
||||
},
|
||||
{ replace: true },
|
||||
@@ -275,11 +319,11 @@ export default function SiteWorkbenchPage() {
|
||||
}
|
||||
>
|
||||
<TabsList aria-label="站点分节">
|
||||
<TabsTrigger value="overview">概览</TabsTrigger>
|
||||
<TabsTrigger value="resources">资源</TabsTrigger>
|
||||
<TabsTrigger value="origin">原站视图</TabsTrigger>
|
||||
<TabsTrigger value="snapshots">抓取与 RSS</TabsTrigger>
|
||||
<TabsTrigger value="accounts">账号</TabsTrigger>
|
||||
{tabs.map((t) => (
|
||||
<TabsTrigger key={t} value={t}>
|
||||
{TAB_LABEL[t]}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview">{s ? <SiteOverview site={s} /> : <LoadingState rows={4} />}</TabsContent>
|
||||
@@ -290,6 +334,7 @@ export default function SiteWorkbenchPage() {
|
||||
onReset={anyFilter ? reset : undefined}
|
||||
actions={
|
||||
<>
|
||||
{s && !siteCan(s, "list") ? null : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -312,6 +357,8 @@ export default function SiteWorkbenchPage() {
|
||||
<Camera />
|
||||
刷新
|
||||
</Button>
|
||||
)}
|
||||
{s && !siteCan(s, "search") ? null : (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!q || !s?.enabled || (live && releases.isFetching)}
|
||||
@@ -324,6 +371,7 @@ export default function SiteWorkbenchPage() {
|
||||
<Radar className={live && releases.isFetching ? "animate-pulse" : undefined} />
|
||||
去站点搜
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -375,6 +423,8 @@ export default function SiteWorkbenchPage() {
|
||||
? "没有符合筛选条件的资源"
|
||||
: q
|
||||
? "本地资源里没有匹配的资源"
|
||||
: s && !siteCan(s, "list")
|
||||
? "还没有资源"
|
||||
: "还没抓取过"
|
||||
}
|
||||
description={
|
||||
@@ -384,6 +434,8 @@ export default function SiteWorkbenchPage() {
|
||||
? "减少快速筛选条件再看。"
|
||||
: coverage?.liveError
|
||||
? `去站点搜失败(${coverage.liveError}),站点上可能仍有该资源。`
|
||||
: s && !siteCan(s, "list")
|
||||
? "RSS 抓到的资源会出现在这里。"
|
||||
: q
|
||||
? "点「去站点搜」直接在站点搜索。"
|
||||
: "点「刷新」抓取站点列表。"
|
||||
@@ -438,21 +490,34 @@ export default function SiteWorkbenchPage() {
|
||||
|
||||
<TabsContent value="origin">{s ? <SiteOriginPanel site={s} /> : <LoadingState rows={4} />}</TabsContent>
|
||||
|
||||
<TabsContent value="snapshots">
|
||||
<div className="space-y-8">
|
||||
{s ? <SnapshotPanel site={s} /> : <LoadingState rows={3} />}
|
||||
<DetailSection
|
||||
title="RSS"
|
||||
<TabsContent value="snapshots">{s ? <SnapshotPanel site={s} /> : <LoadingState rows={3} />}</TabsContent>
|
||||
|
||||
<TabsContent value="rss">
|
||||
{/* 原独立 RSS 页的全部能力(IA-2):新建 / 编辑 / 启停 / 立即抓取 / 抓取状态 / 最近条目 */}
|
||||
<FilterBar
|
||||
onReset={feedHealth ? () => setFeedHealth("") : undefined}
|
||||
actions={
|
||||
<Button size="sm" variant="outline" onClick={() => setCreatingFeed(true)}>
|
||||
<Button size="sm" onClick={() => setCreatingFeed(true)} disabled={!s}>
|
||||
<Plus />
|
||||
新建 RSS
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<FeedTable siteId={siteId} creating={creatingFeed} onCreatingChange={setCreatingFeed} />
|
||||
</DetailSection>
|
||||
</div>
|
||||
<FilterSelect
|
||||
label="健康"
|
||||
value={feedHealth}
|
||||
onChange={setFeedHealth}
|
||||
options={Object.entries(FEED_HEALTH).map(([value, h]) => ({ value, label: h.label }))}
|
||||
/>
|
||||
</FilterBar>
|
||||
<FeedTable
|
||||
siteId={siteId}
|
||||
health={(feedHealth || undefined) as FeedHealth | undefined}
|
||||
filtered={!!feedHealth}
|
||||
creating={creatingFeed}
|
||||
onCreatingChange={setCreatingFeed}
|
||||
openFeedId={feedParam}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="accounts">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SITE_KINDS, type SiteKind } from "@umt/contracts";
|
||||
import { Globe, LayoutGrid, List, PackageSearch, Plus, RefreshCw } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
@@ -13,11 +14,15 @@ import { needsAttention, type SortKey, sortSites } from "~/components/sites/site
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
// 资源 → 站点:站点、凭证、健康、账号数据的唯一入口(docs/design/site-center.md §4)
|
||||
// 获取 → 站点:站点、凭证、健康、账号数据、RSS 的唯一入口(docs/design/site-center.md §4);
|
||||
// IA-2 起站点有类型(PT 站 / 公开 BT 站 / 自定义 RSS 源),列表可按类型筛选,?new=<类型> 直接打开添加。
|
||||
|
||||
const VIEW_KEY = "umt.sites.view";
|
||||
type View = "cards" | "list";
|
||||
type Filter = "all" | "enabled" | "attention";
|
||||
const KIND_OPTIONS = (Object.keys(SITE_KINDS) as SiteKind[])
|
||||
.filter((k) => SITE_KINDS[k].implemented)
|
||||
.map((k) => ({ value: k, label: SITE_KINDS[k].label }));
|
||||
|
||||
const FILTERS: { value: Filter; label: string }[] = [
|
||||
{ value: "all", label: "全部" },
|
||||
@@ -45,7 +50,8 @@ export const meta = () => [{ title: "站点 · UHD 媒体自动化控制台" }];
|
||||
export default function SitesPage() {
|
||||
const sites = useSites();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const [adding, setAdding] = useState(false);
|
||||
const newParam = params.get("new");
|
||||
const [adding, setAdding] = useState<SiteKind | "any" | null>(null);
|
||||
const [dialog, setDialog] = useState<SiteDialog | null>(null);
|
||||
const checks = useSiteChecks(setDialog);
|
||||
|
||||
@@ -58,6 +64,7 @@ export default function SitesPage() {
|
||||
const view: View = urlView === "list" || urlView === "cards" ? urlView : storedView;
|
||||
const filter = (FILTERS.find((f) => f.value === params.get("filter"))?.value ?? "all") as Filter;
|
||||
const sort = (SORTS.find((s) => s.value === params.get("sort"))?.value ?? "name") as SortKey;
|
||||
const kindFilter = KIND_OPTIONS.find((k) => k.value === params.get("kind"))?.value ?? "";
|
||||
|
||||
const setParam = (k: string, v: string | null) =>
|
||||
setParams(
|
||||
@@ -78,6 +85,14 @@ export default function SitesPage() {
|
||||
setParam("view", v);
|
||||
};
|
||||
|
||||
// 命令面板 / 旧 RSS 页的 ?new=1、?new=rss_feed:打开添加(带类型时跳过选类型)
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: 只在参数出现时处理一次
|
||||
useEffect(() => {
|
||||
if (!newParam) return;
|
||||
setAdding(newParam in SITE_KINDS ? (newParam as SiteKind) : "any");
|
||||
setParam("new", null);
|
||||
}, [newParam]);
|
||||
|
||||
// 待处理"改用 Cookie"深链:?credentials=<siteId>
|
||||
const credFor = params.get("credentials");
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: 只在数据到达时处理一次
|
||||
@@ -92,7 +107,11 @@ export default function SitesPage() {
|
||||
const [searchDefaults, setSearchDefaults] = useState(false);
|
||||
const attention = all.filter(needsAttention).length;
|
||||
const shown = sortSites(
|
||||
all.filter((s) => (filter === "enabled" ? s.enabled : filter === "attention" ? needsAttention(s) : true)),
|
||||
all.filter(
|
||||
(s) =>
|
||||
(filter === "enabled" ? s.enabled : filter === "attention" ? needsAttention(s) : true) &&
|
||||
(!kindFilter || s.kind === kindFilter),
|
||||
),
|
||||
sort,
|
||||
);
|
||||
|
||||
@@ -110,7 +129,7 @@ export default function SitesPage() {
|
||||
<PackageSearch />
|
||||
搜索站点
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setAdding(true)}>
|
||||
<Button size="sm" onClick={() => setAdding("any")}>
|
||||
<Plus />
|
||||
添加站点
|
||||
</Button>
|
||||
@@ -130,6 +149,14 @@ export default function SitesPage() {
|
||||
label: f.value === "attention" && attention ? `${f.label} ${attention}` : f.label,
|
||||
}))}
|
||||
/>
|
||||
{new Set(all.map((s) => s.kind)).size > 1 || kindFilter ? (
|
||||
<FilterSelect
|
||||
label="类型"
|
||||
value={kindFilter}
|
||||
onChange={(v) => setParam("kind", v || null)}
|
||||
options={KIND_OPTIONS}
|
||||
/>
|
||||
) : null}
|
||||
<FilterSelect
|
||||
label="排序"
|
||||
value={sort === "name" ? "" : sort}
|
||||
@@ -160,9 +187,9 @@ export default function SitesPage() {
|
||||
<EmptyState
|
||||
icon={Globe}
|
||||
title="还没有站点"
|
||||
description="添加站点并填写凭证后,这里显示账号数据和健康状态"
|
||||
description="添加 PT 站、公开 BT 站或 RSS 源后,这里显示账号数据、健康和 RSS 状态"
|
||||
action={
|
||||
<Button size="sm" onClick={() => setAdding(true)}>
|
||||
<Button size="sm" onClick={() => setAdding("any")}>
|
||||
<Plus />
|
||||
添加站点
|
||||
</Button>
|
||||
@@ -171,8 +198,8 @@ export default function SitesPage() {
|
||||
) : !shown.length ? (
|
||||
<EmptyState
|
||||
icon={Globe}
|
||||
title={filter === "attention" ? "没有需处理的站点" : "没有已启用的站点"}
|
||||
description="切换到「全部」查看其余站点"
|
||||
title={kindFilter ? "没有这类站点" : filter === "attention" ? "没有需处理的站点" : "没有已启用的站点"}
|
||||
description={kindFilter ? "清除类型筛选查看其余站点" : "切换到「全部」查看其余站点"}
|
||||
/>
|
||||
) : view === "list" ? (
|
||||
<SiteTable sites={shown} checks={checks} onDialog={setDialog} />
|
||||
@@ -184,7 +211,7 @@ export default function SitesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{adding ? <SiteAddDialog onClose={() => setAdding(false)} /> : 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} />
|
||||
</>
|
||||
|
||||
@@ -1,76 +1,25 @@
|
||||
import type { FeedHealth } from "@umt/contracts";
|
||||
import { Plus, RefreshCw } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useFeeds } from "~/api/feeds.ts";
|
||||
import { useSites } from "~/api/sites.ts";
|
||||
import { DetailSection } from "~/components/console/detail-sheet.tsx";
|
||||
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { useQueryState } from "~/components/console/use-query-state.ts";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { FEED_HEALTH, FeedTable } from "~/components/workbench/feed-table.tsx";
|
||||
import { legacyRedirectLoader } from "./redirects.ts";
|
||||
import { FeedSource } from "@umt/contracts";
|
||||
import { redirect } from "react-router";
|
||||
import { apiParsed } from "~/api/parse.ts";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { legacyRedirect } from "./redirects.ts";
|
||||
|
||||
// RSS:跨站点的 RSS 列表;站点健康只在站点页(DEAI-2 去掉重复的站点表)。
|
||||
// IA-2:独立 RSS 页取消,RSS 属于站点(站点详情的 RSS Tab)。这里只做旧链接跳转,深链不失效:
|
||||
// ?siteId= → 该站点 RSS Tab;?feed=<id> → 查 RSS 所属站点后打开它的详情;其余 → 站点列表。
|
||||
|
||||
export const meta = () => [{ title: "RSS · UHD 媒体自动化控制台" }];
|
||||
|
||||
// 旧的 ?tab=snapshots&siteId= 跳到站点详情的"快照与 Feed"
|
||||
export const clientLoader = legacyRedirectLoader;
|
||||
|
||||
export default function SourcesPage() {
|
||||
const f = useQueryState(["health", "siteId"] as const);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const sites = useSites();
|
||||
const feeds = useFeeds({
|
||||
health: (f.values.health || undefined) as FeedHealth | undefined,
|
||||
siteId: f.values.siteId || undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="RSS"
|
||||
actions={
|
||||
<Button size="sm" onClick={() => setCreating(true)}>
|
||||
<Plus />
|
||||
新建 RSS
|
||||
</Button>
|
||||
export async function clientLoader({ request }: { request: Request }) {
|
||||
const url = new URL(request.url);
|
||||
const feed = url.searchParams.get("feed");
|
||||
if (feed) {
|
||||
const f = await apiParsed(FeedSource, "/api/v1/feed-sources/:id", { pathParams: { id: feed } }).catch(() => null);
|
||||
if (f?.siteId) return redirect(`${consolePath("sites", f.siteId)}?tab=rss&feed=${encodeURIComponent(feed)}`);
|
||||
return redirect(consolePath("sites"));
|
||||
}
|
||||
/>
|
||||
<div className="space-y-8">
|
||||
<DetailSection title="RSS 地址">
|
||||
<FilterBar
|
||||
onReset={f.active ? f.reset : undefined}
|
||||
actions={
|
||||
<Button variant="ghost" size="sm" onClick={() => void feeds.refetch()} disabled={feeds.isFetching}>
|
||||
<RefreshCw className={feeds.isFetching ? "animate-spin" : undefined} />
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<FilterSelect
|
||||
label="健康"
|
||||
value={f.values.health}
|
||||
onChange={(v) => f.set("health", v)}
|
||||
options={Object.entries(FEED_HEALTH).map(([value, h]) => ({ value, label: h.label }))}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="站点"
|
||||
value={f.values.siteId}
|
||||
onChange={(v) => f.set("siteId", v)}
|
||||
options={(sites.data?.items ?? []).map((s) => ({ value: s.id, label: s.name }))}
|
||||
/>
|
||||
</FilterBar>
|
||||
<FeedTable
|
||||
health={(f.values.health || undefined) as FeedHealth | undefined}
|
||||
siteId={f.values.siteId || undefined}
|
||||
filtered={f.active}
|
||||
creating={creating}
|
||||
onCreatingChange={setCreating}
|
||||
/>
|
||||
</DetailSection>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
return redirect(legacyRedirect(url.pathname, url.search) ?? consolePath("sites"));
|
||||
}
|
||||
|
||||
export default function LegacySourcesRedirect() {
|
||||
return null;
|
||||
}
|
||||
@@ -13,7 +13,10 @@
|
||||
| RuleGroup | 规则组;条件叫"关键字"(内置)/"自定义条件";只有一条规则的组在规则页以"偏好"标签显示 | 偏好标签、过滤规则、规则集 |
|
||||
| Subscription | 订阅 | 追剧、关注 |
|
||||
| Site / SiteAccount | 站点 / 站点账号 | PT 源 |
|
||||
| FeedSource | RSS;新建叫"新建 RSS",地址叫"RSS 地址" | Feed、RSS 源、订阅源、来源健康、RSS 订阅 |
|
||||
| Site.kind(IA-2) | 站点类型:PT 站(`pt`)/ 公开 BT 站(`public_bt`)/ 自定义 RSS 源(`rss_feed`)/ Torznab 索引器(`torznab`,只定义结构,界面显示"未支持") | 站点种类、来源类型、索引器(单用) |
|
||||
| Site capabilities | 获取方式:搜索 / 列表抓取 / RSS / 详情页 / 登录 / 账号数据 | 能力(界面)、功能 |
|
||||
| 侧栏「站点 / 订阅 / 规则」一组 | 获取(组名常量 `ACQUIRE_GROUP_TITLE`,`components/console/nav.ts`,改名只改这一处) | 资源(旧组名) |
|
||||
| FeedSource | RSS;新建叫"新建 RSS",地址叫"RSS 地址";RSS 属于站点,在站点详情的「RSS」里管理(IA-2 起没有独立 RSS 页) | Feed、订阅源、来源健康、RSS 订阅、RSS 页 |
|
||||
| 站点列表/搜索结果的一次抓取 | 抓取;时间写"上次抓取",按钮"刷新";站点实时搜索叫"去站点搜" | 快照、实时补查 |
|
||||
| SourceRelease | 资源 | 来源资源、站点资源、种子(种子只指 .torrent 文件) |
|
||||
| 资源按收录版本评估后 | 不单独命名;说"可下载的资源" | 候选(只在代码与给模型的说明里) |
|
||||
|
||||
@@ -195,6 +195,8 @@ NFO / 图片投影、身份巡检与挂载根因聚合、HTTP 失败自动切浏
|
||||
- **v0.3.0(2026-09-29 打包)VER-5 只读核对问题 → FIX-7**:中——智能体旧会话汇报直出 UUID/能力名/原因码(新提示词只管新会话,展示层要兜底);进度行"步骤 6/6 全部完成"与展开"已完成 4/6(跳过 2)"不一致;"派出了 5 个子会话"实际 2 个(汇报数字要以系统事实为准);巡检目标原样显示 `{{now}}`、`{{openDecisions}}` 占位符;汇报称"下载器不在系统内"而 qB 已配置(查能力返回)。低——种子详情"未关联本地作品(unidentifiable)"直出码、"解析结果"JSON 默认展开;srt 被标"主文件 是";版本行悬浮按钮盖住大小/时间;作品页"还没有收录版本"与"4 个版本"易混;会话标题直出 ISO 时间与 download_failed。LIBV-1 回填已对用户库执行(5 个资产)。
|
||||
- 状态(分支 fix7):根因与改法——① 汇报兜底:`components/agent/report-humanize.ts` 展示时把 UUID(按本会话工具结果与子会话配名字,认不出缩成"…")、能力 ID(换能力标题)、原因码(`reasons.ts` 补了能力错误、下载盘判定、站点登录、待处理类型,`cache:` 这类前缀按后半段查)换成人话,原文进「正文原文」折叠区,历史不改;② 概况条把跳过算进分子,步骤清单只数完成——统一为 `stepProgress`:「步骤 4/6 · 完成 4 · 跳过 2」;③ "派出了 5 个子会话"是进度行把 3 次被预算拒绝的 `agent.spawn` 也算进去(产品计数错):失败的调用不计入动作计数,实际结果行加「派出 N 个子会话」(会话树),汇报格式加"数量以工具返回为准";④ 巡检执行时占位符已正确替换(补单测),界面显示的是模板本身:改成名称标签,会话标题里的 ISO 时间转「9月28日 18:53」,`download_failed` 等码转人话;⑤ 该会话运行时 qB 尚未添加(会话 12:06,qB 12:09 建),但根因是智能体根本没有查下载器的能力(`site.list` 不含下载器),新增只读能力 `downloader.list`(下载器与媒体服务器、连通状态、管理的任务数);⑥ 种子详情按识别状态说人话、解析结果 JSON 跟随技术信息默认收起;"主文件"只认视频(整理把字幕登记成独立资产时其位置 is_primary 为真,文件列表接口按扩展名收紧);悬浮操作只在鼠标悬浮时出现,选中行不再常驻遮挡;作品页空收录版本写「没设收录要求」,版本行写「库里有 N 个版本」。追加:文件管理目录树改成与主面板同款圆角卡片(头部「目录」+ 折叠钮与工具栏第一行对齐,拖宽与折叠记忆保留)。截图 `apps/web/test/screenshots/fix7/`。
|
||||
- **导航与站点类型(IA-2,用户 2026-09-29)**:「任务」归入系统组;站点、订阅、规则合为一组(组名「获取」,用户原话"都算做规则",组名可再改);独立的 RSS 页取消——RSS 属于站点,在站点详情里管理;"站点"增加类型,PT 站之外的下载来源(公开 BT、自定义 RSS 源,以后 Torznab/Jackett/Prowlarr 等)都作为一种站点类型,各类型声明自己支持的获取方式(搜索 / RSS / 详情 / 登录)。
|
||||
- 状态(ia2 分支):已实现。导航 4 组(概览:首页 / 计划 / 智能体会话;获取:站点 / 订阅 / 规则;整理;系统:任务 / 设置 / 审计 / 运行状态),组名常量 `ACQUIRE_GROUP_TITLE`;`/console/sources*` 只做跳转。站点 `kind`(pt / public_bt / rss_feed / torznab)+ `capabilities`(search / list / rss / detail / login / userinfo,类型 ∩ 站点定义,contracts `SITE_KINDS` / `siteCapabilities`);torznab 只定义 `TorznabSiteConfig`,不能添加。服务端调度 / 搜索 / 登录按 capabilities 判断(`sources-identity/site-kinds.ts`)。迁移 0055:孤立 RSS 按主机归入自定义 RSS 源站点;用户库只读核对孤立 RSS 0 条。详见 `docs/runbook/sites.md`「站点类型与获取方式」。
|
||||
- 偏离与补充:获取方式比任务单多一项 `list`(列表抓取 / 快照,调度需要区分);"计划"仍在概览组(用户只提了任务);公开 BT 站目前没有站点定义,实际只有 RSS(有定义后自动获得搜索 / 列表);`POST /feed-sources` 不带站点时自动归入同主机的自定义 RSS 源(兼容旧调用方,而不是 400)。截图 `apps/web/test/screenshots/ia2/`(不进 git)。
|
||||
- **浏览器 UA 与指纹(BR-4,用户 2026-09-30:不着急,先研究明白)**:现状 HTTP 默认 Windows Chrome 140 UA、浏览器是 camofox(Firefox 内核)自生成 UA,同一站点账号 Cookie 两边互注 → UA 不一致。方向:每个站点账号一套固定 UA(跟随 camofox 的 Firefox)+ 固定指纹,浏览器与 HTTP 共用,不轮换,浏览器升级时同步。先做只读调研(camofox 能否按会话指定/持久化指纹、各站会话与 UA 绑定情况、MP 做法),出报告再定实现。
|
||||
- 调研见 `docs/design/browser-ua-research.md`。用户决定(2026-09-30):对外伪装 **Windows**;全实例**一套固定指纹**(插件 `browser:launching` 首次生成落盘、之后复用);浏览器与 HTTP **按站点统一出口 IP**;站点适配器 / RSS 目前发 `Deno/2.9.7` UA 的问题**和 BR-4 一起做**(不单独先修)。cf_clearance 等 CF Cookie 回写时标"仅浏览器"。平滑迁移:下次重新登录 / 浏览器回写 Cookie 时原子切换 Cookie+UA。
|
||||
- **下载计划并入下载面板(DL-3,用户 2026-09-30 同意)**:acquisition_plan 后台保留(批准绑定、预算/空间预占、防重复、升级/换源追踪);界面去掉「下载计划」页签——等批准/等空间/等预算的计划作为「进行中」行显示(需操作的同时进待处理);已提交的计划并入对应下载任务,详情加「为什么下这个」;失败/过期进下载历史,细节进技术信息;用户可见处不再出现"下载计划",统一说"下载"。
|
||||
+16
-3
@@ -2,6 +2,19 @@
|
||||
|
||||
规格:`docs/design/site-center.md`。实现:`apps/server/src/modules/sources-identity/service/account.ts`(凭证、刷新登录、用户数据、健康检测、删除、定时任务)、`routes/sources.ts`、`runtime.ts`(凭证注入、站点默认、代理);账密登录 `packages/adapters/src/sites/login/nexusphp.ts`;迁移 `0031_site_center`。
|
||||
|
||||
## 站点类型与获取方式(IA-2,迁移 `0055_site_kind`)
|
||||
|
||||
- `site.kind`:`pt`(PT 站,从站点定义库选)/ `public_bt`(公开 BT 站,不登录)/ `rss_feed`(自定义 RSS 源,只有 RSS)/ `torznab`(Jackett、Prowlarr 等的 Torznab 接口,**只定义结构**:`TorznabSiteConfig{apiUrl, apiKeySecretRef, indexerId, categories}`,不能添加、不参与调度)。定义在 `packages/contracts/src/sources.ts` 的 `SITE_KINDS`。
|
||||
- 获取方式 `capabilities`:`search / list / rss / detail / login / userinfo`。站点实际可用 = 类型允许的 ∩ 站点定义实现的(`siteCapabilities`):RSS 走通用解析器,不依赖定义;搜索 / 列表 / 详情看定义的 `umt.capabilities`;登录与账号数据要有定义。`adapter = generic_rss` 的站点不查定义。`GET /sites` 每项带派生字段 `capabilities`;`site.list`(智能体)同样带 `kind` 与 `capabilities`。
|
||||
- 服务端统一按 capabilities 判断(`modules/sources-identity/site-kinds.ts`):
|
||||
- 调度:`sites.snapshot` 只排能 `list` 的站点;`feeds.poll` 只排 `kind` 能抓 RSS 的站点(`RSS_KINDS`);`sites.user_data` 只扫能登录的类型(`LOGIN_KINDS`);`sites.health_check` 跳过只剩 RSS 的站点。
|
||||
- 搜索:参与搜索的站点先过滤能 `search` 的(自定义 RSS 源不出现在搜索站点选择里);实时搜索(`live_search`)/ 列表快照 / 补漏在不支持的站点上直接跳过(`reason: unsupported`,不请求站点)。RSS 疑似缺口只在站点能 `list` 时排补漏。
|
||||
- API:`POST /sites/{id}/refresh-login`、`PUT /sites/{id}/credentials` 要 `login`,`POST /sites/{id}/snapshot` 要 `list`;不支持时 400「「名称」是自定义 RSS 源,不支持登录」。
|
||||
- `POST /sites`:`kind` 缺省为"注册表里有定义 = pt,否则 rss_feed"(兼容旧的 generic_rss 登记)。非 PT 站 `adapter` 必须是 `generic_rss`,`key` 可省(按地址主机生成 `rss-<主机>`,重名加序号),可带 `rssUrl` 在同一事务里建 RSS(名称「站点名 RSS」,轮询 30 分钟);非 PT 站 `searchDefault=false`、`snapshotEnabled=false`。`kind` 添加后不改(`SiteUpdate` 不含)。
|
||||
- **RSS 属于站点**:`POST /feed-sources` 不带 `siteId` 时归入同主机(`https://<主机>`)的自定义 RSS 源站点,没有就建一个;`PATCH /feed-sources/{id}` 不能把 `siteId` 改成 null(400)。secret 名带随机后缀,同站多个「站点名 RSS」不再撞名。
|
||||
- 迁移 0055:`adapter = generic_rss` 的存量站点 → `rss_feed`,其余 → `pt`;`site_id` 为空的 RSS 按地址主机归入新建的自定义 RSS 源站点(同主机一个),并清掉游标窗口与 ETag / Last-Modified,下次抓取把条目重新写成资源(无站点时观察记录只标 `ignored/no_site`,没有资源)。用户库 2026-09-30 只读核对:孤立 RSS 0 条(共 1 条 RSS,1 个 nexusphp 站点),迁移只加列。
|
||||
- 界面:侧栏「获取」组(站点 / 订阅 / 规则);站点列表按类型筛选,非 PT 站卡片只显示 RSS 数与上次成功;站点详情按类型显示 Tab(自定义 RSS 源:概览 / 资源 / RSS);添加站点第一步选类型。旧 `/console/sources*` 跳转:`?siteId=` → 该站 RSS Tab,`?feed=<id>` → 查所属站点后打开该 RSS,`?new=1` → 添加自定义 RSS 源,其余 → 站点列表。
|
||||
|
||||
## 端点
|
||||
|
||||
| 端点 | scope | 说明 |
|
||||
@@ -52,20 +65,20 @@
|
||||
|
||||
## Feed(RSS)添加
|
||||
|
||||
- **站点内添加**(站点详情「快照与 Feed」→ 新建 Feed,`components/sites/site-feed-dialog.tsx`):只填地址、用途(发现 = `generic` / 追踪 = `search` / 促销 = `promotion`,缺省发现)、可选备注(即名称)。地址输入时下方预览脱敏后的显示值;域名与站点不符给黄色提示但允许保存。保存后立即 `POST /feed-sources/{id}/fetch` 一次,对话框每 2 s 轮询 `GET /feed-sources/{id}`(最多 90 s),显示首抓条目数(游标窗口 `lastGuidSet` 长度)或 `lastError`。
|
||||
- **站点内添加**(站点详情「RSS」→ 新建 RSS,`components/sites/site-feed-dialog.tsx`):只填地址、用途(发现 = `generic` / 追踪 = `search` / 促销 = `promotion`,缺省发现)、可选备注(即名称)。地址输入时下方预览脱敏后的显示值;域名与站点不符给黄色提示但允许保存。保存后立即 `POST /feed-sources/{id}/fetch` 一次,对话框每 2 s 轮询 `GET /feed-sources/{id}`(最多 90 s),显示首抓条目数(游标窗口 `lastGuidSet` 长度)或 `lastError`。
|
||||
- **`POST /feed-sources` 带 `siteId` 时的推断**(`service/feeds.ts feedDefaultsForSite`):`siteAccountId` 缺省取该站启用账号,优先带 passkey 的,再取最早建的;`parserVersion` 记为 `rss@1;<站点定义 umt.version>`(解析仍走通用 RSS 解析器,版本只作追溯);`name` 缺省「站点名 RSS」;`pollIntervalSec` 缺省 1800(站点请求克制;不挂站点的 Feed 仍缺省 900)。地址主机键(去 `www.`、取最后两级,IP 原样)与站点 `baseUrl` / 定义 `domain` 都不一致时,响应附 `warnings[]`(只在创建响应出现)。`name` 现在可省,不挂站点时缺省为地址主机名。
|
||||
- **站点 RSS 条目规范化(HH-2,`packages/adapters/src/sites/feed-item.ts normalizeSiteFeedItem`)**:NexusPHP `torrentrss.php` 的标题是 `[分类]种子名[副标题][大小][上传者]`(憨憨实测 `[音乐]Travis - L.A. Times (2024) - WEB-DL - 24bit ALAC-HHWEB[Travis - L.A. Times | ALAC | …][697.64 MB]`)。挂站点的 Feed 入库时按站点定义拆开:前缀只认定义里登记过的分类名(`umt.categoryNames` / `category.*.desc`),换成站点分类 ID 写 `category_raw`(与列表快照一致,资源页分类筛选可用);尾部配平地剥最多 3 段方括号,只有出现大小段时才把它前面一段当副标题、后面一段当上传者;去掉「[新]」。大小优先用 enclosure `length`(精确字节),没有才用标题里的显示值。`source_release.raw.rss` 记 `rawTitle / categoryName / mediaType(movie·tv·music·null) / uploader / v`。不改标题解析器(`[分类]` 前缀引起的误识别归 ID-2)。
|
||||
- **与列表快照合并**:同站同资源 ID 一行。快照见过的行(`raw._site`)标题 / 副标题 / 分类 / 做种 / 促销 / H&R 以快照为准,RSS 只补空缺、更新 `raw.rss`;快照没见过的行,RSS 内容变化时用新值。快照后到时照常覆盖 RSS 值。
|
||||
- **旧行补规范化**:规范化上线前写入的 RSS 行(`raw.rss.v` 缺失)在该站任一 Feed 下次抓取时按原标题重算(每次最多 500 行,不请求站点);标题变了且未锁定的重新发 `release.observed`(幂等键 `…:rss-norm-1`)。
|
||||
- **资源列表展示**:列表按 `coalesce(发布时间, 首次发现)` 倒序,和字段是否齐全无关。RSS 独有的条目(憨憨的音乐区不在 `torrents.php` 快照里)没有做种 / 下载 / 完成、H&R,列表显示「—」,不挂「H&R 未知」徽章(详情抽屉的 H&R 字段仍写「未知」);分类名取快照分类名 → `raw.rss.categoryName` → 分类 ID。
|
||||
- **来源页任意 RSS**(`components/workbench/feed-form.tsx`):完整表单不变;新建时地址域名匹配已登记站点会自动选中该站和账号并提示「按地址域名关联到 X · 不关联」,手动改过站点后不再自动改。
|
||||
- **编辑 RSS**(`components/workbench/feed-form.tsx`,站点 RSS Tab 的详情 → 编辑):完整表单;站点只能换成另一个能抓 RSS 的站点,不能"不关联"。RSS 详情里有「最近条目」(`GET /observations?feedId=`,新的在前 20 条)。IA-2 前的独立 RSS 页(`/console/sources`)已取消,只做跳转。
|
||||
|
||||
## 定时任务
|
||||
|
||||
| key | 名称 | 默认 | 行为 |
|
||||
|---|---|---|---|
|
||||
| `sites.user_data` | 站点数据刷新 | 每 6 小时,一个周期后首跑 | 启用站点里 `status=active` 的非 passkey 账号逐个入队 `refreshSiteAccount{mode:"userData"}`;会话失效的账密账号自动重登一次;只读对账模式跳过 |
|
||||
| `sites.health_check` | 站点健康检测 | 每小时扫描,一个周期后首跑 | 启用站点(非 generic_rss)逐个同步检测;距上次检测(手动或定时)不足该站健康检测间隔(缺省 6 小时)的跳过;可在定时任务页关闭 |
|
||||
| `sites.health_check` | 站点健康检测 | 每小时扫描,一个周期后首跑 | 启用站点(有站点适配器、不只 RSS 的)逐个同步检测;距上次检测(手动或定时)不足该站健康检测间隔(缺省 6 小时)的跳过;可在定时任务页关闭 |
|
||||
|
||||
注:定时任务 key 只允许小写(注册表校验),所以规格里的 `sites.userData` / `sites.healthCheck` 落为下划线形式。
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@ export function buildSystemPrompt({
|
||||
'参数按工具 schema 的类型填写:ID 类参数一律是字符串(如 externalId:"693134",不是数字 693134);不要传 schema 里没有的字段。',
|
||||
"业务任务 ID 用 task.inspect,下载任务 ID 或种子 hash 用 download.inspect,两者不要混用。",
|
||||
"下载器、媒体服务器有没有接入、是否正常,用 downloader.list 查;站点列表里没有下载器不代表没配置。",
|
||||
"站点分类型:PT 站、公开 BT 站、自定义 RSS 源;RSS 属于站点(在站点详情的 RSS 里管理),没有单独的 RSS 页。能不能搜索、抓列表、登录看 site.list 返回的获取方式,不支持的不要去试。",
|
||||
"思考、步骤说明和汇报都用中文;引用文件名、原站标题时保持原文。",
|
||||
"工具结果里出现【预算提醒】时立刻停止探查,直接用中文写小结;被预算或中止打断时,汇报只写中文小结(已完成什么、证据、还缺什么),不要复述中间过程或下一步打算。",
|
||||
];
|
||||
|
||||
@@ -73,3 +73,12 @@ describe("工作方式(AGUI-3)", () => {
|
||||
expect(interactionRules({ options: true, proposeFirst: true }).join("\n")).toContain("本会话要求先看方案再执行");
|
||||
});
|
||||
});
|
||||
|
||||
describe("站点类型(IA-2)", () => {
|
||||
it("RSS 属于站点,没有单独的 RSS 页;按获取方式判断能不能搜索 / 登录", () => {
|
||||
const p = userPrompt();
|
||||
expect(p).toContain("RSS 属于站点");
|
||||
expect(p).toContain("没有单独的 RSS 页");
|
||||
expect(p).toContain("自定义 RSS 源");
|
||||
});
|
||||
});
|
||||
@@ -25,8 +25,10 @@ import {
|
||||
SiteAccountUserData,
|
||||
SiteAdapter,
|
||||
SiteAuthKind,
|
||||
SiteCapability,
|
||||
SiteHealth,
|
||||
SiteHealthResult,
|
||||
SiteKind,
|
||||
SiteLastHealth,
|
||||
SiteLoginResult,
|
||||
SiteUserDataPoint,
|
||||
@@ -461,6 +463,10 @@ export const SiteBrief = z.object({
|
||||
id: Id,
|
||||
key: z.string(),
|
||||
name: z.string(),
|
||||
/** 站点类型:pt / public_bt / rss_feed / torznab */
|
||||
kind: SiteKind,
|
||||
/** 实际可用的获取方式(search / list / rss / detail / login / userinfo) */
|
||||
capabilities: z.array(SiteCapability),
|
||||
adapter: SiteAdapter,
|
||||
baseUrl: z.string(),
|
||||
enabled: z.boolean(),
|
||||
|
||||
@@ -370,7 +370,7 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
|
||||
"site.list": d(
|
||||
"site.list",
|
||||
"列出站点",
|
||||
"列出已登记站点的健康、登录状态与账号摘要(不含凭证)。排查站点问题或确认站点可用时用",
|
||||
"列出已登记站点的类型(PT 站 / 公开 BT 站 / 自定义 RSS 源)、可用的获取方式、健康、登录状态与账号摘要(不含凭证)。RSS 属于站点:自定义 RSS 源也是一种站点。排查站点或 RSS 问题、确认站点能不能搜索时用",
|
||||
"read",
|
||||
"SiteList",
|
||||
"read",
|
||||
|
||||
@@ -8,6 +8,94 @@ import { QuickFilterParam, ReleaseQuickFacets } from "./release-facets.ts";
|
||||
/** api = MP 专用 API spider(Haidan/HDDolby/RousiPro/SunnyPT/TNode/TorrentLeech/Yema) */
|
||||
export const SiteAdapter = z.enum(["nexusphp", "mteam_api", "api", "generic_rss"]);
|
||||
export type SiteAdapter = z.infer<typeof SiteAdapter>;
|
||||
|
||||
/**
|
||||
* 站点类型(IA-2):PT 站之外的下载来源都作为一种站点类型。类型声明它可能支持的获取方式,
|
||||
* 站点实际可用的方式 = 类型允许的 ∩ 适配器(站点定义)实现的,见 siteCapabilities。
|
||||
* torznab 只定义结构(Jackett / Prowlarr 的 Torznab 接口),尚未实现:不能添加,也不参与调度。
|
||||
*/
|
||||
export const SiteKind = z.enum(["pt", "public_bt", "rss_feed", "torznab"]);
|
||||
export type SiteKind = z.infer<typeof SiteKind>;
|
||||
/** 获取方式:搜索 / 列表抓取 / RSS / 详情页 / 登录 / 账号数据(上传量、分享率、等级) */
|
||||
export const SiteCapability = z.enum(["search", "list", "rss", "detail", "login", "userinfo"]);
|
||||
export type SiteCapability = z.infer<typeof SiteCapability>;
|
||||
|
||||
export interface SiteKindSpec {
|
||||
/** 界面名 */
|
||||
label: string;
|
||||
/** 添加站点第一步的一句说明 */
|
||||
description: string;
|
||||
/** 这类站点可能支持的获取方式 */
|
||||
capabilities: readonly SiteCapability[];
|
||||
/** false = 只定义了结构,还不能添加 */
|
||||
implemented: boolean;
|
||||
/** 需要从站点定义库里选(PT 站);否则只填名称和地址 */
|
||||
fromDefinition: boolean;
|
||||
}
|
||||
|
||||
export const SITE_KINDS: Record<SiteKind, SiteKindSpec> = {
|
||||
pt: {
|
||||
label: "PT 站",
|
||||
description: "需要账号的私有站。从站点定义里选,支持搜索、列表抓取、RSS、登录和账号数据",
|
||||
capabilities: ["search", "list", "rss", "detail", "login", "userinfo"],
|
||||
implemented: true,
|
||||
fromDefinition: true,
|
||||
},
|
||||
public_bt: {
|
||||
label: "公开 BT 站",
|
||||
description: "不用登录的公开站。填网址和 RSS 地址,按 RSS 获取资源",
|
||||
capabilities: ["search", "list", "rss", "detail"],
|
||||
implemented: true,
|
||||
fromDefinition: false,
|
||||
},
|
||||
rss_feed: {
|
||||
label: "自定义 RSS 源",
|
||||
description: "只有一个 RSS 地址的来源,不登录、不搜索",
|
||||
capabilities: ["rss"],
|
||||
implemented: true,
|
||||
fromDefinition: false,
|
||||
},
|
||||
torznab: {
|
||||
label: "Torznab 索引器",
|
||||
description: "Jackett、Prowlarr 等索引器的 Torznab 接口,还不支持",
|
||||
capabilities: ["search", "rss"],
|
||||
implemented: false,
|
||||
fromDefinition: false,
|
||||
},
|
||||
};
|
||||
|
||||
/** 站点定义(适配器)声明的能力;没有定义的站点传 null */
|
||||
export type SiteDefinitionCaps = { search: boolean; list: boolean; detail: boolean } | null;
|
||||
|
||||
/**
|
||||
* 站点实际可用的获取方式:类型允许的 ∩ 适配器实现的。RSS 走通用解析器,不依赖站点定义;
|
||||
* 搜索 / 列表 / 详情要站点定义声明支持;登录与账号数据要有站点定义(没有定义就没有登录流程)。
|
||||
*/
|
||||
export function siteCapabilities(kind: SiteKind, def: SiteDefinitionCaps): SiteCapability[] {
|
||||
const spec = SITE_KINDS[kind];
|
||||
if (!spec.implemented) return [];
|
||||
return spec.capabilities.filter((c) => {
|
||||
if (c === "rss") return true;
|
||||
if (!def) return false;
|
||||
if (c === "search") return def.search;
|
||||
if (c === "list") return def.list;
|
||||
if (c === "detail") return def.detail;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Torznab 索引器的站点 config(只定义结构,未实现):API 地址、API Key 的 secret 引用、分类过滤。
|
||||
* 对照 MP 的 Jackett / Prowlarr 插件:按索引器登记,搜索走 `t=search`,RSS 走不带 q 的同一接口。
|
||||
*/
|
||||
export const TorznabSiteConfig = z.object({
|
||||
apiUrl: z.url(),
|
||||
apiKeySecretRef: z.uuid().nullable(),
|
||||
indexerId: z.string().max(200).nullable(),
|
||||
categories: z.array(z.number().int().nonnegative()).default([]),
|
||||
});
|
||||
export type TorznabSiteConfig = z.infer<typeof TorznabSiteConfig>;
|
||||
|
||||
export const SiteHealth = z.enum(["unknown", "healthy", "degraded", "failing", "disabled"]);
|
||||
export type SiteHealth = z.infer<typeof SiteHealth>;
|
||||
export const CircuitState = z.enum(["closed", "open", "half_open"]);
|
||||
@@ -34,6 +122,10 @@ export type SiteLastHealth = z.infer<typeof SiteLastHealth>;
|
||||
export const Site = EntityBase.extend({
|
||||
key: z.string().regex(/^[a-z][a-z0-9_-]*$/),
|
||||
name: z.string().min(1),
|
||||
/** 站点类型(IA-2) */
|
||||
kind: SiteKind,
|
||||
/** 派生字段(非表列):实际可用的获取方式,见 siteCapabilities */
|
||||
capabilities: z.array(SiteCapability),
|
||||
adapter: SiteAdapter,
|
||||
baseUrl: z.url(),
|
||||
enabled: z.boolean(),
|
||||
@@ -58,6 +150,7 @@ export type Site = z.infer<typeof Site>;
|
||||
export const SiteCreate = Site.pick({
|
||||
key: true,
|
||||
name: true,
|
||||
kind: true,
|
||||
adapter: true,
|
||||
baseUrl: true,
|
||||
enabled: true,
|
||||
@@ -67,7 +160,10 @@ export const SiteCreate = Site.pick({
|
||||
snapshotIntervalSec: true,
|
||||
proxy: true,
|
||||
searchDefault: true,
|
||||
}).partial({
|
||||
})
|
||||
.partial({
|
||||
key: true,
|
||||
kind: true,
|
||||
enabled: true,
|
||||
rateLimit: true,
|
||||
config: true,
|
||||
@@ -75,9 +171,17 @@ export const SiteCreate = Site.pick({
|
||||
snapshotIntervalSec: true,
|
||||
proxy: true,
|
||||
searchDefault: true,
|
||||
});
|
||||
})
|
||||
.extend({
|
||||
/**
|
||||
* 非 PT 站(public_bt / rss_feed)添加时一并建的 RSS 地址(只写,存为 secret)。
|
||||
* key 缺省时按地址主机名生成(rss-<主机>,重名加序号)。
|
||||
*/
|
||||
rssUrl: z.url().optional(),
|
||||
});
|
||||
export type SiteCreate = z.infer<typeof SiteCreate>;
|
||||
export const SiteUpdate = SiteCreate.omit({ key: true }).partial();
|
||||
/** 类型添加后不改(换类型 = 删了重加) */
|
||||
export const SiteUpdate = SiteCreate.omit({ key: true, kind: true, rssUrl: true }).partial();
|
||||
export type SiteUpdate = z.infer<typeof SiteUpdate>;
|
||||
|
||||
export const SiteAuthKind = z.enum(["cookie", "passkey", "api_key", "password"]);
|
||||
@@ -553,6 +657,8 @@ export type SiteRequestStats = z.infer<typeof SiteRequestStats>;
|
||||
export const SiteDefinitionView = z.object({
|
||||
key: z.string(),
|
||||
name: z.string(),
|
||||
/** 定义对应的站点类型(现有定义都是 PT 站) */
|
||||
kind: SiteKind,
|
||||
adapter: SiteAdapter,
|
||||
baseUrl: z.string(),
|
||||
/** 定义是否随产品启用(未实测站点为 false) */
|
||||
|
||||
@@ -77,8 +77,8 @@ const ROWS: Record<string, { row: Obj; dbOnly?: string[]; apiOnly?: string[] }>
|
||||
Secret: { row: C.Secret, dbOnly: ["ciphertext", "nonce", "authTag"] },
|
||||
ServerMapping: { row: C.ServerMapping },
|
||||
Settings: { row: C.SettingEntry },
|
||||
// deleted_at:软删除只在服务端过滤(已删站点不出 API)
|
||||
Site: { row: C.Site, dbOnly: ["deletedAt"] },
|
||||
// deleted_at:软删除只在服务端过滤(已删站点不出 API);capabilities 由类型 ∩ 站点定义算出(IA-2)
|
||||
Site: { row: C.Site, dbOnly: ["deletedAt"], apiOnly: ["capabilities"] },
|
||||
// secret 引用不外露,只给布尔
|
||||
SiteUserData: { row: C.SiteUserData },
|
||||
SiteAccount: {
|
||||
@@ -248,6 +248,7 @@ const ENUMS: Record<string, z.ZodEnum> = {
|
||||
SiteAdapter: C.SiteAdapter,
|
||||
SiteHealthStatus: C.SiteHealth,
|
||||
SiteCircuitState: C.CircuitState,
|
||||
SiteKind: C.SiteKind,
|
||||
SiteAccountAuthKind: C.SiteAuthKind,
|
||||
SiteAccountStatus: C.SiteAccountStatus,
|
||||
SourceReleaseResolutionStatus: C.ResolutionStatus,
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { type Kysely, sql } from "kysely";
|
||||
import { execDdl, oneOf } from "../src/migration-helpers.ts";
|
||||
|
||||
// IA-2 站点类型:site.kind(pt / public_bt / rss_feed / torznab),各类型声明支持的获取方式(contracts SITE_KINDS)。
|
||||
// - 存量:adapter = generic_rss 的站点(旧"自定义 RSS 站点:只抓 RSS,不登录")→ rss_feed;其余 → pt。
|
||||
// - RSS 归属站点:不属于任何站点的 feed_source 按地址主机名归入「自定义 RSS 源」站点(同主机一个站点,
|
||||
// key = rss-<主机>,重名加序号);清掉这些 Feed 的游标窗口与条件请求头,下次抓取把窗口里的条目重新写成资源
|
||||
// (之前无站点时观察记录被标 ignored/no_site,没有进 source_release)。
|
||||
// down 只删列;迁移建的站点与 Feed 归属保留(对旧版本就是普通的 generic_rss 站点)。
|
||||
|
||||
export const SITE_KINDS = ["pt", "public_bt", "rss_feed", "torznab"] as const;
|
||||
|
||||
/** 主机名 → 站点 key 片段:小写,非字母数字换成 -,首尾去 -;空则 feed */
|
||||
export function hostSlug(host: string): string {
|
||||
const s = host
|
||||
.toLowerCase()
|
||||
.replace(/^www\./, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 40)
|
||||
.replace(/-+$/, "");
|
||||
return s || "feed";
|
||||
}
|
||||
|
||||
function hostOf(urlDisplay: string): string | null {
|
||||
try {
|
||||
return new URL(urlDisplay).host || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(
|
||||
db,
|
||||
`alter table site add column kind text not null default 'pt' ${oneOf("kind", SITE_KINDS)};
|
||||
update site set kind = 'rss_feed' where adapter = 'generic_rss';
|
||||
create index site_kind_idx on site (kind) where deleted_at is null;`,
|
||||
);
|
||||
|
||||
const orphans = await db
|
||||
.selectFrom("feed_source")
|
||||
.select(["id", "url_display", "name"])
|
||||
.where("site_id", "is", null)
|
||||
.orderBy("created_at")
|
||||
.execute();
|
||||
if (!orphans.length) return;
|
||||
|
||||
const taken = new Set((await db.selectFrom("site").select("key").execute()).map((r) => r.key as string));
|
||||
const byHost = new Map<string, { id: string; name: string }[]>();
|
||||
for (const f of orphans) {
|
||||
const host = hostOf(f.url_display as string) ?? "unknown";
|
||||
const list = byHost.get(host) ?? [];
|
||||
list.push({ id: f.id as string, name: f.name as string });
|
||||
byHost.set(host, list);
|
||||
}
|
||||
for (const [host, feeds] of byHost) {
|
||||
const base = `rss-${hostSlug(host === "unknown" ? "" : host)}`;
|
||||
let key = base;
|
||||
for (let n = 2; taken.has(key); n++) key = `${base}-${n}`;
|
||||
taken.add(key);
|
||||
const site = await db
|
||||
.insertInto("site")
|
||||
.values({
|
||||
key,
|
||||
name: host === "unknown" ? (feeds[0]?.name ?? "自定义 RSS 源") : host,
|
||||
kind: "rss_feed",
|
||||
adapter: "generic_rss",
|
||||
base_url: host === "unknown" ? "https://invalid.local" : `https://${host}`,
|
||||
snapshot_enabled: false,
|
||||
search_default: false,
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await db
|
||||
.updateTable("feed_source")
|
||||
.set({
|
||||
site_id: site.id,
|
||||
etag: null,
|
||||
last_modified: null,
|
||||
cursor: sql`cursor - 'window'`,
|
||||
})
|
||||
.where(
|
||||
"id",
|
||||
"in",
|
||||
feeds.map((f) => f.id),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(
|
||||
db,
|
||||
`drop index if exists site_kind_idx;
|
||||
alter table site drop column if exists kind;`,
|
||||
);
|
||||
}
|
||||
@@ -239,6 +239,7 @@ export type ServerMappingMatchStatus = "matched" | "ambiguous" | "unmatched" | "
|
||||
export type SiteAdapter = "nexusphp" | "mteam_api" | "api" | "generic_rss";
|
||||
export type SiteHealthStatus = "unknown" | "healthy" | "degraded" | "failing" | "disabled";
|
||||
export type SiteCircuitState = "closed" | "open" | "half_open";
|
||||
export type SiteKind = "pt" | "public_bt" | "rss_feed" | "torznab";
|
||||
export type SiteAccountAuthKind = "cookie" | "passkey" | "api_key" | "password";
|
||||
export type SiteAccountStatus = "unknown" | "active" | "invalid" | "expired" | "challenge";
|
||||
export type SiteSnapshotKind = "periodic" | "backfill" | "live_search" | "manual";
|
||||
@@ -1384,6 +1385,7 @@ export interface SiteTable {
|
||||
proxy: Generated<boolean>;
|
||||
deleted_at: Timestamptz | null;
|
||||
search_default: Generated<boolean>;
|
||||
kind: Generated<SiteKind>;
|
||||
}
|
||||
|
||||
export interface SiteAccountTable {
|
||||
|
||||
Reference in new issue
Block a user