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
+1845
-1503
No files matched your search
@@ -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,18 +139,22 @@ 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(
|
||||
<Camera />,
|
||||
"抓取最新资源",
|
||||
() =>
|
||||
snap.mutate(
|
||||
{ id: site.id },
|
||||
{ onSuccess: () => toast.success("已开始抓取"), onError: (e) => toast.error(errorMessage(e)) },
|
||||
),
|
||||
{ disabled: !site.enabled },
|
||||
)}
|
||||
{siteCan(site, "list")
|
||||
? item(
|
||||
<Camera />,
|
||||
"抓取最新资源",
|
||||
() =>
|
||||
snap.mutate(
|
||||
{ id: site.id },
|
||||
{ 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,12 +243,14 @@ export function FeedFormDialog({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value={NONE}>不关联站点</SelectItem>
|
||||
{sites.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
{!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>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
|
||||
@@ -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]),
|
||||
},
|
||||
{
|
||||
key: "site",
|
||||
header: "站点 / 账号",
|
||||
hideBelow: "lg",
|
||||
cell: (f) => (
|
||||
<span className="text-muted-foreground">
|
||||
{f.siteId ? (siteName.get(f.siteId) ?? "未知站点") : "—"}
|
||||
{f.siteAccountId ? ` · ${accountName.get(f.siteAccountId) ?? "账号"}` : ""}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
// 站点详情里只列账号(站点已知);没有登录的站点不显示这一列
|
||||
...(scopedSite && !hasLogin(scopedSite)
|
||||
? []
|
||||
: [
|
||||
{
|
||||
key: "site",
|
||||
header: siteId ? "账号" : "站点 / 账号",
|
||||
hideBelow: "lg" as const,
|
||||
cell: (f: FeedSource) => (
|
||||
<span className="text-muted-foreground">
|
||||
{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} />
|
||||
</>
|
||||
|
||||
Reference in new issue
Block a user