代码提交
This commit is contained in:
1 parent
4ac7bc21cd
commit
17cf849361
110 files changed
+17007
-518
No files matched your search
@@ -1,21 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Bot,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Compass,
|
||||
Clock,
|
||||
DownloadIcon,
|
||||
FilePenLine,
|
||||
MessageSquare,
|
||||
PenTool,
|
||||
Pin,
|
||||
PinOff,
|
||||
Search,
|
||||
Star,
|
||||
Users2,
|
||||
type LucideIcon,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { motion } from "motion/react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -48,6 +50,9 @@ import {
|
||||
import { formatTimeAgo } from "@/core/utils/datetime";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAIWritingSessions } from "@/open-canvas/hooks/useAIWritingSessions";
|
||||
import { useRoundtableDrafts } from "@/roundtable-planning/hooks/useRoundtableDrafts";
|
||||
import { useResearchSessions } from "@/strategy-components/api/deep-research";
|
||||
|
||||
import { RoundtableHistoryTab } from "./chats-history/RoundtableHistoryTab";
|
||||
import { WritingHistoryTab } from "./chats-history/WritingHistoryTab";
|
||||
@@ -79,19 +84,19 @@ const HISTORY_TABS: {
|
||||
id: "roundtable",
|
||||
label: "多智能体会商",
|
||||
description: "多角色协作研讨",
|
||||
icon: Bot,
|
||||
icon: Users2,
|
||||
},
|
||||
{
|
||||
id: "writing",
|
||||
label: "AI写作",
|
||||
description: "写作任务与草稿",
|
||||
icon: FilePenLine,
|
||||
icon: PenTool,
|
||||
},
|
||||
{
|
||||
id: "research",
|
||||
label: "深度研究",
|
||||
description: "研究任务与报告",
|
||||
icon: Search,
|
||||
icon: Compass,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -126,11 +131,20 @@ export default function ChatsPage({
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
// 非对话 Tab 的记录总数由 Tab 组件回传(客户端过滤后的条数)。
|
||||
const [tabTotal, setTabTotal] = useState<number | null>(null);
|
||||
// 顶部的数字角标与参考页一致,始终显示各类记录的实际总数,而不是当前筛选结果。
|
||||
const { drafts: roundtableTabDrafts, refresh: refreshRoundtableTabDrafts } = useRoundtableDrafts();
|
||||
const { sessions: writingTabSessions, refresh: refreshWritingTabSessions } = useAIWritingSessions();
|
||||
const { data: researchTabData } = useResearchSessions(100);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${t.pages.chats} - ${t.pages.appName}`;
|
||||
}, [t.pages.chats, t.pages.appName]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshRoundtableTabDrafts();
|
||||
void refreshWritingTabSessions();
|
||||
}, [refreshRoundtableTabDrafts, refreshWritingTabSessions]);
|
||||
|
||||
// Debounce the search box so each keystroke doesn't fire a backend query.
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedSearch(search), 300);
|
||||
@@ -224,7 +238,7 @@ export default function ChatsPage({
|
||||
"group/chat-row relative flex min-w-0 items-start gap-3 rounded-[10px] border bg-card p-3 transition-[border-color,box-shadow,background-color] duration-150 sm:items-center sm:p-3.5",
|
||||
pinned
|
||||
? "border-red-200 bg-gradient-to-r from-red-50/50 via-white to-white shadow-xs hover:border-red-300 hover:shadow-sm"
|
||||
: "border-neutral-200/80 bg-white hover:border-red-200 hover:bg-red-50/20 hover:shadow-xs",
|
||||
: "border-neutral-200/80 bg-white hover:!border-red-300 hover:!bg-gradient-to-r hover:!from-red-50/50 hover:!via-white hover:!to-white hover:shadow-xs",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
@@ -248,12 +262,12 @@ export default function ChatsPage({
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{pinned && (
|
||||
<span className="inline-flex items-center gap-1 rounded border border-red-200/50 bg-red-100/80 px-1.5 py-0.5 text-[10px] font-semibold text-red-700">
|
||||
<span className="inline-flex items-center gap-1 rounded border !border-red-200 bg-red-100/80 px-1.5 py-0.5 text-[10px] font-semibold text-red-700">
|
||||
<Pin className="size-3 rotate-45 text-red-600" />
|
||||
置顶
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-red-200/70 bg-red-50 px-2 py-0.5 text-[11px] font-semibold text-red-700">
|
||||
<span className="inline-flex items-center gap-1 rounded-full border !border-red-200 bg-red-50 px-2 py-0.5 text-[11px] font-semibold text-red-700">
|
||||
<MessageSquare className="size-3 text-red-600" />
|
||||
对话
|
||||
</span>
|
||||
@@ -298,6 +312,12 @@ export default function ChatsPage({
|
||||
// 头部计数:对话 Tab 用后端总数,其余 Tab 用组件回传的过滤后条数。
|
||||
const displayTotal = activeTab === "chats" ? totalCount : tabTotal;
|
||||
const activeTabConfig = HISTORY_TABS.find((tab) => tab.id === activeTab)!;
|
||||
const historyTabCounts: Record<HistoryTab, number> = {
|
||||
chats: allThreadsCount ?? 0,
|
||||
roundtable: roundtableTabDrafts.length,
|
||||
writing: writingTabSessions.length,
|
||||
research: researchTabData?.total ?? 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<WorkspaceContainer>
|
||||
@@ -315,52 +335,77 @@ export default function ChatsPage({
|
||||
<WorkspaceBody>
|
||||
<div className="flex size-full min-w-0 flex-col">
|
||||
<header className="flex shrink-0 flex-col items-center gap-3 pt-8">
|
||||
<div className="relative flex w-full max-w-5xl items-center justify-center px-4 sm:px-6">
|
||||
<div
|
||||
className="inline-flex max-w-full overflow-x-auto rounded-2xl border border-red-100 bg-white p-1.5 shadow-xs backdrop-blur-sm"
|
||||
role="tablist"
|
||||
aria-label="历史记录类型"
|
||||
>
|
||||
{HISTORY_TABS.map((tab) => {
|
||||
const isActive = activeTab === tab.id;
|
||||
const Icon = tab.icon;
|
||||
<div className="relative w-full max-w-5xl px-4 sm:px-6">
|
||||
<div className="flex w-full flex-col items-center">
|
||||
<div
|
||||
id="primary-tabs-nav"
|
||||
className="inline-flex rounded-2xl border border-red-100 bg-white p-1.5 shadow-xs backdrop-blur-sm"
|
||||
role="tablist"
|
||||
aria-label="历史记录类型"
|
||||
>
|
||||
{HISTORY_TABS.map((tab) => {
|
||||
const isActive = activeTab === tab.id;
|
||||
const Icon = tab.icon;
|
||||
const count = historyTabCounts[tab.id];
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
id={`history-tab-${tab.id}`}
|
||||
type="button"
|
||||
className={cn(
|
||||
"relative flex shrink-0 select-none items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/30",
|
||||
isActive
|
||||
? "font-semibold text-red-700 shadow-xs"
|
||||
: "text-neutral-600 hover:bg-red-50/50 hover:text-red-600",
|
||||
)}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-controls={`history-panel-${tab.id}`}
|
||||
title={tab.description}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
{isActive && (
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
id={`history-tab-${tab.id}`}
|
||||
type="button"
|
||||
className={cn(
|
||||
"relative flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition-all duration-200 select-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/30",
|
||||
isActive
|
||||
? "font-semibold text-red-700 shadow-xs"
|
||||
: "text-neutral-600 hover:bg-red-50/50 hover:text-red-600",
|
||||
)}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-controls={`history-panel-${tab.id}`}
|
||||
title={tab.description}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId="activeTabBadge"
|
||||
className="absolute inset-0 rounded-xl border !border-red-200 bg-red-50/90 shadow-xs"
|
||||
transition={{ type: "spring", stiffness: 450, damping: 35 }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className="absolute inset-0 rounded-xl border border-red-200 bg-red-50/90 shadow-xs"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10 flex items-center gap-2">
|
||||
<Icon className="size-4" aria-hidden="true" />
|
||||
<span>{tab.label}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
className={cn(
|
||||
"relative z-10 flex items-center gap-2",
|
||||
isActive
|
||||
? "font-semibold text-red-700"
|
||||
: "text-neutral-600",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" aria-hidden="true" />
|
||||
<span>{tab.label}</span>
|
||||
</span>
|
||||
{count > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"relative z-10 rounded-full px-1.5 py-[0.05rem] font-mono text-[11px] transition-colors",
|
||||
isActive
|
||||
? "bg-red-600 font-bold text-white"
|
||||
: "bg-neutral-100 text-neutral-500",
|
||||
)}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{activeTab === "chats" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="absolute right-4 sm:right-6"
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 sm:right-6"
|
||||
onClick={() => setImportOpen(true)}
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
@@ -376,9 +421,9 @@ export default function ChatsPage({
|
||||
id={`history-panel-${activeTab}`}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`history-tab-${activeTab}`}
|
||||
className="space-y-4 rounded-2xl border border-red-100 bg-white p-4 shadow-xs sm:p-5"
|
||||
className="space-y-4 rounded-2xl border border-red-200 bg-white p-4 shadow-xs sm:p-5"
|
||||
>
|
||||
<div className="w-full space-y-3.5">
|
||||
{activeTab !== "roundtable" && <div className="w-full space-y-3.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{activeTab === "chats" ? (
|
||||
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 md:pb-0" role="group" aria-label="对话筛选">
|
||||
@@ -387,7 +432,7 @@ export default function ChatsPage({
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-xs font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/30",
|
||||
chatFilter === "all"
|
||||
? "bg-red-600 font-semibold text-white shadow-xs"
|
||||
? "border !border-red-600 bg-red-600 font-semibold text-white shadow-xs"
|
||||
: "border border-neutral-200 bg-white text-neutral-600 hover:border-red-200 hover:bg-red-50/60 hover:text-red-700",
|
||||
)}
|
||||
aria-pressed={chatFilter === "all"}
|
||||
@@ -499,7 +544,7 @@ export default function ChatsPage({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
{activeTab === "chats" && (
|
||||
<div className="space-y-3 pt-5">
|
||||
{currentPage === 1 && pinnedThreads.map(renderThread)}
|
||||
@@ -543,12 +588,7 @@ export default function ChatsPage({
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "roundtable" && (
|
||||
<div className="pt-5">
|
||||
<RoundtableHistoryTab
|
||||
query={search}
|
||||
onTotalChange={handleTabTotalChange}
|
||||
/>
|
||||
</div>
|
||||
<RoundtableHistoryTab onTotalChange={handleTabTotalChange} />
|
||||
)}
|
||||
{activeTab === "writing" && (
|
||||
<div className="pt-5">
|
||||
|
||||
@@ -3,113 +3,326 @@
|
||||
/**
|
||||
* 「对话历史」页 —— 多智能体会商 Tab。
|
||||
*
|
||||
* 数据与原侧栏「多智能体会商」列表同源(当前用户的个人草稿 roundtable_drafts,
|
||||
* useRoundtableDrafts 不带 taskId);点击行为也一致:暂存 sessionStorage
|
||||
* 「roundtable:pending-draft」后跳转会商页,由页面挂载时消费并加载该草稿。
|
||||
* 任务维度会商(task-draft,按 taskId 全任务共享、不分权)不随个人历史走,
|
||||
* 仍只在对应任务工作区里查看。
|
||||
* 卡片与筛选区按最新「智能工作区管理 (1)」的 ConversationRow /
|
||||
* FilterAndSearchBar 适配。会商步骤、状态、模式、互动数和模型都来自实际草稿快照。
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Bot, Share2 } from "lucide-react";
|
||||
import {
|
||||
ArrowUpDown,
|
||||
Clock,
|
||||
Cpu,
|
||||
ExternalLink,
|
||||
Search,
|
||||
Tag,
|
||||
Trash2,
|
||||
Users2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { ShareRoundtableDraftDialog } from "@/components/workspace/roundtable-draft-share-dialogs";
|
||||
import { formatRelative } from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown";
|
||||
import { useRoundtableDrafts } from "@/roundtable-planning/hooks/useRoundtableDrafts";
|
||||
import type { DraftMeta } from "@/roundtable-planning/api/drafts";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
import {
|
||||
DeleteIcon,
|
||||
HistoryRenameDialog,
|
||||
HistoryRow,
|
||||
HistoryRowIconButton,
|
||||
HistoryStatusPill,
|
||||
HistoryTypeBadge,
|
||||
RenameIcon,
|
||||
} from "./history-tab-shared";
|
||||
getDraft,
|
||||
type DraftMeta,
|
||||
type DraftRecord,
|
||||
} from "@/roundtable-planning/api/drafts";
|
||||
import { useRoundtableDrafts } from "@/roundtable-planning/hooks/useRoundtableDrafts";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
const ROUNDTABLE_PATH = "/page/workspace/roundtable/planning";
|
||||
|
||||
const ROUNDTABLE_STEP_LABEL: Record<DraftMeta["furthestStep"], string> = {
|
||||
1: "议题梳理",
|
||||
2: "多智能体会商",
|
||||
3: "报告生成",
|
||||
};
|
||||
type SortType = "updated_desc" | "updated_asc" | "messages_desc";
|
||||
|
||||
interface RoundtableItem {
|
||||
draft: DraftMeta;
|
||||
record?: DraftRecord;
|
||||
status: ReturnType<typeof progressOf>;
|
||||
businessChainName: string;
|
||||
messageCount: number;
|
||||
model?: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表接口会返回草稿已推进到的工作台步骤以及最新后台作业状态。两者须同时展示:
|
||||
* `furthestStep` 告诉用户做到哪一环,`status` 告诉用户是否已真正完成或被中断。
|
||||
*/
|
||||
function progressOf(draft: DraftMeta): {
|
||||
label: string;
|
||||
className: string;
|
||||
stepLabel: string;
|
||||
tone: "completed" | "active" | "waiting" | "failed" | "cancelled";
|
||||
} {
|
||||
const stepLabel = `第 ${draft.furthestStep} 步 · ${ROUNDTABLE_STEP_LABEL[draft.furthestStep]}`;
|
||||
const stepLabel = `第 ${draft.furthestStep} 步`;
|
||||
|
||||
if (draft.status === "done" || (!draft.status && draft.furthestStep === 3)) {
|
||||
return { label: "已完成", className: "text-emerald-600", stepLabel };
|
||||
return { label: "已完成", tone: "completed", stepLabel };
|
||||
}
|
||||
if (draft.status === "error") {
|
||||
return { label: "执行失败", className: "text-rose-500", stepLabel: `停在${stepLabel}` };
|
||||
return { label: "执行失败", tone: "failed", stepLabel: `停在${stepLabel}` };
|
||||
}
|
||||
if (draft.status === "cancelled") {
|
||||
return { label: "已取消", className: "text-muted-foreground", stepLabel: `停在${stepLabel}` };
|
||||
return { label: "已取消", tone: "cancelled", stepLabel: `停在${stepLabel}` };
|
||||
}
|
||||
if (draft.status === "awaiting_input") {
|
||||
return { label: "等待确认", className: "text-amber-500", stepLabel: `当前${stepLabel}` };
|
||||
return { label: "等待确认", tone: "waiting", stepLabel: `当前${stepLabel}` };
|
||||
}
|
||||
return { label: "进行中", className: "text-primary", stepLabel: `当前${stepLabel}` };
|
||||
return { label: "进行中", tone: "active", stepLabel: `当前${stepLabel}` };
|
||||
}
|
||||
|
||||
function modeOf(record?: DraftRecord): string {
|
||||
switch (record?.step2?.orchestrationMode) {
|
||||
case "chain":
|
||||
return "业务链会商";
|
||||
case "dag":
|
||||
return "DAG 编排";
|
||||
default:
|
||||
return "自由模式";
|
||||
}
|
||||
}
|
||||
|
||||
function businessChainNameOf(draft: DraftMeta, record?: DraftRecord): string {
|
||||
return record?.step2?.chain?.title ?? draft.chainTitle ?? modeOf(record);
|
||||
}
|
||||
|
||||
function interactionCount(record?: DraftRecord): number {
|
||||
if (!record) return 0;
|
||||
return (
|
||||
(record.step1?.interactiveMessages?.length ?? 0) +
|
||||
(record.step2?.step2RoundtableDialogues?.length ?? 0) +
|
||||
(record.step3?.summaryReport?.dialogues?.length ?? 0) +
|
||||
(record.step3?.reportDialogues?.length ?? 0) +
|
||||
(record.step3?.dashboardDialogues?.length ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
function modelOf(record?: DraftRecord): string | undefined {
|
||||
return record?.step3?.summaryReport?.model ?? record?.step3?.model;
|
||||
}
|
||||
|
||||
function tagsOf(draft: DraftMeta, record?: DraftRecord): string[] {
|
||||
const chain = record?.step2?.chain?.title ?? draft.chainTitle;
|
||||
return chain ? [chain] : [];
|
||||
}
|
||||
|
||||
function timestamp(value: string): number {
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function renderHighlightedTitle(text: string, query: string) {
|
||||
if (!query.trim()) return text;
|
||||
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const parts = text.split(new RegExp(`(${escaped})`, "gi"));
|
||||
return parts.map((part, index) =>
|
||||
part.toLowerCase() === query.toLowerCase() ? (
|
||||
<mark key={index} className="rounded-xs bg-red-100 px-0.5 font-semibold text-red-900">
|
||||
{part}
|
||||
</mark>
|
||||
) : (
|
||||
part
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function RoundtableHistoryRow({
|
||||
item,
|
||||
searchQuery,
|
||||
onDelete,
|
||||
onOpen,
|
||||
}: {
|
||||
item: RoundtableItem;
|
||||
searchQuery: string;
|
||||
onDelete: () => void;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const { draft, status } = item;
|
||||
const statusClass = {
|
||||
completed: "border-emerald-200/80 bg-emerald-50 text-emerald-700",
|
||||
active: "border-red-200/80 bg-red-50 text-red-600",
|
||||
waiting: "border-amber-200/80 bg-amber-50 text-amber-700",
|
||||
failed: "border-rose-200/80 bg-rose-50 text-rose-600",
|
||||
cancelled: "border-neutral-200/80 bg-neutral-50 text-neutral-500",
|
||||
}[status.tone];
|
||||
const statusDotClass = {
|
||||
completed: "bg-emerald-500",
|
||||
active: "bg-red-500",
|
||||
waiting: "bg-amber-500",
|
||||
failed: "bg-rose-500",
|
||||
cancelled: "bg-neutral-400",
|
||||
}[status.tone];
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`conversation-item-${draft.id}`}
|
||||
onClick={(event) => {
|
||||
if ((event.target as HTMLElement).closest("button, input")) return;
|
||||
onOpen();
|
||||
}}
|
||||
className="group relative flex cursor-pointer items-start justify-between rounded-xl border border-neutral-200/80 bg-white p-4 transition-all duration-150 hover:!border-red-300 hover:!bg-gradient-to-r hover:!from-red-50/50 hover:!via-white hover:!to-white hover:shadow-xs sm:items-center"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-start gap-3.5 pr-4 sm:items-center">
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border !border-red-200 bg-red-50/90 px-2 py-0.5 text-[11px] font-semibold text-red-700">
|
||||
<span className="relative flex size-1.5">
|
||||
{status.tone === "active" && (
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-red-400 opacity-75" />
|
||||
)}
|
||||
<span className="relative inline-flex size-1.5 rounded-full bg-red-600" />
|
||||
</span>
|
||||
<Users2 className="size-3 text-red-600" />
|
||||
会商
|
||||
</span>
|
||||
<h3 className="truncate text-sm font-semibold text-neutral-900 transition-colors group-hover:text-red-700">
|
||||
{renderHighlightedTitle(desensitize(draft.title || "未命名会商"), searchQuery)}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 pt-0.5 text-[11px] text-neutral-400">
|
||||
<span className={`inline-flex items-center gap-1 rounded border !border-red-200 px-1.5 py-0.5 text-[10px] font-medium ${statusClass}`}>
|
||||
<span className={`size-1.5 rounded-full ${statusDotClass}`} />
|
||||
{status.label}
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded border border-neutral-200/60 bg-neutral-100 px-1.5 py-0.5 text-[10px] font-medium text-neutral-600">
|
||||
{status.stepLabel}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 rounded border border-neutral-200/40 bg-neutral-50 px-1.5 py-0.5 text-[10px] text-neutral-500">
|
||||
<Cpu className="size-2.5 text-neutral-400" />
|
||||
{item.businessChainName}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 text-neutral-400">
|
||||
<Clock className="size-3 text-neutral-400" />
|
||||
{formatRelative(draft.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
<div className="hidden items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100 sm:flex">
|
||||
<button
|
||||
id={`delete-btn-${draft.id}`}
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="rounded-md p-1.5 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-600"
|
||||
title="删除会商"
|
||||
aria-label="删除会商"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
id={`open-preview-btn-${draft.id}`}
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOpen();
|
||||
}}
|
||||
className="rounded-md p-1.5 text-neutral-500 transition-colors hover:bg-red-50 hover:text-red-700"
|
||||
title="打开会商详情"
|
||||
aria-label="打开会商详情"
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RoundtableHistoryTab({
|
||||
query,
|
||||
onTotalChange,
|
||||
}: {
|
||||
query: string;
|
||||
onTotalChange?: (total: number) => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { drafts, loading, refresh, rename, remove } = useRoundtableDrafts();
|
||||
const [renameId, setRenameId] = useState<string | null>(null);
|
||||
const [renameTitle, setRenameTitle] = useState("");
|
||||
const [shareId, setShareId] = useState<string | null>(null);
|
||||
const { drafts, loading, refresh, remove } = useRoundtableDrafts();
|
||||
const [records, setRecords] = useState<Record<string, DraftRecord>>({});
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedTag, setSelectedTag] = useState<string | null>(null);
|
||||
const [sortType, setSortType] = useState<SortType>("updated_desc");
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const keyword = query.trim().toLowerCase();
|
||||
const filtered = keyword
|
||||
? drafts.filter((draft) =>
|
||||
(draft.title || "未命名草稿").toLowerCase().includes(keyword),
|
||||
)
|
||||
: drafts;
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (drafts.length === 0) {
|
||||
setRecords({});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
void Promise.all(
|
||||
drafts.map(async (draft) => {
|
||||
try {
|
||||
return await getDraft(draft.id);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
).then((fullDrafts) => {
|
||||
if (cancelled) return;
|
||||
setRecords(
|
||||
Object.fromEntries(
|
||||
fullDrafts
|
||||
.filter((record): record is DraftRecord => record !== null)
|
||||
.map((record) => [record.id, record]),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [drafts]);
|
||||
|
||||
const allItems = useMemo<RoundtableItem[]>(
|
||||
() =>
|
||||
drafts.map((draft) => {
|
||||
const record = records[draft.id];
|
||||
return {
|
||||
draft,
|
||||
record,
|
||||
status: progressOf(draft),
|
||||
businessChainName: businessChainNameOf(draft, record),
|
||||
messageCount: interactionCount(record),
|
||||
model: modelOf(record),
|
||||
tags: tagsOf(draft, record),
|
||||
};
|
||||
}),
|
||||
[drafts, records],
|
||||
);
|
||||
const availableTags = useMemo(
|
||||
() => [...new Set(allItems.flatMap((item) => item.tags))],
|
||||
[allItems],
|
||||
);
|
||||
const filteredItems = useMemo(() => {
|
||||
const keyword = searchQuery.trim().toLowerCase();
|
||||
return allItems
|
||||
.filter((item) => {
|
||||
if (selectedTag && !item.tags.includes(selectedTag)) return false;
|
||||
return !keyword || (item.draft.title || "未命名会商").toLowerCase().includes(keyword);
|
||||
})
|
||||
.sort((left, right) => {
|
||||
if (sortType === "messages_desc") return right.messageCount - left.messageCount;
|
||||
const delta = timestamp(right.draft.updatedAt) - timestamp(left.draft.updatedAt);
|
||||
return sortType === "updated_desc" ? delta : -delta;
|
||||
});
|
||||
}, [allItems, searchQuery, selectedTag, sortType]);
|
||||
|
||||
useEffect(() => {
|
||||
onTotalChange?.(filtered.length);
|
||||
}, [filtered.length, onTotalChange]);
|
||||
onTotalChange?.(filteredItems.length);
|
||||
}, [filteredItems.length, onTotalChange]);
|
||||
|
||||
const openDraft = (draft: DraftMeta) => {
|
||||
sessionStorage.setItem("roundtable:pending-draft", draft.id);
|
||||
navigate(ROUNDTABLE_PATH);
|
||||
};
|
||||
|
||||
const handleRename = async (id: string, title: string) => {
|
||||
try {
|
||||
await rename(id, title);
|
||||
toast.success("已重命名");
|
||||
} catch {
|
||||
toast.error("重命名失败,请重试");
|
||||
} finally {
|
||||
setRenameId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (draft: DraftMeta) => {
|
||||
const deleteDraft = async (draft: DraftMeta) => {
|
||||
if (!window.confirm("确定删除这条会商记录吗?此操作不可恢复。")) return;
|
||||
try {
|
||||
await remove(draft.id);
|
||||
@@ -119,90 +332,135 @@ export function RoundtableHistoryTab({
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && drafts.length === 0) {
|
||||
return (
|
||||
<div className="text-muted-foreground py-16 text-center text-sm">
|
||||
加载中…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<div className="text-muted-foreground py-16 text-center text-sm">
|
||||
{keyword ? "没有匹配的会商记录" : "暂无会商记录"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
{filtered.map((draft) => {
|
||||
const progress = progressOf(draft);
|
||||
return (
|
||||
<HistoryRow
|
||||
key={draft.id}
|
||||
badge={<HistoryTypeBadge icon={Bot} label="会商" />}
|
||||
title={desensitize(draft.title || "未命名草稿")}
|
||||
meta={
|
||||
<>
|
||||
<HistoryStatusPill
|
||||
label={progress.label}
|
||||
className={progress.className}
|
||||
/>
|
||||
<span className="text-muted-foreground/60">·</span>
|
||||
<span className="max-w-48 truncate" title={progress.stepLabel}>
|
||||
{progress.stepLabel}
|
||||
</span>
|
||||
<span className="text-muted-foreground/60">·</span>
|
||||
<span className="max-w-40 truncate" title={draft.chainTitle || "自由模式"}>
|
||||
{draft.chainTitle || "自由模式"}
|
||||
</span>
|
||||
<span className="text-muted-foreground/60">·</span>
|
||||
<span>{formatRelative(draft.updatedAt)}</span>
|
||||
</>
|
||||
}
|
||||
onOpen={() => openDraft(draft)}
|
||||
<div className="w-full space-y-4">
|
||||
<div className="w-full space-y-3.5">
|
||||
<div className="flex flex-col items-stretch justify-between gap-3 md:flex-row md:items-center">
|
||||
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 md:pb-0">
|
||||
<button
|
||||
id="filter-all-btn"
|
||||
type="button"
|
||||
onClick={() => setSelectedTag(null)}
|
||||
className={`inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-xs font-medium transition-all ${
|
||||
!selectedTag
|
||||
? "border !border-red-600 bg-red-600 font-semibold text-white shadow-xs"
|
||||
: "border border-neutral-200 bg-white text-neutral-600 hover:border-red-200 hover:bg-red-50/60 hover:text-red-700"
|
||||
}`}
|
||||
>
|
||||
<HistoryRowIconButton
|
||||
title="重命名"
|
||||
onClick={() => {
|
||||
setRenameId(draft.id);
|
||||
setRenameTitle(draft.title || "");
|
||||
}}
|
||||
<span>全部</span>
|
||||
<span className={`rounded-full px-1.5 py-0.2 font-mono text-[11px] ${!selectedTag ? "bg-red-700 font-semibold text-white" : "bg-neutral-100 text-neutral-500"}`}>
|
||||
{allItems.length}
|
||||
</span>
|
||||
</button>
|
||||
{selectedTag && (
|
||||
<div className="inline-flex items-center gap-1 rounded-lg bg-red-600 px-2.5 py-1.5 text-xs font-medium text-white shadow-xs">
|
||||
<Tag className="size-3 text-red-100" />
|
||||
<span>{selectedTag}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedTag(null)}
|
||||
className="ml-1 hover:text-red-200"
|
||||
title="清除标签筛选"
|
||||
aria-label="清除标签筛选"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative flex-1 md:max-w-md">
|
||||
<Search className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-neutral-400" />
|
||||
<input
|
||||
id="conversation-search-input"
|
||||
type="search"
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder="搜索会商记录…"
|
||||
className="w-full rounded-xl border border-neutral-200 bg-neutral-50/80 py-2 pl-9 pr-16 text-sm text-neutral-900 transition-all placeholder:text-neutral-400 hover:bg-white focus:border-red-500 focus:bg-white focus:outline-hidden focus:ring-2 focus:ring-red-500/20"
|
||||
/>
|
||||
{searchQuery ? (
|
||||
<button
|
||||
id="clear-search-query-btn"
|
||||
type="button"
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 text-neutral-400 hover:text-red-600"
|
||||
aria-label="清空搜索"
|
||||
>
|
||||
<RenameIcon />
|
||||
</HistoryRowIconButton>
|
||||
<HistoryRowIconButton title="分享" onClick={() => setShareId(draft.id)}>
|
||||
<Share2 className="text-muted-foreground size-4" />
|
||||
</HistoryRowIconButton>
|
||||
<HistoryRowIconButton
|
||||
title="删除"
|
||||
onClick={() => void handleDelete(draft)}
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<div className="pointer-events-none absolute right-3 top-1/2 hidden -translate-y-1/2 items-center gap-0.5 rounded bg-neutral-200/60 px-1.5 py-0.5 font-mono text-[10px] text-neutral-400 sm:flex">
|
||||
⌘K
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-red-50 pt-1 text-xs text-neutral-500">
|
||||
<div className="flex items-center gap-3 overflow-x-auto">
|
||||
<span className="whitespace-nowrap font-medium text-neutral-500">
|
||||
共 <strong className="font-bold text-red-600">{filteredItems.length}</strong> 条会商记录
|
||||
{searchQuery && <span className="ml-1 text-neutral-400">(匹配 “{searchQuery}”)</span>}
|
||||
</span>
|
||||
{availableTags.length > 0 && (
|
||||
<div className="hidden items-center gap-1.5 text-xs lg:flex">
|
||||
<span className="text-neutral-400">快捷标签:</span>
|
||||
{availableTags.slice(0, 4).map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
type="button"
|
||||
onClick={() => setSelectedTag(selectedTag === tag ? null : tag)}
|
||||
className={`rounded-md px-2 py-0.5 transition-colors ${
|
||||
selectedTag === tag
|
||||
? "bg-red-600 font-medium text-white shadow-2xs"
|
||||
: "border !border-red-200 bg-red-50 text-red-700 hover:bg-red-100 hover:text-red-800"
|
||||
}`}
|
||||
>
|
||||
#{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 rounded-lg border border-neutral-200 bg-white px-2 py-1 shadow-2xs">
|
||||
<ArrowUpDown className="size-3 text-neutral-400" />
|
||||
<select
|
||||
id="sort-select-dropdown"
|
||||
value={sortType}
|
||||
onChange={(event) => setSortType(event.target.value as SortType)}
|
||||
className="cursor-pointer bg-transparent text-xs text-neutral-700 focus:outline-hidden"
|
||||
>
|
||||
<DeleteIcon />
|
||||
</HistoryRowIconButton>
|
||||
</HistoryRow>
|
||||
);
|
||||
})}
|
||||
<option value="updated_desc">最近活跃优先</option>
|
||||
<option value="updated_asc">最早创建优先</option>
|
||||
<option value="messages_desc">消息数量最多</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HistoryRenameDialog
|
||||
open={renameId !== null}
|
||||
initialTitle={renameTitle}
|
||||
dialogTitle="重命名会商"
|
||||
onCancel={() => setRenameId(null)}
|
||||
onConfirm={(title) => renameId && void handleRename(renameId, title)}
|
||||
/>
|
||||
|
||||
<ShareRoundtableDraftDialog
|
||||
draftId={shareId}
|
||||
open={shareId !== null}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setShareId(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
{loading && allItems.length === 0 ? (
|
||||
<div className="py-16 text-center text-sm text-muted-foreground">加载中…</div>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<div className="py-16 text-center text-sm text-muted-foreground">
|
||||
{searchQuery || selectedTag ? "没有匹配的会商记录" : "暂无会商记录"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredItems.map((item) => (
|
||||
<RoundtableHistoryRow
|
||||
key={item.draft.id}
|
||||
item={item}
|
||||
searchQuery={searchQuery}
|
||||
onDelete={() => void deleteDraft(item.draft)}
|
||||
onOpen={() => openDraft(item.draft)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -43,7 +43,7 @@ export function HistoryTypeBadge({
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-red-200/70 bg-red-50 px-2 py-0.5 text-[11px] font-semibold text-red-700">
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border !border-red-200 bg-red-50 px-2 py-0.5 text-[11px] font-semibold text-red-700">
|
||||
<Icon className="size-3 text-red-600" />
|
||||
{label}
|
||||
</span>
|
||||
@@ -68,7 +68,7 @@ export function HistoryRow({
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="group/chat-row relative mb-3 flex min-w-0 rounded-[10px] border border-neutral-200/80 bg-white transition-[border-color,box-shadow,background-color] duration-150 last:mb-0 hover:border-red-200 hover:bg-red-50/20 hover:shadow-xs">
|
||||
<div className="group/chat-row relative mb-3 flex min-w-0 rounded-[10px] border border-neutral-200/80 bg-white transition-[border-color,box-shadow,background-color] duration-150 last:mb-0 hover:!border-red-300 hover:!bg-gradient-to-r hover:!from-red-50/50 hover:!via-white hover:!to-white hover:shadow-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
|
||||
Reference in new issue
Block a user