From 123b9dcd611704a921e7df9e92da0da8debb1331 Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:12:51 +0800 Subject: [PATCH] fix: polish credential traffic cards and support copying over HTTP --- web/src/components/credential-content.tsx | 142 ++++++++++++++++------ web/src/components/intelligence-panel.tsx | 9 +- web/src/lib/utils.ts | 28 +++-- 3 files changed, 130 insertions(+), 49 deletions(-) diff --git a/web/src/components/credential-content.tsx b/web/src/components/credential-content.tsx index a8d1e30..062d36d 100644 --- a/web/src/components/credential-content.tsx +++ b/web/src/components/credential-content.tsx @@ -2,7 +2,7 @@ import * as React from "react"; -import { CopyIcon } from "lucide-react"; +import { ArrowUpRightIcon, ChevronDownIcon, ChevronUpIcon, CopyIcon, NetworkIcon } from "lucide-react"; import { toast } from "sonner"; import { HttpCodeBlock } from "@/components/http-code-block"; @@ -14,8 +14,8 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { api } from "@/lib/api"; import { toneClasses } from "@/lib/status"; -import type { CredentialAccount, CredentialTrafficDetail, TaskIntelligence } from "@/lib/types"; -import { cn } from "@/lib/utils"; +import type { CredentialAccount, CredentialTraffic, CredentialTrafficDetail, TaskIntelligence } from "@/lib/types"; +import { cn, copyText } from "@/lib/utils"; export function intelligencePreview(text: string, limit = 260) { const chars = Array.from(text); @@ -52,6 +52,93 @@ function HighlightedOriginal({ text, accounts }: { text: string; accounts: Crede ); } +function TrafficList({ + traffic, + detail, + onTraffic, + onDetails, +}: { + traffic: CredentialTraffic[]; + detail: boolean; + onTraffic?: (id: string) => void; + onDetails?: () => void; +}) { + const [expanded, setExpanded] = React.useState(false); + const limit = detail ? 3 : 2; + const visible = detail && expanded ? traffic : traffic.slice(0, limit); + const remaining = traffic.length - limit; + return ( +
+
+ + 关联流量 + {traffic.length} 条 +
+
+ {visible.map((ref) => { + let host = "", + path = ref.url; + try { + const url = new URL(ref.url); + host = url.host; + path = `${url.pathname}${url.search}${url.hash}`; + } catch { + // Retain non-URL references as plain text. + } + const tone = + ref.status >= 500 + ? "red" + : ref.status >= 400 + ? "amber" + : ref.status >= 300 + ? "blue" + : ref.status >= 200 + ? "green" + : "neutral"; + return ( + + ); + })} +
+ {remaining > 0 ? ( + + ) : null} +
+ ); +} export function CredentialContent({ item, detail = false, @@ -84,18 +171,16 @@ export function CredentialContent({ } return (
- {analysis ? ( + {analysis && analysis.state !== "error" ? (
{analysis.state === "processing" ? "正在整理" : analysis.state === "queued" ? "等待整理" - : analysis.state === "error" - ? "整理失败" - : analysis.method === "rules" - ? "规则整理" - : "Agent 整理"} + : analysis.method === "rules" + ? "规则整理" + : "Agent 整理"} {analysis.method === "rules" ? 未调用模型 : null} {detail && analysis.model ? ( @@ -107,7 +192,7 @@ export function CredentialContent({ ) : null}
- ) : enabled && item.kind === "credential" ? ( + ) : !analysis && enabled && item.kind === "credential" ? ( 等待凭据整理 ) : null} {ready ? ( @@ -155,12 +240,10 @@ export function CredentialContent({ variant="ghost" size="icon-xs" aria-label={`复制${label} ${index + 1}`} - onClick={() => - void navigator.clipboard - .writeText(account[field] ?? "") - .then(() => toast.success(`${label}已复制`)) - .catch(() => toast.error("复制失败")) - } + onClick={async () => { + if (await copyText(account[field] ?? "")) toast.success(`${label}已复制`); + else toast.error("复制失败,请手动选择内容复制"); + }} > @@ -178,26 +261,15 @@ export function CredentialContent({ {accounts.length > 2 ? `还有 ${accounts.length - 2} 组凭据,查看详情` : "查看完整凭据"} ) : null} - {analysis?.error ? ( -

- {analysis.error} -

- ) : null} - {analysis?.note ?

{analysis.note}

: null} + {ready && analysis?.note ?

{analysis.note}

: null} {analysis?.traffic?.length ? ( -
- 关联流量 · {analysis.traffic.length} 条 - {analysis.traffic.map((ref) => ( - - ))} -
+ ) : null} {detail && analysis?.traffic_note ? (

{analysis.traffic_note}

diff --git a/web/src/components/intelligence-panel.tsx b/web/src/components/intelligence-panel.tsx index 3ac82ed..81022fb 100644 --- a/web/src/components/intelligence-panel.tsx +++ b/web/src/components/intelligence-panel.tsx @@ -23,6 +23,7 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { api } from "@/lib/api"; import { statusMeta } from "@/lib/status"; import type { IntelligenceKind, IntelligencePage, TaskIntelligence } from "@/lib/types"; +import { copyText } from "@/lib/utils"; const KINDS: { value: IntelligenceKind; label: string }[] = [ { value: "credential", label: "凭据" }, @@ -397,12 +398,8 @@ export function IntelligencePanel({ size="sm" variant="ghost" onClick={async () => { - try { - await navigator.clipboard.writeText(selected.content); - toast.success("已复制"); - } catch { - toast.error("复制失败,请手动选择内容复制"); - } + if (await copyText(selected.content)) toast.success("已复制"); + else toast.error("复制失败,请手动选择内容复制"); }} > diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index e5cbf1d..a5d31dd 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -57,20 +57,32 @@ export async function copyText(text: string): Promise { // 继续走降级方案 } } + const active = document.activeElement; + const selection = window.getSelection(); + const ranges = selection ? Array.from({ length: selection.rangeCount }, (_, i) => selection.getRangeAt(i)) : []; + const textarea = document.createElement("textarea"); try { - const textarea = document.createElement("textarea"); textarea.value = text; + textarea.setAttribute("aria-label", "复制内容"); textarea.style.position = "fixed"; - textarea.style.left = "-9999px"; - textarea.style.top = "0"; - document.body.appendChild(textarea); - textarea.focus(); + textarea.style.opacity = "0"; + textarea.style.pointerEvents = "none"; + // Stay inside the active modal so its focus trap does not steal the selection. + const dialogs = document.querySelectorAll('[role="dialog"][data-state="open"]'); + const container = active?.closest('[role="dialog"]') ?? dialogs.item(dialogs.length - 1) ?? document.body; + container.appendChild(textarea); + textarea.focus({ preventScroll: true }); textarea.select(); - const ok = document.execCommand("copy"); - document.body.removeChild(textarea); - return ok; + return document.execCommand("copy"); } catch { return false; + } finally { + textarea.remove(); + if (active instanceof HTMLElement && active.isConnected) active.focus({ preventScroll: true }); + if (selection && ranges.length) { + selection.removeAllRanges(); + for (const range of ranges) selection.addRange(range); + } } }