Files
deerflow-code/frontend-web/src/pages/LightAppManagementPage.tsx
T
董宏凯andClaude Opus 4.8 9ed5420381 feat(light-app): 应用管理页加类型分类筛选 + 修复深色顶栏标题高分屏内边距
- 应用管理工具栏新增「全部/新页跳转/iframe嵌套」分类切换,选中 iframe
  嵌套时出现挂载菜单下拉,可按 mountLocation 进一步筛选(深色/浅蓝主题适配)
- 深色顶栏 .header-center-title 内边距由固定 155px 改为 8.07vw,随视口
  等比缩放以匹配 background cover,修复 4K 等高分屏两侧菜单贴近中间的问题

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:33:18 +08:00

1004 lines
47 KiB
TypeScript

/**
* 应用管理页 —— 注册 / 编辑轻应用(iframe 与 window.open)。
*
* 深蓝主题适配见 `./light-app-management.css`(《深蓝色 UI 规范》tokens.css)。
*/
import { useMemo, useState } from "react";
import {
AlertCircle,
AlignLeft,
ChevronDown,
ChevronUp,
Copy,
ExternalLink,
FolderTree,
Globe,
HelpCircle,
Info,
Layers,
Link2,
Monitor,
Pencil,
Plus,
Search,
Sliders,
Tag,
Trash2,
X,
} from "lucide-react";
import { toast } from "sonner";
import { Select } from "tdesign-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
useLightAppActions,
useLightApps,
type LightApp,
type LightAppRouteParam,
type LightAppType,
} from "@/strategy-components/api/light-apps";
import {
APP_THEME_COLORS,
LOGIN_SOURCE_OPTIONS,
MOUNT_OPTIONS,
mountLabel,
parseThemeMap,
stringifyThemeMap,
} from "@/strategy-components/lib/light-app";
import "./light-app-management.css";
interface FormState {
name: string;
description: string;
appType: LightAppType;
url: string;
mountLocation: string;
enabled: boolean;
routeParams: LightAppRouteParam[];
}
const APP_TYPE_OPTIONS: { label: string; value: LightAppType }[] = [
{ label: "iframe 嵌套 (挂载到菜单)", value: "iframe" },
{ label: "window.open (新页跳转)", value: "window_open" },
];
const PARAM_TYPE_OPTIONS: { label: string; value: "login" | "theme" }[] = [
{ label: "登录参数", value: "login" },
{ label: "主题参数", value: "theme" },
];
const DIALOG_SELECT_POPUP = {
attach: () => document.body,
overlayClassName: "lam-select-popup",
};
const emptyForm = (): FormState => ({
name: "",
description: "",
appType: "window_open",
url: "",
mountLocation: "",
enabled: true,
routeParams: [],
});
const newParam = (): LightAppRouteParam => ({
key: "",
paramType: "login",
source: "accessToken",
customValue: "",
themeValue: "red=red;blue=blue;dark-blue=dark-blue",
});
function formFromApp(app: LightApp): FormState {
return {
name: app.name,
description: app.description ?? "",
appType: app.appType,
url: app.url,
mountLocation: app.mountLocation ?? "",
enabled: app.enabled,
routeParams: app.routeParams.map((p) => ({
key: p.key,
paramType: p.paramType,
source: p.source ?? "accessToken",
customValue: p.customValue ?? "",
themeValue: p.themeValue ?? "",
})),
};
}
function getParamSourceLabel(param: LightAppRouteParam): string {
if (param.paramType === "theme") {
return `主题参数: ${param.themeValue || ""}`;
}
const source = param.source || "accessToken";
const option = LOGIN_SOURCE_OPTIONS.find((o) => o.value === source);
if (source === "other") {
return `登录参数: ${param.customValue || ""}`;
}
return `登录参数: ${option?.label || source}`;
}
export default function LightAppManagementPage() {
const [search, setSearch] = useState("");
const { data: apps = [], isLoading } = useLightApps({ search });
const actions = useLightAppActions();
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<LightApp | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
const [activeParamPopover, setActiveParamPopover] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<"table" | "cards">("table");
const [categoryFilter, setCategoryFilter] = useState<"all" | LightAppType>("all");
const [mountFilter, setMountFilter] = useState<string>("");
const saving = actions.create.isPending || actions.update.isPending;
const openCreate = () => {
setEditing(null);
setForm(emptyForm());
setDialogOpen(true);
};
const openEdit = (app: LightApp) => {
setEditing(app);
setForm(formFromApp(app));
setDialogOpen(true);
};
const patchForm = (patch: Partial<FormState>) =>
setForm((f) => ({ ...f, ...patch }));
const patchParam = (idx: number, patch: Partial<LightAppRouteParam>) =>
setForm((f) => ({
...f,
routeParams: f.routeParams.map((p, i) => (i === idx ? { ...p, ...patch } : p)),
}));
const addParam = () => patchForm({ routeParams: [...form.routeParams, newParam()] });
const removeParam = (idx: number) =>
patchForm({ routeParams: form.routeParams.filter((_, i) => i !== idx) });
const handleSave = async () => {
const name = form.name.trim();
const url = form.url.trim();
if (!name) return toast.error("请输入应用名称");
if (!url) return toast.error("请输入应用地址");
if (form.appType === "iframe" && !form.mountLocation) {
return toast.error("iframe 类型请选择挂载菜单");
}
for (const p of form.routeParams) {
if (!p.key.trim()) return toast.error("路由参数键名不可为空");
if (p.paramType === "login" && p.source === "other" && !p.customValue?.trim()) {
return toast.error("登录参数自定义值不可为空");
}
if (p.paramType === "theme" && !p.themeValue?.trim()) {
return toast.error("主题参数值不可为空");
}
}
const payload = {
name,
description: form.description.trim() || null,
appType: form.appType,
url,
routeParams: cleanParams(form.routeParams),
mountLocation: form.appType === "iframe" ? form.mountLocation : null,
enabled: form.enabled,
};
try {
if (editing) {
await actions.update.mutateAsync({ appId: editing.appId, patch: payload });
toast.success(`已更新「${name}」`);
} else {
await actions.create.mutateAsync(payload);
toast.success(`已注册「${name}」`);
}
setDialogOpen(false);
} catch (err) {
toast.error((err as Error).message || "保存失败");
}
};
const handleDelete = async (app: LightApp) => {
if (!window.confirm(`是否确定要删除「${app.name}」?该操作不可逆。`)) return;
try {
await actions.remove.mutateAsync(app.appId);
toast.success(`已删除「${app.name}」`);
} catch (err) {
toast.error((err as Error).message || "删除失败");
}
};
const handleCopyUrl = (url: string) => {
navigator.clipboard?.writeText(url);
toast.success("已复制地址");
};
const selectCategory = (cat: "all" | LightAppType) => {
setCategoryFilter(cat);
if (cat !== "iframe") setMountFilter("");
};
const tableApps = useMemo(
() =>
apps.filter((app) => {
if (categoryFilter !== "all" && app.appType !== categoryFilter) return false;
if (categoryFilter === "iframe" && mountFilter && app.mountLocation !== mountFilter) {
return false;
}
return true;
}),
[apps, categoryFilter, mountFilter],
);
return (
<div className="light-app-management-page h-full overflow-y-auto bg-neutral-50 dark:bg-[#0C1836] dark:text-[#C5CFE0]">
<div className="mx-auto flex min-h-full w-full max-w-7xl flex-col space-y-7 px-8 py-8">
<div className="flex flex-col justify-between gap-6 md:flex-row md:items-center">
<div className="space-y-1.5">
<h1 className="lam-title flex items-center text-xl font-black tracking-tight text-neutral-900">
应用管理
</h1>
<p className="lam-desc max-w-2xl text-xs leading-relaxed text-neutral-500">
注册轻应用。
<code className="mx-1 rounded-[5px] bg-neutral-100 px-1.5 py-0.5 font-mono text-[11px] text-neutral-600">
window.open
</code>
类型在「应用列表」以卡片展示;
<code className="mx-1 rounded-[5px] bg-neutral-100 px-1.5 py-0.5 font-mono text-[11px] text-neutral-600">
iframe
</code>
类型挂载到所选菜单下并注入侧栏。
</p>
</div>
<button
onClick={openCreate}
className="lam-primary-btn flex cursor-pointer items-center justify-center space-x-1.5 rounded-[5px] bg-[#A8071A] px-5 py-2.5 text-xs font-semibold text-white shadow-md shadow-rose-950/10 transition-all hover:bg-[#BC0D1E] hover:shadow-lg hover:shadow-rose-900/15 active:bg-[#820014]"
>
<Plus className="h-4 w-4" />
<span>注册应用</span>
</button>
</div>
<div className="lam-toolbar flex flex-col items-center gap-3.5 rounded-[5px] bg-white p-3 shadow-[0_1px_3px_rgba(0,0,0,0.01)] md:flex-row md:flex-wrap">
<div className="relative w-full flex-1">
<Search className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-neutral-400" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜索应用名称 / 描述..."
className="lam-search-input w-full rounded-[5px] border border-neutral-100 bg-neutral-50/60 py-2 pl-10 pr-10 text-xs text-neutral-800 placeholder-neutral-400 transition-all hover:bg-white focus:border-[#F5222D]/40 focus:outline-none focus:ring-4 focus:ring-[#F5222D]/10"
/>
{search && (
<button
onClick={() => setSearch("")}
className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer text-neutral-400 hover:text-neutral-700"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
<div className="lam-cat-toggle flex shrink-0 items-center space-x-0.5 self-end rounded-[5px] border border-neutral-200/50 bg-neutral-100/60 p-0.5 md:self-auto">
{(
[
{ value: "all", label: "全部" },
{ value: "window_open", label: "新页跳转" },
{ value: "iframe", label: "iframe 嵌套" },
] as const
).map((c) => (
<button
key={c.value}
onClick={() => selectCategory(c.value)}
className={`lam-cat-toggle-btn cursor-pointer rounded-[5px] px-4 py-1.5 text-xs font-bold transition-all ${
categoryFilter === c.value
? "is-active bg-white text-[#F5222D] shadow-sm"
: "text-neutral-500 hover:text-neutral-700"
}`}
>
{c.label}
</button>
))}
</div>
{categoryFilter === "iframe" && (
<div className="lam-mount-filter w-full shrink-0 animate-in fade-in self-end md:w-[180px] md:self-auto">
<Select
className="w-full"
value={mountFilter || undefined}
placeholder="按挂载菜单筛选"
clearable
options={MOUNT_OPTIONS}
onChange={(v) => setMountFilter((v as string) ?? "")}
/>
</div>
)}
<div className="lam-view-toggle flex shrink-0 items-center space-x-0.5 self-end rounded-[5px] border border-neutral-200/50 bg-neutral-100/60 p-0.5 md:self-auto">
<button
onClick={() => setViewMode("table")}
className={`lam-view-toggle-btn cursor-pointer rounded-[5px] px-4 py-1.5 text-xs font-bold transition-all ${
viewMode === "table"
? "is-active bg-white text-[#F5222D] shadow-sm"
: "text-neutral-500 hover:text-neutral-700"
}`}
>
表格视图
</button>
<button
onClick={() => setViewMode("cards")}
className={`lam-view-toggle-btn cursor-pointer rounded-[5px] px-4 py-1.5 text-xs font-bold transition-all ${
viewMode === "cards"
? "is-active bg-white text-[#F5222D] shadow-sm"
: "text-neutral-500 hover:text-neutral-700"
}`}
>
卡片视图
</button>
</div>
</div>
<div className="space-y-4">
<div className="lam-count text-xs font-medium text-neutral-500">
搜寻到 <strong className="font-bold text-[#F5222D]">{tableApps.length}</strong>{" "}
个注册完成的子模块轻应用
</div>
{viewMode === "table" ? (
<div className="lam-table-wrap overflow-visible rounded-[5px] bg-white shadow-[0_1px_3px_0_rgba(0,0,0,0.01)]">
<table className="lam-table w-full table-fixed border-collapse text-left">
<thead>
<tr className="select-none border-b border-neutral-100/80 bg-neutral-50/40 text-[12.5px] font-semibold text-neutral-500">
<th className="w-[15%] px-6 py-4">名称</th>
<th className="w-[14%] px-4 py-4">类型</th>
<th className="w-[27%] px-4 py-4">地址</th>
<th className="w-[14%] px-4 py-4">挂载</th>
<th className="w-[9%] px-4 py-4">参数</th>
<th className="w-[9%] px-4 py-4">启用</th>
<th className="w-[12%] px-4 py-4 pr-6 text-center">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{tableApps.map((app) => (
<tr key={app.appId} className="group align-middle transition-colors hover:bg-neutral-50/40">
<td className="px-6 py-[18px]">
<span className="lam-row-name text-[14px] font-bold text-neutral-800 transition-colors group-hover:text-[#F5222D]">
{app.name}
</span>
</td>
<td className="px-4 py-[18px]">
{app.appType === "window_open" ? (
<span className="lam-tag-window inline-flex select-none items-center space-x-1.5 rounded-[5px] border border-rose-100 bg-rose-50 px-3 py-1 text-[12px] font-semibold text-rose-800">
<ExternalLink className="h-3.5 w-3.5" />
<span>新页跳转</span>
</span>
) : (
<span className="lam-tag-iframe inline-flex select-none items-center space-x-1.5 rounded-[5px] border border-sky-100 bg-sky-50 px-3 py-1 text-[12px] font-semibold text-sky-800">
<Monitor className="h-3.5 w-3.5" />
<span>iframe 嵌套</span>
</span>
)}
</td>
<td className="overflow-hidden px-4 py-[18px]">
<div className="lam-url flex max-w-full items-center space-x-1.5 font-mono text-[12px] text-neutral-600 transition-colors group-hover:text-neutral-800">
<a
href={app.url}
target="_blank"
rel="noopener noreferrer"
className="truncate hover:underline"
title={app.url}
>
{app.url}
</a>
<button
onClick={() => handleCopyUrl(app.url)}
className="lam-icon-btn shrink-0 rounded-[5px] p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600"
title="复制网址"
>
<Copy className="h-3 w-3" />
</button>
</div>
</td>
<td className="px-4 py-[18px]">
{app.appType === "iframe" && app.mountLocation ? (
<span className="lam-mount-tag inline-flex max-w-full items-center space-x-1.5 rounded-[5px] bg-sky-50 px-2 py-0.5 text-[11px] font-medium text-sky-700">
<span className="lam-mount-dot h-1.5 w-1.5 shrink-0 rounded-full bg-sky-500" />
<span className="truncate">{mountLabel(app.mountLocation)}</span>
</span>
) : (
<span className="text-[12px] text-neutral-300">—</span>
)}
</td>
<td className="relative px-4 py-[18px]">
<div className="flex select-none items-center">
{app.routeParams.length > 0 ? (
<button
onClick={() =>
setActiveParamPopover(
activeParamPopover === app.appId ? null : app.appId
)
}
className="lam-param-btn inline-flex cursor-pointer items-center space-x-1.5 rounded-[5px] border border-neutral-200/60 bg-neutral-50/60 px-2.5 py-1 text-xs font-medium text-neutral-500 transition-colors hover:bg-neutral-100"
>
<span>{app.routeParams.length}个</span>
{activeParamPopover === app.appId ? (
<ChevronUp className="h-3.5 w-3.5 text-neutral-400" />
) : (
<ChevronDown className="h-3.5 w-3.5 text-neutral-400" />
)}
</button>
) : (
<span className="text-xs text-neutral-400">0个</span>
)}
{activeParamPopover === app.appId && app.routeParams.length > 0 && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setActiveParamPopover(null)}
/>
<div className="lam-param-popover absolute left-4 top-12 z-50 w-[280px] animate-in fade-in rounded-[5px] border border-neutral-100 bg-white p-3.5 text-[12px] shadow-[0_8px_32px_rgba(34,34,34,0.16)]">
<div className="lam-param-popover-head mb-2.5 flex items-center justify-between border-b border-neutral-100 pb-2">
<span className="text-[11px] font-bold uppercase tracking-wider text-neutral-400">
注入的路由参数
</span>
<button
onClick={() => setActiveParamPopover(null)}
className="lam-param-popover-close text-[10px] font-semibold text-rose-700 hover:underline"
>
关闭
</button>
</div>
<div className="max-h-40 space-y-1.5 overflow-y-auto">
{app.routeParams.map((p, idx) => (
<div
key={`${app.appId}-${idx}`}
className="lam-param-item rounded-[5px] border border-neutral-100 bg-neutral-50/60 p-2 text-[11px] leading-relaxed"
>
<div className="flex items-center justify-between gap-1 font-mono">
<span className="lam-param-key truncate font-bold text-rose-700">{p.key}</span>
<span className="lam-param-val truncate text-[10px] text-neutral-500">
{getParamSourceLabel(p)}
</span>
</div>
</div>
))}
</div>
</div>
</>
)}
</div>
</td>
<td className="px-4 py-[18px]">
<div className="flex select-none items-center space-x-2">
{app.enabled ? (
<>
<span className="relative flex h-2.5 w-2.5 shrink-0">
<span className="lam-status-on-ping absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
<span className="lam-status-on-dot relative inline-flex h-2.5 w-2.5 rounded-full bg-emerald-500" />
</span>
<span className="lam-status-on text-[12.5px] font-bold text-emerald-700">已启用</span>
</>
) : (
<>
<span className="lam-status-off-dot h-2.5 w-2.5 shrink-0 rounded-full bg-neutral-300" />
<span className="lam-status-off text-[12.5px] font-medium text-neutral-400">已停用</span>
</>
)}
</div>
</td>
<td className="px-4 py-[18px] pr-6 text-center">
<div className="flex items-center justify-center space-x-1">
<button
onClick={() => openEdit(app)}
className="lam-icon-btn cursor-pointer rounded-[5px] p-1.5 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-emerald-600"
title="编辑配置"
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => handleDelete(app)}
className="lam-icon-btn lam-icon-btn-danger cursor-pointer rounded-[5px] p-1.5 text-neutral-400 transition-colors hover:bg-rose-50 hover:text-rose-600"
title="删除配置"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))}
{tableApps.length === 0 && (
<tr>
<td colSpan={7} className="py-12 text-center">
<div className="flex flex-col items-center justify-center space-y-3">
<div className="lam-empty-icon flex h-12 w-12 items-center justify-center rounded-full bg-neutral-100 text-neutral-300">
<Info className="h-6 w-6" />
</div>
<div className="space-y-1">
<p className="lam-empty-title text-[14px] font-semibold text-neutral-400">
{isLoading ? "加载中..." : "暂无匹配的组件应用"}
</p>
{!isLoading && (
<p className="lam-empty-sub text-[12px] text-neutral-300">
{search ? "请确认搜索关键词输入是否正确" : "点击右上角“注册应用”添加第一款轻应用"}
</p>
)}
</div>
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
) : tableApps.length === 0 ? (
<div className="lam-table-wrap rounded-[5px] bg-white py-20 text-center">
<div className="lam-empty-icon mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-neutral-100 text-neutral-300">
<Info className="h-6 w-6" />
</div>
<p className="lam-empty-title text-[14px] font-semibold text-neutral-400">
{isLoading ? "加载中..." : "暂无匹配的组件应用"}
</p>
{!isLoading && (
<p className="lam-empty-sub mt-1 text-[12px] text-neutral-300">
{search ? "请确认搜索关键词输入是否正确" : "点击右上角“注册应用”添加第一款轻应用"}
</p>
)}
</div>
) : (
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
{tableApps.map((app) => (
<div
key={app.appId}
className="lam-card group flex flex-col justify-between overflow-hidden rounded-[5px] bg-white shadow-[0_1px_3px_0_rgba(0,0,0,0.01)] transition-all hover:shadow-md"
>
<div
className={`h-1.5 w-full bg-gradient-to-r ${
app.appType === "window_open"
? "from-rose-600 to-amber-500"
: "from-indigo-600 to-sky-500"
}`}
/>
<div className="flex-1 space-y-3.5 p-5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1 space-y-1.5">
{app.appType === "window_open" ? (
<span className="lam-tag-window inline-flex items-center space-x-1 rounded-[5px] border border-rose-100 bg-rose-50 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-rose-700">
<ExternalLink className="h-3 w-3" />
<span>新页跳转</span>
</span>
) : (
<span className="lam-tag-iframe inline-flex items-center space-x-1 rounded-[5px] border border-sky-100 bg-sky-50 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-sky-700">
<Monitor className="h-3 w-3" />
<span>iframe 嵌套</span>
</span>
)}
<h4 className="lam-card-title line-clamp-1 text-sm font-bold leading-snug text-neutral-800 transition-colors group-hover:text-[#F5222D]">
{app.name}
</h4>
</div>
{app.enabled ? (
<span className="lam-status-on inline-flex shrink-0 items-center space-x-1 text-[11px] font-bold text-emerald-700">
<span className="lam-status-on-dot h-2 w-2 rounded-full bg-emerald-500" />
<span>已启用</span>
</span>
) : (
<span className="lam-status-off inline-flex shrink-0 items-center space-x-1 text-[11px] font-medium text-neutral-400">
<span className="lam-status-off-dot h-2 w-2 rounded-full bg-neutral-300" />
<span>已停用</span>
</span>
)}
</div>
<p className="lam-card-desc line-clamp-2 min-h-[2.25rem] text-xs leading-relaxed text-neutral-500">
{app.description || "暂无应用详细描述说明。"}
</p>
<div className="lam-card-url-box flex items-center space-x-1.5 rounded-[5px] bg-neutral-50/60 p-2 font-mono text-[11px] text-neutral-600">
<Globe className="h-3.5 w-3.5 shrink-0 text-neutral-400" />
<span className="flex-1 truncate" title={app.url}>
{app.url}
</span>
<button
onClick={() => handleCopyUrl(app.url)}
className="shrink-0 rounded-[5px] p-1 text-neutral-400 transition-colors hover:bg-neutral-200 hover:text-neutral-600"
title="复制网址"
>
<Copy className="h-3.5 w-3.5" />
</button>
</div>
{app.routeParams.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{app.routeParams.map((p, idx) => (
<span
key={`${app.appId}-card-${idx}`}
className="lam-card-param rounded-[5px] border border-neutral-200/40 bg-neutral-100 px-1.5 py-0.5 font-mono text-[10px] text-neutral-500"
>
{p.key}
</span>
))}
</div>
)}
{app.appType === "iframe" && app.mountLocation && (
<div className="lam-card-mount inline-flex items-center gap-1.5 rounded-[5px] bg-sky-50/60 p-2 text-[11px] font-medium leading-none text-sky-700">
<span>挂载分类:</span>
<code className="font-semibold">{mountLabel(app.mountLocation)}</code>
</div>
)}
</div>
<div className="lam-card-footer flex items-center justify-end space-x-1 border-t border-neutral-100/60 bg-neutral-50/30 px-5 py-3">
<button
onClick={() => openEdit(app)}
className="lam-icon-btn cursor-pointer rounded-[5px] p-1.5 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-emerald-600"
title="编辑配置"
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => handleDelete(app)}
className="lam-icon-btn lam-icon-btn-danger cursor-pointer rounded-[5px] p-1.5 text-neutral-400 transition-colors hover:bg-rose-50 hover:text-rose-600"
title="删除配置"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
{viewMode === "table" && (
<div className="lam-tip-box select-none rounded-[5px] bg-amber-50/40 p-5 shadow-[0_1px_2px_rgba(0,0,0,0.005)]">
<div className="lam-tip-head flex items-center space-x-2 border-b border-amber-100/50 pb-3 text-amber-800">
<AlertCircle className="h-4 w-4 shrink-0 text-amber-600" />
<h4 className="text-[13px] font-bold tracking-tight">智能多维挂载规则:</h4>
</div>
<ul className="lam-tip-list mt-3.5 space-y-3 pl-1 text-[12px] leading-relaxed text-neutral-600">
<li className="flex items-start space-x-2">
<span className="lam-tip-bullet mt-1 shrink-0 text-amber-600">•</span>
<span>
当您注册 <strong className="font-bold text-neutral-800">iframe 嵌套</strong>{" "}
应用时,您需要在菜单一栏指定分类(如“资源管理”),组件将直接作为新导航菜单项加入该分类。
</span>
</li>
<li className="flex items-start space-x-2">
<span className="lam-tip-bullet mt-1 shrink-0 text-amber-600">•</span>
<span>
当路由挂载成功,点击左侧注入的子栏名称可在主区域调起{" "}
<strong className="font-bold text-neutral-800">真实 Iframe 预览器</strong>
,并自动将参数以加密/标准的QueryString传递过去。
</span>
</li>
</ul>
</div>
)}
</div>
{dialogOpen && (
<div className="fixed inset-0 z-[100] flex items-center justify-center overflow-y-auto select-none">
<div
className="lam-dialog-overlay fixed inset-0 bg-[#3D3D3D]/40 backdrop-blur-[1px]"
onClick={() => setDialogOpen(false)}
/>
<div className="lam-dialog relative z-[110] m-4 flex max-h-[90vh] w-full max-w-[900px] flex-col rounded-[5px] border border-[#E8E8E8] bg-white p-6 shadow-[0_8px_32px_rgba(34,34,34,0.16)]">
<div className="lam-dialog-header flex flex-shrink-0 items-center justify-between border-b border-[#E8E8E8] pb-4">
<div className="flex items-center space-x-2.5">
<span className="lam-dialog-icon rounded-[5px] bg-rose-50 p-1.5 text-[#A8071A]">
<Layers className="h-5 w-5" />
</span>
<h2 className="lam-dialog-title text-[17px] font-bold text-[#222222]">
{editing ? "修改轻应用" : "注册应用"}
</h2>
</div>
<button
onClick={() => setDialogOpen(false)}
className="lam-dialog-close cursor-pointer rounded-[5px] p-1 text-[#A6A6A6] transition-colors hover:bg-[#FAFAFA] hover:text-[#F5222D]"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="flex-1 space-y-4 overflow-y-auto py-4 pr-1 text-[#222222]">
<div className="space-y-1.5">
<label className="lam-form-label flex items-center space-x-1.5 text-[14px] font-medium">
<span className="lam-required font-bold text-[#F5222D]">*</span>
<Layers className="h-4 w-4 text-[#A6A6A6]" />
<span>应用类型</span>
</label>
<Select
className="w-full"
value={form.appType}
options={APP_TYPE_OPTIONS}
popupProps={DIALOG_SELECT_POPUP}
onChange={(v) => patchForm({ appType: v as LightAppType })}
/>
<p className="lam-form-hint text-[11.5px] leading-relaxed text-[#A6A6A6]">
{form.appType === "window_open"
? "在新标签页或卡片式路由容器打开。多用于外部高度复杂的系统跳转。"
: "通过标准 iframe 嵌入页面,可直接挂载到左侧导航树相应分类内。"}
</p>
</div>
<div className="space-y-1.5">
<label className="lam-form-label flex items-center space-x-1.5 text-[14px] font-medium">
<span className="lam-required font-bold text-[#F5222D]">*</span>
<Tag className="h-4 w-4 text-[#A6A6A6]" />
<span>应用名称</span>
</label>
<Input
value={form.name}
onChange={(e) => patchForm({ name: e.target.value })}
placeholder="如: 舆情看板"
className="lam-input h-9 rounded-[5px] border-[#DBDBDB] text-[14px]"
/>
</div>
<div className="space-y-1.5">
<label className="lam-form-label flex items-center space-x-1.5 text-[14px] font-medium">
<span className="lam-required font-bold text-[#F5222D]">*</span>
<Link2 className="h-4 w-4 text-[#A6A6A6]" />
<span>应用地址</span>
</label>
<Input
value={form.url}
onChange={(e) => patchForm({ url: e.target.value })}
placeholder="https://example.com/page 或 http://47.88.25.99:3000/"
className="lam-input h-9 rounded-[5px] border-[#DBDBDB] font-mono text-[14px]"
/>
</div>
{form.appType === "iframe" && (
<div className="animate-in fade-in space-y-1.5">
<label className="lam-form-label flex items-center space-x-1.5 text-[14px] font-medium">
<span className="lam-required font-bold text-[#F5222D]">*</span>
<FolderTree className="h-4 w-4 text-[#A6A6A6]" />
<span>挂载菜单</span>
</label>
<Select
className="w-full"
value={form.mountLocation || undefined}
placeholder="请选择挂载菜单"
options={MOUNT_OPTIONS}
popupProps={DIALOG_SELECT_POPUP}
onChange={(v) => patchForm({ mountLocation: (v as string) ?? "" })}
/>
</div>
)}
<div className="space-y-1.5">
<label className="lam-form-label flex items-center space-x-1.5 text-[14px] font-medium">
<span className="w-2 shrink-0" />
<AlignLeft className="h-4 w-4 text-[#A6A6A6]" />
<span>应用描述 (可选)</span>
</label>
<Textarea
value={form.description}
onChange={(e) => patchForm({ description: e.target.value })}
placeholder="简要说明该应用的用途"
rows={2}
className="lam-input resize-none rounded-[5px] border-[#DBDBDB] text-[14px]"
/>
</div>
<div className="space-y-2.5 pt-1.5">
<div className="flex items-center justify-between">
<span className="lam-form-label flex items-center space-x-1.5 text-[14px] font-medium">
<Sliders className="h-4 w-4 text-[#A6A6A6]" />
<span>路由参数</span>
<span
className="inline-flex"
title="参数会在加载此应用时作为 query 参数拼接在 URL 的尾部"
>
<HelpCircle className="h-3.5 w-3.5 text-[#A6A6A6]" />
</span>
</span>
<button
type="button"
onClick={addParam}
className="lam-add-param-btn flex h-7 cursor-pointer items-center space-x-1 rounded-[5px] 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>
</button>
</div>
{form.routeParams.length > 0 ? (
<div className="lam-param-panel max-h-[220px] space-y-2 overflow-y-auto rounded-[5px] border border-[#E8E8E8] bg-[#FAFAFA] p-1">
{form.routeParams.map((p, idx) => {
const themeMap =
p.paramType === "theme" ? parseThemeMap(p.themeValue) : {};
return (
<div
key={idx}
className="lam-param-row relative rounded-[5px] border border-[#E8E8E8] bg-white p-2"
>
<div className="flex items-center space-x-2">
<div className="flex-1">
<input
type="text"
value={p.key}
onChange={(e) => patchParam(idx, { key: e.target.value })}
placeholder="参数名 key"
className="lam-input h-8 w-full rounded-[5px] border border-[#DBDBDB] bg-white px-2 font-mono text-[12px] focus:border-[#F5222D] focus:outline-none focus:ring-px focus:ring-[#F5222D]"
/>
</div>
<div className="w-[110px]">
<Select
className="w-full"
size="small"
value={p.paramType}
options={PARAM_TYPE_OPTIONS}
popupProps={DIALOG_SELECT_POPUP}
onChange={(v) =>
patchParam(idx, {
paramType: v as "login" | "theme",
source: v === "login" ? (p.source || "accessToken") : undefined,
})
}
/>
</div>
{p.paramType === "login" && (
<div className="flex flex-[1.2] items-center gap-2 text-[11px]">
<Select
className="w-full"
size="small"
value={p.source || "accessToken"}
options={LOGIN_SOURCE_OPTIONS}
popupProps={DIALOG_SELECT_POPUP}
onChange={(v) => patchParam(idx, { source: v as string })}
/>
{p.source === "other" && (
<input
type="text"
value={p.customValue || ""}
onChange={(e) =>
patchParam(idx, { customValue: e.target.value })
}
placeholder="自定义值"
className="lam-input h-8 w-[120px] rounded-[5px] border border-[#DBDBDB] px-2 text-[12px] focus:border-[#F5222D] focus:outline-none"
/>
)}
</div>
)}
<button
type="button"
onClick={() => removeParam(idx)}
className="lam-icon-btn lam-icon-btn-danger cursor-pointer rounded-[5px] p-1 text-[#A6A6A6] transition-colors hover:bg-[#FEEDED] hover:text-[#F5222D]"
>
<X className="h-4 w-4" />
</button>
</div>
{/* 主题参数:逐主题色配置传给目标应用的值(左=系统主题色,右=目标应用值,可改) */}
{p.paramType === "theme" && (
<div className="lam-param-theme-box mt-2 space-y-1.5 rounded-[5px] bg-[#FAFAFA] p-2">
<p className="lam-form-hint text-[11px] leading-relaxed text-[#A6A6A6]">
各系统主题色跳转/嵌套时传给该应用的值(默认同名,可按对方命名修改)
</p>
{APP_THEME_COLORS.map((c) => (
<div key={c.key} className="flex items-center gap-2">
<span className="flex w-[72px] shrink-0 items-center gap-1.5 text-[12px] text-[#575757]">
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: c.dot }}
/>
{c.label}
</span>
<span className="shrink-0 font-mono text-[11px] text-[#A6A6A6]">
{c.key} =
</span>
<input
type="text"
value={themeMap[c.key] ?? c.key}
onChange={(e) =>
patchParam(idx, {
themeValue: stringifyThemeMap({
...themeMap,
[c.key]: e.target.value,
}),
})
}
placeholder={c.key}
className="lam-input h-7 flex-1 rounded-[5px] border border-[#DBDBDB] bg-white px-2 font-mono text-[12px] focus:border-[#F5222D] focus:outline-none focus:ring-px focus:ring-[#F5222D]"
/>
</div>
))}
</div>
)}
</div>
);
})}
</div>
) : (
<div className="lam-param-empty rounded-[5px] border border-dashed border-[#E8E8E8] bg-[#FAFAFA] p-4 text-center text-[12px] text-[#A6A6A6]">
暂无注入的路由参数,加载地址时不传递多余拼接。
</div>
)}
</div>
<div className="lam-dialog-footer flex items-center justify-between border-t border-[#E8E8E8] py-2.5">
<span className="lam-form-label text-[14px] font-medium text-[#222222]">立即启用该应用</span>
<button
type="button"
onClick={() => patchForm({ enabled: !form.enabled })}
className={`relative h-[22px] w-10 cursor-pointer rounded-full p-0.5 transition-colors duration-200 ${
form.enabled ? "lam-switch-on bg-[#F5222D]" : "lam-switch-off bg-[#DBDBDB]"
}`}
>
<div
className={`h-[18px] w-[18px] rounded-full bg-white shadow-sm transition-transform duration-200 ${
form.enabled ? "translate-x-[18px]" : "translate-x-0"
}`}
/>
</button>
</div>
</div>
<div className="lam-dialog-footer flex flex-shrink-0 items-center justify-end space-x-3 border-t border-[#E8E8E8] pt-4">
<Button
variant="outline"
disabled={saving}
onClick={() => setDialogOpen(false)}
className="lam-dialog-cancel h-9 rounded-[5px] border-[#DBDBDB] px-[18px] text-[14px] font-semibold"
>
取消
</Button>
<button
type="button"
disabled={saving}
onClick={handleSave}
className="lam-dialog-save h-9 cursor-pointer rounded-[5px] bg-[#F5222D] px-[18px] text-[14px] font-bold text-white shadow-[0_2px_8px_rgba(34,34,34,0.08)] transition-colors hover:bg-[#CF1322] active:bg-[#A8071A] disabled:cursor-not-allowed disabled:opacity-70"
>
{saving ? "保存中..." : editing ? "保存修改" : "立即注册"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
function cleanParams(params: LightAppRouteParam[]): LightAppRouteParam[] {
return params
.filter((p) => p.key.trim())
.map((p) => {
const base: LightAppRouteParam = { key: p.key.trim(), paramType: p.paramType };
if (p.paramType === "login") {
base.source = p.source || "accessToken";
if (base.source === "other") {
base.customValue = (p.customValue ?? "").trim();
}
} else {
// theme:规范化为完整映射串(red=..;blue=..;dark-blue=..),运行时按当前主题取值
base.themeValue = stringifyThemeMap(parseThemeMap(p.themeValue));
}
return base;
});
}