/** * 应用列表页 —— 以卡片网格展示「新页跳转(window.open)」类型的轻应用。 * * 只读启动器:搜索 + 分页;每张卡片预览「拼接后最终路由」(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 { ArrowUpRight, Bot, Check, Code, Copy, FileText, Globe, Layers, Notebook, Search, SlidersHorizontal, Terminal, X, type LucideIcon, } from "lucide-react"; import { toast } from "sonner"; import { Pagination } from "@/components/ui/pagination"; import { cn } from "@/lib/utils"; import { useTheme, type ThemeType } from "@/contexts/ThemeContext"; import { isCustomThemeId } from "@/core/theme/custom-theme"; import { useLightApps, type LightApp } from "@/strategy-components/api/light-apps"; import { buildLightAppUrl } from "@/strategy-components/lib/light-app"; import "./light-app-pages.css"; const PAGE_SIZE = 12; /** 卡片图标池(按 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; } function getLightAccent(theme: ThemeType) { if (isCustomThemeId(theme)) { return { dot: "bg-[var(--primary)]", badge: "border-[var(--border)] bg-[var(--accent)] text-[var(--accent-foreground)]", focus: "focus:border-[var(--brand-900)] focus:ring-[var(--ring)]", text: "text-[var(--accent-foreground)]", hoverText: "group-hover:text-[var(--accent-foreground)]", link: "text-[var(--accent-foreground)] hover:text-[var(--brand-950)]", button: "bg-[var(--primary)] text-[var(--button-foreground,var(--primary-foreground))] shadow-sm hover:bg-[var(--theme-button-hover)]", }; } if (theme === "lightgreen") { return { dot: "bg-[#07C05F]", badge: "border-[#07C05F]/15 bg-[#E9F8EC] text-[#049B38]", focus: "focus:border-[#07C05F]/60 focus:ring-[#07C05F]/20", text: "text-[#049B38]", hoverText: "group-hover:text-[#049B38]", link: "text-[#049B38] hover:text-[#038626]", button: "bg-[#07C05F] shadow-[0_1px_3px_rgba(7,192,95,0.2)] hover:bg-[#08DD6E]", }; } if (theme === "lightblue") { return { dot: "bg-[#0062D9]", badge: "border-[#0062D9]/15 bg-[#EAF4FF] text-[#0062D9]", focus: "focus:border-[#0062D9]/60 focus:ring-[#0062D9]/20", text: "text-[#0062D9]", hoverText: "group-hover:text-[#0062D9]", link: "text-[#0062D9] hover:text-[#0056C6]", button: "bg-[#0062D9] shadow-[0_1px_3px_rgba(0,98,217,0.2)] hover:bg-[#0079FE]", }; } if (theme === "iceblue") { return { dot: "bg-[#9BCEF1]", badge: "border-[#3D6E96]/15 bg-[#E1F1FB] text-[#2D587A]", focus: "focus:border-[#3D6E96]/60 focus:ring-[#3D6E96]/20", text: "text-[#2D587A]", hoverText: "group-hover:text-[#2D587A]", link: "text-[#2D587A] hover:text-[#1E405C]", button: "bg-[#9BCEF1] text-[#18324A] shadow-[0_1px_3px_rgba(155,206,241,0.42)] hover:bg-[#B7DCF5]", }; } if (theme === "lightyellow") { return { dot: "bg-[#F3FBD9]", badge: "border-[#4C6330]/15 bg-[#EEF6D4] text-[#4C6330]", focus: "focus:border-[#4C6330]/60 focus:ring-[#F3FBD9]/40", text: "text-[#4C6330]", hoverText: "group-hover:text-[#4C6330]", link: "text-[#4C6330] hover:text-[#34451F]", button: "bg-[#F3FBD9] text-[#303D23] shadow-[0_1px_3px_rgba(243,251,217,0.72)] hover:bg-[#E4EFBF]", }; } return { dot: "bg-[#CF1322]", badge: "border-[#CF1322]/15 bg-[#FFF1F0] text-[#CF1322]", focus: "focus:border-[#CF1322]/60 focus:ring-[#CF1322]/20", text: "text-[#CF1322]", hoverText: "group-hover:text-[#CF1322]", link: "text-[#CF1322] hover:text-[#A8071A]", button: "bg-[#CF1322] shadow-[0_1px_3px_rgba(207,19,34,0.2)] hover:bg-[#A8071A]", }; } export default function LightAppListPage() { const [search, setSearch] = useState(""); const [page, setPage] = useState(1); const [copiedId, setCopiedId] = useState(null); const { theme } = useTheme(); const lightAccent = getLightAccent(theme); const { data: apps = [], isLoading } = useLightApps({ appType: "window_open", enabledOnly: true, search, }); // 搜索变化时回到第一页。 useEffect(() => { setPage(1); }, [search]); const pageApps = useMemo( () => apps.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), [apps, page], ); const openApp = (app: LightApp) => { const url = buildLightAppUrl(app); if (!url) { toast.error("该应用地址为空,无法打开"); return; } window.open(url, "_blank", "noopener,noreferrer"); }; 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 (
{/* 标题 */}

应用列表

导航工作台

点击卡片将在新标签页中一键直达应用。地址会自动、智能地拼接路由参数(跨域登录态、个性化主题等)。

{/* 搜索 */}
setSearch(e.target.value)} placeholder="键入应用名称、目标地址、或描述,实时过滤检索…" className={cn("w-full rounded-[5px] border border-zinc-200 bg-zinc-50/50 py-3 pl-10 pr-10 text-sm transition focus:bg-white focus:outline-none focus:ring-2 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", lightAccent.focus)} /> {search && ( )}
{/* 结果计数 */}
检索到最终结果:{" "} {apps.length} 个关联应用
{search && ( )}
{/* 卡片网格 / 空态 */} {apps.length === 0 ? (

{isLoading ? "正在加载应用…" : search ? "未找到匹配的应用" : "暂无可用应用"}

{isLoading ? "请稍候。" : search ? "尝试更换关键词,或清除搜索查看全部应用。" : "应用由管理员在「应用管理」中登记后会出现在这里。"}

) : ( <>
{pageApps.map((app) => ( void copyUrl(app)} onOpen={() => openApp(app)} /> ))}
{apps.length > PAGE_SIZE && ( )} )} {/* 页脚 */}
应用导航工作台 • 优雅可靠的任务中转入口
); } // ── 单张应用卡 ─────────────────────────────────────────────────────────────── function AppCard({ app, copied, onCopy, onOpen, }: { app: LightApp; copied: boolean; onCopy: () => void; onOpen: () => void; }) { const { theme } = useTheme(); const lightAccent = getLightAccent(theme); const Icon = ICON_POOL[hashIndex(app.appId, ICON_POOL.length)]; const cardTheme = THEME_POOL[hashIndex(app.appId, THEME_POOL.length)]; const parsedUrl = buildLightAppUrl(app); const hasParams = (app.routeParams ?? []).some((p) => p.key) && parsedUrl !== app.url; return (
{/* 上:图标 + 标题 + 描述 */}

{app.name}

{app.description || "暂无应用详细描述说明,点击进入下方链接查看详情。"}

{/* 下:URL 预览 + 动作 */}
拼接后目标路由终端: 自动拼接已激活参
{parsedUrl || "(地址为空)"}
{hasParams && (
包含动态拼接参数
)}
); }