Files

393 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 应用列表页 —— 以卡片网格展示「新页跳转(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<string | null>(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 (
<div className="light-app-list-page 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">
<div className="flex items-center gap-2.5">
<span className="relative flex size-3">
<span className={cn("absolute inline-flex size-full animate-ping rounded-full opacity-75", lightAccent.dot, "dark:bg-[#1677FF]")} />
<span className={cn("relative inline-flex size-3 rounded-full", lightAccent.dot, "dark:bg-[#1677FF]")} />
</span>
<h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-[#E8EDF5]">
应用列表
</h1>
<span className={cn("rounded-[5px] border px-2 py-0.5 font-mono text-xs font-medium dark:border-[#1677FF]/30 dark:bg-[#1677FF]/15 dark:text-[#4096FF]", lightAccent.badge)}>
导航工作台
</span>
</div>
<p className="max-w-2xl text-sm leading-relaxed text-zinc-500 dark:text-[#6E88B0]">
点击卡片将在新标签页中一键直达应用。地址会自动、智能地拼接路由参数(跨域登录态、个性化主题等)。
</p>
</div>
{/* 搜索 */}
<div className="rounded-[5px] bg-white p-5 shadow-sm 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={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 && (
<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={cn("font-bold dark:text-[#4096FF]", lightAccent.text)}>{apps.length}</span> 个关联应用
</div>
{search && (
<button
type="button"
onClick={() => setSearch("")}
className={cn("text-xs font-medium underline dark:text-[#4096FF] dark:hover:text-[#1677FF]", lightAccent.link)}
>
重置过滤条件
</button>
)}
</div>
{/* 卡片网格 / 空态 */}
{apps.length === 0 ? (
<div className="rounded-[5px] bg-white p-16 text-center shadow-sm 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-6 md:grid-cols-2 lg:grid-cols-3">
{pageApps.map((app) => (
<AppCard
key={app.appId}
app={app}
copied={copiedId === app.appId}
onCopy={() => void copyUrl(app)}
onOpen={() => openApp(app)}
/>
))}
</div>
{apps.length > PAGE_SIZE && (
<Pagination
page={page}
pageSize={PAGE_SIZE}
total={apps.length}
onPageChange={setPage}
className="pt-2 [&_button]:rounded-[5px]"
/>
)}
</>
)}
{/* 页脚 */}
<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 { 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 (
<div className="group relative flex flex-col justify-between overflow-hidden rounded-[5px] bg-white p-5 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition-all duration-300 hover:-translate-y-1 hover:shadow-[0_12px_24px_rgba(0,0,0,0.04)] dark:bg-[#122347] dark:shadow-none">
{/* 上:图标 + 标题 + 描述 */}
<div>
<div className="mb-3.5 flex items-start gap-4">
<div className={cn("rounded-[5px] p-3 shadow-sm", cardTheme.iconBg)}>
<Icon className="size-5.5" />
</div>
</div>
<h4 className={cn("mb-2 line-clamp-1 text-base font-bold leading-snug text-zinc-900 transition-colors dark:text-[#E8EDF5] dark:group-hover:text-[#4096FF]", lightAccent.hoverText)}>
{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-[5px] bg-zinc-50/70 p-3 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={cn("text-[9px] font-bold uppercase tracking-wider dark:text-[#4096FF]", lightAccent.text)}>
自动拼接已激活参
</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-[5px] 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={cn("flex items-center justify-center gap-1.5 rounded-[5px] px-3 py-2 text-xs font-semibold text-white transition active:scale-[0.98] dark:bg-[#1677FF] dark:text-white dark:shadow-none dark:hover:bg-[#0958D9]", lightAccent.button)}
>
<span>进入应用</span>
<ArrowUpRight className="size-3.5" />
</button>
</div>
</div>
</div>
);
}