feat(圆桌): 后台挂起按钮移至顶部暂停按钮右侧 + 实时研讨复位历史闸门

- Step2 顶部「暂停/恢复会商」右侧新增「后台挂起」按钮(查看历史时隐藏),移除底部干预栏里的旧入口
- handleConfirmAgents 从第一步开启实时研讨时复位 loadedFromDraftRef,修复看过历史后再开新讨论按钮不出现的问题
- 大屏 dashboard 多组件样式与业务链条编辑页细节调整

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
董宏凯andClaude Opus 4.8 committed 2026-06-13 17:37:14 +08:00
1 parent 634cb1413b
commit 61754acb87
18 files changed
+672 -368

No files matched your search

+255 -52
View File
@@ -1,27 +1,71 @@
/**
* 应用列表页 —— 以卡片网格展示「新页跳转(window.open)」类型的轻应用。
*
* 支持搜索 + 分页。点击卡片用 buildLightAppUrl 构造最终地址(追加解析后的路由参数)
* 并 window.open 新开页跳转。仅展示已启用的应用;iframe 类型不在此处(它们注入侧边栏)。
* 只读启动器:搜索 + 分页;每张卡片预览「拼接后最终路由」(buildLightAppUrl 追加解析后的
* 登录态 / 主题路由参数),并提供「一键复制URL」「进入应用」(window.open 新开页) 两个动作。
* 增删改不在本页,走独立的「应用管理」页(admin);iframe 类型也不在此(它们注入侧边栏)。
*
* 配色(与「业务链条配置」页统一):
* - 浅色:品牌红 `#CF1322`(hover `#A8071A`)。
* - 深蓝:按《深蓝色 UI 规范》——页面 `#0C1836`、卡片 `#122347`、边框 `#1A2F5A`、
* 正文 `#C5CFE0`、次级 `#6E88B0`;主操作改用规范主色蓝 `#1677FF`(hover `#0958D9`)。
*
* 后端 LightApp 无「分类」字段,故不做分类筛选;卡片图标 / 主题色按 appId 哈希派生
* (`icon` 字段当前未用),仅作视觉区分,不承载语义。
*/
import { useEffect, useMemo, useState } from "react";
import { ExternalLink, Search } from "lucide-react";
import {
ArrowUpRight,
Bot,
Check,
Code,
Copy,
FileText,
Globe,
Layers,
Notebook,
Search,
SlidersHorizontal,
Terminal,
X,
type LucideIcon,
} from "lucide-react";
import { toast } from "sonner";
import { Input } from "@/components/ui/input";
import { Pagination } from "@/components/ui/pagination";
import { useTheme } from "@/contexts/ThemeContext";
import { cn } from "@/lib/utils";
import { useLightApps, type LightApp } from "@/strategy-components/api/light-apps";
import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
const PAGE_SIZE = 12;
export default function LightAppListPage() {
const { theme } = useTheme();
const isDark = theme === "dark";
/** 卡片图标池(按 appId 哈希取一个,仅作视觉区分)。 */
const ICON_POOL: LucideIcon[] = [Bot, Notebook, Terminal, SlidersHorizontal, Globe, Code, Layers, FileText];
/** 卡片主题色池:浅色用对应色阶,深蓝统一落到 `#1A2F5A` 容器底 + 规范强调色。 */
const THEME_POOL = [
{ iconBg: "bg-rose-100 text-rose-600 dark:bg-[#1A2F5A] dark:text-[#FF7875]" },
{ iconBg: "bg-indigo-100 text-indigo-600 dark:bg-[#1A2F5A] dark:text-[#6388FF]" },
{ iconBg: "bg-emerald-100 text-emerald-600 dark:bg-[#1A2F5A] dark:text-[#52C41A]" },
{ iconBg: "bg-amber-100 text-amber-600 dark:bg-[#1A2F5A] dark:text-[#FAAD14]" },
{ iconBg: "bg-blue-100 text-blue-600 dark:bg-[#1A2F5A] dark:text-[#4096FF]" },
{ iconBg: "bg-purple-100 text-purple-600 dark:bg-[#1A2F5A] dark:text-[#9C7BFF]" },
] as const;
/** 稳定字符串哈希 → [0, mod) 下标。 */
function hashIndex(str: string, mod: number): number {
let h = 0;
for (let i = 0; i < str.length; i += 1) {
h = (h * 31 + str.charCodeAt(i)) >>> 0;
}
return mod > 0 ? h % mod : 0;
}
export default function LightAppListPage() {
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [copiedId, setCopiedId] = useState<string | null>(null);
const { data: apps = [], isLoading } = useLightApps({
appType: "window_open",
enabledOnly: true,
@@ -47,67 +91,226 @@ export default function LightAppListPage() {
window.open(url, "_blank", "noopener,noreferrer");
};
const shellClass = isDark
? "h-full overflow-y-auto bg-[linear-gradient(180deg,#081322_0%,#0d1d33_42%,#0a1628_100%)] text-slate-100"
: "h-full overflow-y-auto bg-[linear-gradient(180deg,#fffdfd_0%,#fff7f7_42%,#fafafa_100%)] text-[#222222]";
const cardClass = isDark
? "group cursor-pointer rounded-2xl border border-[rgba(67,89,124,0.46)] bg-[rgba(12,29,54,0.94)] p-4 transition-all hover:-translate-y-0.5 hover:border-[rgba(77,141,255,0.6)] hover:shadow-[0_18px_38px_rgba(3,10,22,0.35)]"
: "group cursor-pointer rounded-2xl border border-[#E8E8E8] bg-white p-4 transition-all hover:-translate-y-0.5 hover:border-[#FFA39E] hover:shadow-[0_16px_32px_rgba(245,34,45,0.1)]";
const mutedText = isDark ? "text-slate-500" : "text-[#A6A6A6]";
const iconWrap = isDark
? "bg-[rgba(77,141,255,0.16)] text-[#9CCBFF]"
: "bg-[#FFF1F0] text-[#CF1322]";
const copyUrl = async (app: LightApp) => {
const url = buildLightAppUrl(app);
if (!url) {
toast.error("该应用地址为空,无法复制");
return;
}
try {
await navigator.clipboard.writeText(url);
setCopiedId(app.appId);
setTimeout(() => setCopiedId((id) => (id === app.appId ? null : id)), 2000);
} catch {
toast.error("复制失败,请手动选择地址");
}
};
return (
<div className={shellClass}>
<div className="mx-auto flex min-h-full max-w-[1280px] flex-col gap-5 px-6 py-6 lg:px-8">
<div className="h-full overflow-y-auto bg-zinc-50/60 font-sans text-zinc-900 antialiased dark:bg-[#0C1836] dark:text-[#C5CFE0]">
<div className="mx-auto flex min-h-full w-full max-w-7xl flex-col gap-6 px-4 py-7 sm:px-6 lg:px-8">
{/* 标题 */}
<div className="space-y-1">
<h1 className={`text-[26px] font-semibold tracking-[0.02em] ${isDark ? "text-slate-50" : "text-[#222222]"}`}>
应用列表
</h1>
<p className={`text-sm ${isDark ? "text-slate-300/80" : "text-slate-500"}`}>
点击卡片在新标签页打开应用。地址会自动附带配置的路由参数(登录态 / 主题)。
<div className="flex items-center gap-2.5">
<span className="relative flex size-3">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-[#CF1322] opacity-75 dark:bg-[#1677FF]" />
<span className="relative inline-flex size-3 rounded-full bg-[#CF1322] dark:bg-[#1677FF]" />
</span>
<h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-[#E8EDF5]">
应用列表
</h1>
<span className="rounded-full border border-[#CF1322]/15 bg-[#FFF1F0] px-2 py-0.5 font-mono text-xs font-medium text-[#CF1322] dark:border-[#1677FF]/30 dark:bg-[#1677FF]/15 dark:text-[#4096FF]">
导航工作台
</span>
</div>
<p className="max-w-2xl text-sm leading-relaxed text-zinc-500 dark:text-[#6E88B0]">
点击卡片将在新标签页中一键直达应用。地址会自动、智能地拼接路由参数(跨域登录态、个性化主题等)。
</p>
</div>
<div className="relative max-w-sm">
<Search className={`pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 ${mutedText}`} />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜索应用名称 / 描述"
className="pl-9"
/>
{/* 搜索 */}
<div className="rounded-2xl border border-zinc-200/80 bg-white p-5 shadow-sm dark:border-[#1A2F5A] dark:bg-[#122347]">
<div className="relative w-full">
<Search className="pointer-events-none absolute left-3.5 top-1/2 size-4.5 -translate-y-1/2 text-zinc-400 dark:text-[#6E88B0]" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="键入应用名称、目标地址、或描述,实时过滤检索…"
className="w-full rounded-xl border border-zinc-200 bg-zinc-50/50 py-3 pl-10 pr-10 text-sm transition focus:border-[#CF1322]/60 focus:bg-white focus:outline-none focus:ring-2 focus:ring-[#CF1322]/20 dark:border-[#1A2F5A] dark:bg-[#0C1836] dark:text-[#C5CFE0] dark:placeholder-[#4A6491] dark:focus:border-[#1677FF]/60 dark:focus:bg-[#122347] dark:focus:ring-[#1677FF]/25"
/>
{search && (
<button
type="button"
onClick={() => setSearch("")}
title="清除搜索"
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-zinc-400 transition hover:text-zinc-600 dark:hover:text-[#C5CFE0]"
>
<X className="size-4" />
</button>
)}
</div>
</div>
{/* 结果计数 */}
<div className="-mt-2 flex items-center justify-between">
<div className="text-xs font-medium text-zinc-500 dark:text-[#6E88B0]">
检索到最终结果:{" "}
<span className="font-bold text-[#CF1322] dark:text-[#4096FF]">{apps.length}</span> 个关联应用
</div>
{search && (
<button
type="button"
onClick={() => setSearch("")}
className="text-xs font-medium text-[#CF1322] underline hover:text-[#A8071A] dark:text-[#4096FF] dark:hover:text-[#1677FF]"
>
重置过滤条件
</button>
)}
</div>
{/* 卡片网格 / 空态 */}
{apps.length === 0 ? (
<div className={`rounded-3xl border py-20 text-center ${isDark ? "border-[rgba(67,89,124,0.4)] text-slate-500" : "border-[#F0F0F0] text-[#A6A6A6]"}`}>
{isLoading ? "加载中…" : "暂无可用应用"}
<div className="rounded-2xl border border-dashed border-zinc-200 bg-white p-16 text-center shadow-sm dark:border-[#1A2F5A] dark:bg-[#122347]">
<div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-full border border-zinc-100 bg-zinc-50 text-zinc-300 dark:border-[#1A2F5A] dark:bg-[#0C1836] dark:text-[#6E88B0]">
<Search className="size-6" />
</div>
<h4 className="mb-1 text-base font-medium text-zinc-800 dark:text-[#C5CFE0]">
{isLoading ? "正在加载应用…" : search ? "未找到匹配的应用" : "暂无可用应用"}
</h4>
<p className="mx-auto max-w-md text-sm text-zinc-400 dark:text-[#6E88B0]">
{isLoading
? "请稍候。"
: search
? "尝试更换关键词,或清除搜索查看全部应用。"
: "应用由管理员在「应用管理」中登记后会出现在这里。"}
</p>
</div>
) : (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{pageApps.map((app) => (
<button key={app.appId} type="button" onClick={() => openApp(app)} className={`${cardClass} text-left`}>
<div className="flex items-start gap-3">
<span className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl ${iconWrap}`}>
<ExternalLink className="h-5 w-5" />
</span>
<div className="min-w-0 flex-1">
<div className={`truncate text-[15px] font-semibold ${isDark ? "text-white" : "text-[#222222]"}`}>
{app.name}
</div>
<div className={`mt-1 line-clamp-2 text-xs leading-relaxed ${isDark ? "text-slate-400" : "text-slate-500"}`}>
{app.description || app.url}
</div>
</div>
</div>
</button>
<AppCard
key={app.appId}
app={app}
copied={copiedId === app.appId}
onCopy={() => void copyUrl(app)}
onOpen={() => openApp(app)}
/>
))}
</div>
<Pagination page={page} pageSize={PAGE_SIZE} total={apps.length} onPageChange={setPage} className="pt-2" />
{apps.length > PAGE_SIZE && (
<Pagination
page={page}
pageSize={PAGE_SIZE}
total={apps.length}
onPageChange={setPage}
className="pt-2"
/>
)}
</>
)}
{/* 页脚 */}
<footer className="mt-auto pt-8 text-center text-xs text-zinc-400 dark:text-[#4A6491]">
应用导航工作台 • 优雅可靠的任务中转入口
</footer>
</div>
</div>
);
}
// ── 单张应用卡 ───────────────────────────────────────────────────────────────
function AppCard({
app,
copied,
onCopy,
onOpen,
}: {
app: LightApp;
copied: boolean;
onCopy: () => void;
onOpen: () => void;
}) {
const Icon = ICON_POOL[hashIndex(app.appId, ICON_POOL.length)];
const theme = THEME_POOL[hashIndex(app.appId, THEME_POOL.length)];
const parsedUrl = buildLightAppUrl(app);
const hasParams = (app.routeParams ?? []).some((p) => p.key) && parsedUrl !== app.url;
return (
<div className="group relative flex flex-col justify-between overflow-hidden rounded-2xl border border-zinc-100 bg-white p-5 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition-all duration-300 hover:-translate-y-1 hover:border-zinc-200/90 hover:shadow-[0_12px_24px_rgba(0,0,0,0.04)] dark:border-[#1A2F5A] dark:bg-[#122347] dark:shadow-none dark:hover:border-[#2D4473]">
{/* 上:图标 + 标题 + 描述 */}
<div>
<div className="mb-3.5 flex items-start gap-4">
<div className={cn("rounded-xl border border-white/60 p-3 shadow-sm dark:border-transparent", theme.iconBg)}>
<Icon className="size-5.5" />
</div>
</div>
<h4 className="mb-2 line-clamp-1 text-base font-bold leading-snug text-zinc-900 transition-colors group-hover:text-[#CF1322] dark:text-[#E8EDF5] dark:group-hover:text-[#4096FF]">
{app.name}
</h4>
<p className="mb-4 line-clamp-2 min-h-[2.5rem] text-xs leading-relaxed text-zinc-500 dark:text-[#6E88B0]">
{app.description || "暂无应用详细描述说明,点击进入下方链接查看详情。"}
</p>
</div>
{/* 下:URL 预览 + 动作 */}
<div className="mt-auto border-t border-zinc-100 pt-4 dark:border-[#1A2F5A]">
<div className="mb-4 rounded-xl border border-zinc-100 bg-zinc-50/70 p-3 dark:border-[#1A2F5A] dark:bg-[#0C1836]">
<div className="mb-1 flex items-center justify-between text-[10px] font-semibold text-zinc-400 dark:text-[#6E88B0]">
<span>拼接后目标路由终端:</span>
<span className="text-[9px] font-bold uppercase tracking-wider text-[#CF1322] dark:text-[#4096FF]">
自动拼接已激活参
</span>
</div>
<div className="line-clamp-1 select-all break-all font-mono text-[11px] font-medium text-zinc-600 dark:text-[#C5CFE0]">
{parsedUrl || "(地址为空)"}
</div>
{hasParams && (
<div className="mt-1 flex items-center gap-1 text-[9px] text-zinc-400 dark:text-[#6E88B0]">
<span className="inline-block size-1 rounded-full bg-amber-400 dark:bg-[#FAAD14]" />
<span>包含动态拼接参数</span>
</div>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={onCopy}
className={cn(
"flex items-center justify-center gap-1.5 rounded-lg border px-3 py-2 text-xs font-semibold transition",
copied
? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-[#52C41A]/30 dark:bg-[#52C41A]/15 dark:text-[#52C41A]"
: "border-zinc-200 bg-white text-zinc-600 hover:bg-zinc-50 hover:text-zinc-800 dark:border-[#1A2F5A] dark:bg-[#122347] dark:text-[#C5CFE0] dark:hover:bg-[#1A2F5A]",
)}
>
{copied ? (
<>
<Check className="size-3.5" />
<span>已复制!</span>
</>
) : (
<>
<Copy className="size-3.5" />
<span>一键复制URL</span>
</>
)}
</button>
<button
type="button"
onClick={onOpen}
className="flex items-center justify-center gap-1.5 rounded-lg bg-[#CF1322] px-3 py-2 text-xs font-semibold text-white shadow-[0_1px_3px_rgba(207,19,34,0.2)] transition hover:bg-[#A8071A] active:scale-[0.98] dark:bg-[#1677FF] dark:shadow-none dark:hover:bg-[#0958D9]"
>
<span>进入应用</span>
<ArrowUpRight className="size-3.5" />
</button>
</div>
</div>
</div>
);
@@ -528,7 +528,7 @@ export default function LightAppManagementPage() {
className="flex h-7 cursor-pointer items-center space-x-1 rounded-[2px] border border-[#DBDBDB] bg-white px-2.5 text-[12px] font-bold text-[#575757] transition-colors hover:border-[#F5222D] hover:text-[#F5222D]"
>
<Plus className="h-3.5 w-3.5" />
<span>+ 添加路由参数</span>
<span>添加路由参数</span>
</button>
</div>