应用管理页样式对齐参考设计,修复参数明细弹层被裁切问题。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
dd329e7a4f
commit
369efc784b
1 file changed
+477
-343
@@ -1,16 +1,8 @@
|
||||
/**
|
||||
* 应用管理页 —— 注册 / 编辑 / 删除轻应用(全局共享,写操作仅 admin)。
|
||||
*
|
||||
* 两种类型:
|
||||
* - window_open:填地址 + 路由参数,列表页以卡片展示、点击新开页跳转。
|
||||
* - iframe:填地址 + 路由参数 + 选择挂载菜单,注册后动态注入侧边栏对应父菜单下。
|
||||
*
|
||||
* 路由参数构造器:参数名(key) + 类型(登录/主题)。登录参数右侧选 accessToken/yUserId/
|
||||
* userName/其他(自填值),运行时从 localStorage.userInfo 解析;主题参数填 dark-blue/red/blue。
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
ExternalLink,
|
||||
HelpCircle,
|
||||
Info,
|
||||
Layers,
|
||||
Pencil,
|
||||
Plus,
|
||||
@@ -20,25 +12,9 @@ import {
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import {
|
||||
useLightAppActions,
|
||||
useLightApps,
|
||||
@@ -47,11 +23,9 @@ import {
|
||||
type LightAppType,
|
||||
} from "@/strategy-components/api/light-apps";
|
||||
import {
|
||||
buildLightAppUrl,
|
||||
LOGIN_SOURCE_OPTIONS,
|
||||
MOUNT_OPTIONS,
|
||||
mountLabel,
|
||||
THEME_EXAMPLE_OPTIONS,
|
||||
} from "@/strategy-components/lib/light-app";
|
||||
|
||||
interface FormState {
|
||||
@@ -100,10 +74,19 @@ function formFromApp(app: LightApp): FormState {
|
||||
};
|
||||
}
|
||||
|
||||
export default function LightAppManagementPage() {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
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();
|
||||
@@ -111,6 +94,7 @@ export default function LightAppManagementPage() {
|
||||
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 saving = actions.create.isPending || actions.update.isPending;
|
||||
|
||||
@@ -126,7 +110,8 @@ export default function LightAppManagementPage() {
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const patchForm = (patch: Partial<FormState>) => setForm((f) => ({ ...f, ...patch }));
|
||||
const patchForm = (patch: Partial<FormState>) =>
|
||||
setForm((f) => ({ ...f, ...patch }));
|
||||
|
||||
const patchParam = (idx: number, patch: Partial<LightAppRouteParam>) =>
|
||||
setForm((f) => ({
|
||||
@@ -135,26 +120,27 @@ export default function LightAppManagementPage() {
|
||||
}));
|
||||
|
||||
const addParam = () => patchForm({ routeParams: [...form.routeParams, newParam()] });
|
||||
|
||||
const removeParam = (idx: number) =>
|
||||
patchForm({ routeParams: form.routeParams.filter((_, i) => i !== idx) });
|
||||
|
||||
const previewUrl = useMemo(() => {
|
||||
if (!form.url) return "";
|
||||
try {
|
||||
return buildLightAppUrl({ url: form.url, routeParams: cleanParams(form.routeParams) });
|
||||
} catch {
|
||||
return form.url;
|
||||
}
|
||||
}, [form.url, form.routeParams]);
|
||||
|
||||
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 类型请选择挂载菜单");
|
||||
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.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 = {
|
||||
@@ -182,7 +168,7 @@ export default function LightAppManagementPage() {
|
||||
};
|
||||
|
||||
const handleDelete = async (app: LightApp) => {
|
||||
if (!window.confirm(`确定删除应用「${app.name}」吗?`)) return;
|
||||
if (!window.confirm(`是否确定要删除「${app.name}」?该操作不可逆。`)) return;
|
||||
try {
|
||||
await actions.remove.mutateAsync(app.appId);
|
||||
toast.success(`已删除「${app.name}」`);
|
||||
@@ -191,235 +177,487 @@ export default function LightAppManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// ── theme classes(与配置管理页一致) ──────────────────────────────────
|
||||
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
|
||||
? "border border-[rgba(67,89,124,0.46)] bg-[rgba(12,29,54,0.94)] shadow-[0_16px_36px_rgba(3,10,22,0.2)]"
|
||||
: "border border-[#E8E8E8] bg-white shadow-[0_14px_30px_rgba(245,34,45,0.06)]";
|
||||
const headRowClass = isDark ? "text-slate-400" : "text-[#8C8C8C]";
|
||||
const rowBorder = isDark ? "border-[rgba(67,89,124,0.32)]" : "border-[#F0F0F0]";
|
||||
const mutedText = isDark ? "text-slate-500" : "text-[#A6A6A6]";
|
||||
|
||||
const typeBadge = (t: LightAppType) =>
|
||||
t === "iframe"
|
||||
? isDark
|
||||
? "bg-[rgba(168,85,247,0.16)] text-[#D6B4FF]"
|
||||
: "bg-[#F9F0FF] text-[#722ED1]"
|
||||
: isDark
|
||||
? "bg-[rgba(77,141,255,0.16)] text-[#9CCBFF]"
|
||||
: "bg-[#FFF1F0] text-[#CF1322]";
|
||||
const tableApps = useMemo(() => apps, [apps]);
|
||||
|
||||
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="flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="h-full overflow-y-auto bg-[#FAFAFA]">
|
||||
<div className="mx-auto flex min-h-full max-w-[1280px] flex-col space-y-6 px-6 py-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<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"}`}>
|
||||
注册轻应用。window.open 类型在「应用列表」以卡片展示;iframe 类型挂载到所选菜单下并注入侧边栏。
|
||||
<h1 className="text-[20px] font-bold tracking-tight text-[#222222]">应用管理</h1>
|
||||
<p className="max-w-4xl text-[13px] leading-relaxed text-[#575757]">
|
||||
注册轻应用。window.open 类型在「应用列表」以卡片展示;iframe 类型挂载到所选菜单下并注入侧栏。
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={openCreate} className="gap-1.5">
|
||||
<Plus className="h-4 w-4" />
|
||||
注册应用
|
||||
</Button>
|
||||
|
||||
<button
|
||||
onClick={openCreate}
|
||||
className="flex h-9 cursor-pointer items-center space-x-1.5 rounded-[2px] bg-[#A8071A] px-4 text-[14px] font-bold text-white shadow-[0_2px_8px_rgba(34,34,34,0.08)] transition-all hover:bg-[#BC0D1E] active:bg-[#820014]"
|
||||
>
|
||||
<span>+ 注册应用</span>
|
||||
</button>
|
||||
</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
|
||||
<Search className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-[#C1C1C1]" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="搜索应用名称 / 描述"
|
||||
className="pl-9"
|
||||
className="h-9 w-full rounded-[2px] border border-[#E8E8E8] bg-white pl-10 pr-4 text-[14px] text-[#222222] placeholder-[#C1C1C1] shadow-sm transition-all focus:border-[#F5222D] focus:outline-none focus:ring-1 focus:ring-[#F5222D]"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch("")}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer text-[12px] text-[#A6A6A6] hover:text-[#F5222D]"
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`overflow-hidden rounded-3xl ${cardClass}`}>
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<div className="overflow-visible rounded-[4px] border border-[#E8E8E8] bg-white shadow-[0_2px_8px_rgba(34,34,34,0.08)]">
|
||||
<table className="w-full table-fixed border-collapse text-left">
|
||||
<thead>
|
||||
<tr className={`text-left text-xs uppercase tracking-widest ${headRowClass}`}>
|
||||
<th className="px-6 py-4 font-semibold">名称</th>
|
||||
<th className="px-6 py-4 font-semibold">类型</th>
|
||||
<th className="px-6 py-4 font-semibold">地址 / 挂载</th>
|
||||
<th className="px-6 py-4 font-semibold">参数</th>
|
||||
<th className="px-6 py-4 font-semibold">启用</th>
|
||||
<th className="px-6 py-4 text-right font-semibold">操作</th>
|
||||
<tr className="border-b border-[#E8E8E8] bg-[#FAFAFA] text-[12px] font-bold uppercase tracking-wider text-[#A6A6A6]">
|
||||
<th className="w-[18%] px-6 py-3.5">名称</th>
|
||||
<th className="w-[12%] px-4 py-3.5">类型</th>
|
||||
<th className="w-[34%] px-4 py-3.5">地址 / 挂载</th>
|
||||
<th className="w-[12%] px-4 py-3.5">参数</th>
|
||||
<th className="w-[12%] px-4 py-3.5">启用</th>
|
||||
<th className="w-[12%] px-4 py-3.5 pr-6 text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{apps.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className={`px-6 py-12 text-center ${mutedText}`}>
|
||||
{isLoading ? "加载中…" : "暂无应用,点击右上角「注册应用」开始"}
|
||||
<tbody className="divide-y divide-[#E8E8E8]">
|
||||
{tableApps.map((app) => (
|
||||
<tr key={app.appId} className="group align-top transition-colors hover:bg-[#FEFAFA]">
|
||||
<td className="px-6 py-[18px]">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<span className="text-[14px] font-bold text-[#222222] transition-colors group-hover:text-[#F5222D]">
|
||||
{app.name}
|
||||
</span>
|
||||
{app.description ? (
|
||||
<span className="line-clamp-2 text-[12px] text-[#575757]">{app.description}</span>
|
||||
) : (
|
||||
<span className="text-[11px] italic text-[#C1C1C1]">无描述说明</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-4 py-[18px]">
|
||||
{app.appType === "window_open" ? (
|
||||
<span className="inline-flex select-none items-center space-x-1 rounded-[2px] border border-[#FDDADC] bg-[#FEEDED] px-2 py-0.5 text-[11px] font-medium text-[#F5222D] shadow-sm">
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
<span>新页跳转</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex select-none items-center space-x-1 rounded-[2px] border border-[#D4E9FF] bg-[#EAF4FF] px-2 py-0.5 text-[11px] font-medium text-[#0079FE] shadow-sm">
|
||||
<Layers className="h-3 w-3" />
|
||||
<span>iframe 嵌套</span>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="overflow-hidden px-4 py-[18px]">
|
||||
<div className="flex max-w-full flex-col space-y-1">
|
||||
<a
|
||||
href={app.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center space-x-1.5 truncate font-mono text-[13px] text-[#222222] hover:text-[#0079FE] hover:underline"
|
||||
>
|
||||
<span className="truncate">{app.url}</span>
|
||||
</a>
|
||||
{app.appType === "iframe" && app.mountLocation && (
|
||||
<div className="flex items-center space-x-1 text-[11px] text-[#A6A6A6]">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-[#0079FE]" />
|
||||
<span>挂载: {mountLabel(app.mountLocation)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="relative px-4 py-[18px]">
|
||||
<div className="flex items-center select-none">
|
||||
{app.routeParams.length > 0 ? (
|
||||
<button
|
||||
onClick={() =>
|
||||
setActiveParamPopover(
|
||||
activeParamPopover === app.appId ? null : app.appId
|
||||
)
|
||||
}
|
||||
className="group/param inline-flex cursor-pointer items-center space-x-1 text-[14px] text-[#222222] transition-colors hover:text-[#0079FE]"
|
||||
>
|
||||
<span className="font-medium">{app.routeParams.length}个</span>
|
||||
<span className="text-[10px] text-[#A6A6A6] transition-colors group-hover/param:text-[#0079FE]">
|
||||
▼
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-[14px] text-[#A6A6A6]">0个</span>
|
||||
)}
|
||||
|
||||
{activeParamPopover === app.appId && app.routeParams.length > 0 && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setActiveParamPopover(null)}
|
||||
/>
|
||||
<div className="absolute left-4 top-11 z-50 mt-1 w-[280px] animate-in fade-in rounded-[4px] border border-[#E8E8E8] bg-white p-3 text-[12px] shadow-[0_8px_32px_rgba(34,34,34,0.16)]">
|
||||
<div className="mb-2 flex items-center justify-between border-b border-[#E8E8E8] pb-2 font-semibold text-[#3D3D3D]">
|
||||
<span>路由参数明细</span>
|
||||
<span className="rounded-sm bg-[#EAF4FF] px-1.5 py-0.5 text-[10px] text-[#0079FE]">
|
||||
URL 拼接
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-[160px] space-y-1.5 overflow-y-auto">
|
||||
{app.routeParams.map((p, idx) => (
|
||||
<div
|
||||
key={`${app.appId}-${idx}`}
|
||||
className="flex items-center justify-between rounded-sm bg-[#FAFAFA] p-1.5 font-mono hover:bg-[#FEF6F6]"
|
||||
>
|
||||
<span className="font-bold text-[#F5222D]">{p.key}</span>
|
||||
<span className="text-[11px] text-[#A6A6A6]">
|
||||
({getParamSourceLabel(p)})
|
||||
</span>
|
||||
</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 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[#2FD69A] opacity-75" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-[#2FD69A]" />
|
||||
</span>
|
||||
<span className="text-[13px] font-semibold text-[#189D72]">已启用</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="h-2 w-2 rounded-full bg-[#A6A6A6]" />
|
||||
<span className="text-[13px] text-[#A6A6A6]">已禁用</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-4 py-[18px] pr-6 text-right">
|
||||
<div className="inline-flex items-center justify-end space-x-2.5">
|
||||
<button
|
||||
onClick={() => openEdit(app)}
|
||||
className="group/btn flex h-[34px] w-[34px] cursor-pointer items-center justify-center rounded-[4px] border border-[#D4E9FF] bg-[#EAF4FF] text-[#0079FE] transition-all duration-200 hover:scale-105 hover:border-[#0079FE] hover:bg-[#0079FE] hover:text-white active:scale-95"
|
||||
title="编辑配置"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5 transition-transform duration-200 group-hover/btn:rotate-12" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(app)}
|
||||
className="group/btn flex h-[34px] w-[34px] cursor-pointer items-center justify-center rounded-[4px] border border-[#FDDADC] bg-[#FEEDED] text-[#F5222D] transition-all duration-200 hover:scale-105 hover:border-[#F5222D] hover:bg-[#F5222D] hover:text-white active:scale-95"
|
||||
title="删除配置"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 transition-transform duration-200 group-hover/btn:scale-110" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
apps.map((app) => (
|
||||
<tr key={app.appId} className={`border-t ${rowBorder}`}>
|
||||
<td className="px-6 py-4 align-middle">
|
||||
<div className={`text-[15px] font-semibold ${isDark ? "text-white" : "text-[#222222]"}`}>
|
||||
{app.name}
|
||||
))}
|
||||
|
||||
{tableApps.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-12 text-center">
|
||||
<div className="flex flex-col items-center justify-center space-y-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-[#F5F5F5] text-[#C1C1C1]">
|
||||
<Info className="h-6 w-6" />
|
||||
</div>
|
||||
{app.description && (
|
||||
<div className={`mt-0.5 line-clamp-1 text-xs ${mutedText}`}>{app.description}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 align-middle">
|
||||
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ${typeBadge(app.appType)}`}>
|
||||
{app.appType === "iframe" ? <Layers className="h-3 w-3" /> : <ExternalLink className="h-3 w-3" />}
|
||||
{app.appType === "iframe" ? "iframe 嵌套" : "新页跳转"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 align-middle">
|
||||
<div className="max-w-[320px] truncate font-mono text-xs">{app.url}</div>
|
||||
{app.appType === "iframe" && app.mountLocation && (
|
||||
<div className={`mt-0.5 text-xs ${mutedText}`}>挂载:{mountLabel(app.mountLocation)}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 align-middle">
|
||||
<span className={mutedText}>{app.routeParams.length} 个</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 align-middle">
|
||||
<span className={app.enabled ? "text-emerald-500" : mutedText}>
|
||||
{app.enabled ? "已启用" : "已停用"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right align-middle">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="gap-1" onClick={() => openEdit(app)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="gap-1 text-red-500 hover:text-red-600" onClick={() => handleDelete(app)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
删除
|
||||
</Button>
|
||||
<div className="space-y-1">
|
||||
<p className="text-[14px] font-semibold text-[#A6A6A6]">
|
||||
{isLoading ? "加载中..." : "暂无匹配的组件应用"}
|
||||
</p>
|
||||
{!isLoading && (
|
||||
<p className="text-[12px] text-[#C1C1C1]">
|
||||
{search ? "请确认搜索关键词输入是否正确" : "点击右上角“注册应用”添加第一款轻应用"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex max-w-full select-none items-start space-x-3.5 rounded-[4px] border border-[#FFBB55] bg-[#FFF6EA] p-4 text-[13px] text-[#575757]">
|
||||
<Info className="mt-0.5 h-5 w-5 flex-shrink-0 text-[#FF9900]" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-bold text-[#222222]">📌 智能多维挂载规则:</p>
|
||||
<ul className="list-inside list-disc space-y-1 pl-1">
|
||||
<li>
|
||||
当您注册 <strong>iframe 嵌套</strong>{" "}
|
||||
应用时,您需要在菜单一栏指定分类(如“资源管理”),组件将直接作为新导航菜单项加入该分类。
|
||||
</li>
|
||||
<li>
|
||||
当路由挂载成功,点击左侧注入的子栏名称可在主区域调起{" "}
|
||||
<strong>真实 Iframe 预览器</strong>
|
||||
,并自动将参数以加密/标准的QueryString传递过去。
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-h-[88vh] overflow-y-auto sm:max-w-[640px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? "编辑应用" : "注册应用"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{dialogOpen && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center overflow-y-auto select-none">
|
||||
<div
|
||||
className="fixed inset-0 bg-[#3D3D3D]/40 backdrop-blur-[1px]"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
/>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* 类型 */}
|
||||
<Field label="应用类型">
|
||||
<Select value={form.appType} onValueChange={(v) => patchForm({ appType: v as LightAppType })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="window_open">新页跳转(window.open)</SelectItem>
|
||||
<SelectItem value="iframe">iframe 嵌套(挂载到菜单)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<div className="relative z-[110] m-4 flex max-h-[90vh] w-full max-w-[580px] flex-col rounded-[4px] border border-[#E8E8E8] bg-white p-6 shadow-[0_8px_32px_rgba(34,34,34,0.16)]">
|
||||
<div className="flex flex-shrink-0 items-center justify-between border-b border-[#E8E8E8] pb-4">
|
||||
<h2 className="text-[17px] font-bold text-[#222222]">
|
||||
{editing ? "修改轻应用" : "注册应用"}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setDialogOpen(false)}
|
||||
className="cursor-pointer rounded-sm p-1 text-[#A6A6A6] transition-colors hover:bg-[#FAFAFA] hover:text-[#F5222D]"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Field label="应用名称">
|
||||
<Input value={form.name} onChange={(e) => patchForm({ name: e.target.value })} placeholder="如:舆情看板" />
|
||||
</Field>
|
||||
|
||||
<Field label="应用地址">
|
||||
<Input
|
||||
value={form.url}
|
||||
onChange={(e) => patchForm({ url: e.target.value })}
|
||||
placeholder="https://example.com/page 或 https://x#/route"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{form.appType === "iframe" && (
|
||||
<Field label="挂载菜单">
|
||||
<Select value={form.mountLocation || undefined} onValueChange={(v) => patchForm({ mountLocation: v })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择挂载到哪个菜单下" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MOUNT_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label="应用描述(可选)">
|
||||
<Textarea
|
||||
value={form.description}
|
||||
onChange={(e) => patchForm({ description: e.target.value })}
|
||||
placeholder="简要说明该应用的用途"
|
||||
rows={2}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* 路由参数构造器 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">路由参数</span>
|
||||
<Button variant="outline" size="sm" className="gap-1" onClick={addParam}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
添加路由参数
|
||||
</Button>
|
||||
<div className="flex-1 space-y-4 overflow-y-auto py-4 pr-1 text-[#222222]">
|
||||
<div className="space-y-1.5">
|
||||
<label className="flex items-center text-[14px] font-medium">
|
||||
<span>应用类型</span>
|
||||
<span className="ml-1 text-[#F5222D]">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={form.appType}
|
||||
onChange={(e) => patchForm({ appType: e.target.value as LightAppType })}
|
||||
className="h-9 w-full cursor-pointer rounded-[2px] border border-[#DBDBDB] bg-white px-3 text-[14px] focus:border-[#F5222D] focus:outline-none focus:ring-1 focus:ring-[#F5222D]"
|
||||
>
|
||||
<option value="iframe">iframe 嵌套 (挂载到菜单)</option>
|
||||
<option value="window_open">window.open (新页跳转)</option>
|
||||
</select>
|
||||
</div>
|
||||
{form.routeParams.length === 0 && (
|
||||
<p className={`text-xs ${mutedText}`}>暂无参数。登录参数运行时从 userInfo 取值,主题参数用固定值。</p>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="flex items-center text-[14px] font-medium">
|
||||
<span>应用名称</span>
|
||||
<span className="ml-1 text-[#F5222D]">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) => patchForm({ name: e.target.value })}
|
||||
placeholder="如: 舆情看板"
|
||||
className="h-9 rounded-[2px] border-[#DBDBDB] text-[14px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="flex items-center text-[14px] font-medium">
|
||||
<span>应用地址</span>
|
||||
<span className="ml-1 text-[#F5222D]">*</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="h-9 rounded-[2px] border-[#DBDBDB] font-mono text-[14px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{form.appType === "iframe" && (
|
||||
<div className="animate-in fade-in space-y-1.5">
|
||||
<label className="flex items-center text-[14px] font-medium">
|
||||
<span>挂载菜单</span>
|
||||
<span className="ml-1 text-[#F5222D]">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={form.mountLocation}
|
||||
onChange={(e) => patchForm({ mountLocation: e.target.value })}
|
||||
className="h-9 w-full cursor-pointer rounded-[2px] border border-[#DBDBDB] bg-white px-3 text-[14px] focus:border-[#F5222D] focus:outline-none focus:ring-1 focus:ring-[#F5222D]"
|
||||
>
|
||||
<option value="">请选择挂载菜单</option>
|
||||
{MOUNT_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{form.routeParams.map((p, idx) => (
|
||||
<ParamRow
|
||||
key={idx}
|
||||
param={p}
|
||||
isDark={isDark}
|
||||
onChange={(patch) => patchParam(idx, patch)}
|
||||
onRemove={() => removeParam(idx)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[14px] font-medium">应用描述 (可选)</label>
|
||||
<Textarea
|
||||
value={form.description}
|
||||
onChange={(e) => patchForm({ description: e.target.value })}
|
||||
placeholder="简要说明该应用的用途"
|
||||
rows={2}
|
||||
className="resize-none rounded-[2px] border-[#DBDBDB] text-[14px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5 pt-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center text-[14px] font-medium">
|
||||
<span>路由参数</span>
|
||||
<HelpCircle
|
||||
className="ml-1 h-3.5 w-3.5 text-[#A6A6A6]"
|
||||
title="参数会在加载此应用时作为 query 参数拼接在 URL 的尾部"
|
||||
/>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addParam}
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{form.routeParams.length > 0 ? (
|
||||
<div className="max-h-[220px] space-y-2 overflow-y-auto rounded-sm border border-[#E8E8E8] bg-[#FAFAFA] p-1">
|
||||
{form.routeParams.map((p, idx) => (
|
||||
<div
|
||||
key={`${p.key}-${idx}`}
|
||||
className="relative flex items-center space-x-2 rounded-sm border border-[#E8E8E8] bg-white p-2"
|
||||
>
|
||||
<div className="flex-1 space-y-1">
|
||||
<input
|
||||
type="text"
|
||||
value={p.key}
|
||||
onChange={(e) => patchParam(idx, { key: e.target.value })}
|
||||
placeholder="参数名 key"
|
||||
className="h-8 w-full rounded-[2px] 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
|
||||
value={p.paramType}
|
||||
onChange={(e) =>
|
||||
patchParam(idx, {
|
||||
paramType: e.target.value as "login" | "theme",
|
||||
source:
|
||||
e.target.value === "login" ? (p.source || "accessToken") : undefined,
|
||||
})
|
||||
}
|
||||
className="h-8 w-full rounded-[2px] border border-[#DBDBDB] bg-[#FAFAFA] px-1 text-[12px] focus:border-[#F5222D] focus:outline-none"
|
||||
>
|
||||
<option value="login">登录参数</option>
|
||||
<option value="theme">主题参数</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex-[1.2] text-[11px]">
|
||||
{p.paramType === "login" ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={p.source || "accessToken"}
|
||||
onChange={(e) => patchParam(idx, { source: e.target.value })}
|
||||
className="h-8 w-full rounded-[2px] border border-[#DBDBDB] bg-white px-1 text-[12px] focus:border-[#F5222D] focus:outline-none"
|
||||
>
|
||||
{LOGIN_SOURCE_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{p.source === "other" && (
|
||||
<input
|
||||
type="text"
|
||||
value={p.customValue || ""}
|
||||
onChange={(e) =>
|
||||
patchParam(idx, { customValue: e.target.value })
|
||||
}
|
||||
placeholder="自定义值"
|
||||
className="h-8 w-[120px] rounded-[2px] border border-[#DBDBDB] px-2 text-[12px] focus:border-[#F5222D] focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={p.themeValue || ""}
|
||||
onChange={(e) => patchParam(idx, { themeValue: e.target.value })}
|
||||
placeholder="主题值,如 dark-blue"
|
||||
className="h-8 w-full rounded-[2px] border border-[#DBDBDB] bg-white px-2 text-[12px] focus:border-[#F5222D] focus:outline-none focus:ring-px focus:ring-[#F5222D]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeParam(idx)}
|
||||
className="cursor-pointer rounded-sm p-1 text-[#A6A6A6] transition-colors hover:bg-[#FEEDED] hover:text-[#F5222D]"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-sm border border-dashed border-[#E8E8E8] bg-[#FAFAFA] p-4 text-center text-[12px] text-[#A6A6A6]">
|
||||
暂无注入的路由参数,加载地址时不传递多余拼接。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-[#E8E8E8] py-2.5">
|
||||
<span className="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 ? "bg-[#F5222D]" : "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>
|
||||
|
||||
{previewUrl && (
|
||||
<div className={`rounded-lg border px-3 py-2 ${isDark ? "border-[rgba(67,89,124,0.4)] bg-[rgba(8,19,34,0.6)]" : "border-[#F0F0F0] bg-[#FAFAFA]"}`}>
|
||||
<div className={`text-xs ${mutedText}`}>访问预览(登录参数取自当前 userInfo)</div>
|
||||
<div className="mt-1 break-all font-mono text-xs">{previewUrl}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">启用</span>
|
||||
<Switch checked={form.enabled} onCheckedChange={(v) => patchForm({ enabled: v })} />
|
||||
<div className="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="h-9 rounded-[2px] border-[#DBDBDB] px-[18px] text-[14px] font-semibold"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={handleSave}
|
||||
className="h-9 cursor-pointer rounded-[2px] 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>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={saving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? "保存中…" : editing ? "保存" : "注册"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 去掉与当前参数类型无关的字段,保持载荷干净。 */
|
||||
function cleanParams(params: LightAppRouteParam[]): LightAppRouteParam[] {
|
||||
return params
|
||||
.filter((p) => p.key.trim())
|
||||
@@ -427,116 +665,12 @@ function cleanParams(params: LightAppRouteParam[]): LightAppRouteParam[] {
|
||||
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 ?? "";
|
||||
if (base.source === "other") {
|
||||
base.customValue = (p.customValue ?? "").trim();
|
||||
}
|
||||
} else {
|
||||
base.themeValue = p.themeValue ?? "";
|
||||
base.themeValue = (p.themeValue ?? "").trim();
|
||||
}
|
||||
return base;
|
||||
});
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function ParamRow({
|
||||
param,
|
||||
isDark,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: {
|
||||
param: LightAppRouteParam;
|
||||
isDark: boolean;
|
||||
onChange: (patch: Partial<LightAppRouteParam>) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const rowClass = isDark
|
||||
? "border border-[rgba(67,89,124,0.4)] bg-[rgba(8,19,34,0.5)]"
|
||||
: "border border-[#F0F0F0] bg-[#FAFAFA]";
|
||||
return (
|
||||
<div className={`rounded-lg p-2.5 ${rowClass}`}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
value={param.key}
|
||||
onChange={(e) => onChange({ key: e.target.value })}
|
||||
placeholder="参数名 key"
|
||||
className="h-8 w-[140px]"
|
||||
/>
|
||||
<Select value={param.paramType} onValueChange={(v) => onChange({ paramType: v as "login" | "theme" })}>
|
||||
<SelectTrigger className="h-8 w-[120px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="login">登录参数</SelectItem>
|
||||
<SelectItem value="theme">主题参数</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{param.paramType === "login" ? (
|
||||
<>
|
||||
<Select value={param.source || "accessToken"} onValueChange={(v) => onChange({ source: v })}>
|
||||
<SelectTrigger className="h-8 w-[150px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LOGIN_SOURCE_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{param.source === "other" && (
|
||||
<Input
|
||||
value={param.customValue ?? ""}
|
||||
onChange={(e) => onChange({ customValue: e.target.value })}
|
||||
placeholder="自定义值"
|
||||
className="h-8 w-[150px]"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Input
|
||||
value={param.themeValue ?? ""}
|
||||
onChange={(e) => onChange({ themeValue: e.target.value })}
|
||||
placeholder="主题值,如 dark-blue"
|
||||
className="h-8 w-[160px]"
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="ml-auto inline-flex h-7 w-7 items-center justify-center rounded-md text-red-500 hover:bg-red-500/10"
|
||||
title="删除该参数"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{param.paramType === "theme" && (
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 pl-1">
|
||||
<span className="text-[11px] text-muted-foreground">示例(点击填入,可再修改):</span>
|
||||
{THEME_EXAMPLE_OPTIONS.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
onClick={() => onChange({ themeValue: o.value })}
|
||||
className={`rounded px-1.5 py-0.5 text-[11px] ${
|
||||
param.themeValue === o.value
|
||||
? "bg-primary/15 text-primary"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/70"
|
||||
}`}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user