393 lines
16 KiB
TypeScript
393 lines
16 KiB
TypeScript
/**
|
||
* 应用列表页 —— 以卡片网格展示「新页跳转(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>
|
||
);
|
||
}
|