diff --git a/frontend-web/src/components/workspace/messages/message-token-usage.tsx b/frontend-web/src/components/workspace/messages/message-token-usage.tsx index 16019b2c..f45b2ef0 100644 --- a/frontend-web/src/components/workspace/messages/message-token-usage.tsx +++ b/frontend-web/src/components/workspace/messages/message-token-usage.tsx @@ -23,7 +23,7 @@ function classifySpeed(tps: number): { color: string; label: string } { return { color: "#ef4444", label: "缓慢" }; } -function formatDuration(ms: number): string { +export function formatDuration(ms: number): string { if (ms < 1000) return `${Math.round(ms)} ms`; if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`; const mins = Math.floor(ms / 60_000); @@ -31,7 +31,7 @@ function formatDuration(ms: number): string { return `${mins}m ${secs}s`; } -function SpeedPill({ metric }: { metric: MessageMetric }) { +export function SpeedPill({ metric }: { metric: MessageMetric }) { const fill = Math.max(0, Math.min(1, metric.tokensPerSec / FULL_SCALE_TOKENS_PER_SEC)); const { color, label } = classifySpeed(metric.tokensPerSec); const display = diff --git a/frontend-web/src/pages/LightAppManagementPage.tsx b/frontend-web/src/pages/LightAppManagementPage.tsx index a1f9cae1..1b54131a 100644 --- a/frontend-web/src/pages/LightAppManagementPage.tsx +++ b/frontend-web/src/pages/LightAppManagementPage.tsx @@ -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(null); const [form, setForm] = useState(emptyForm); + const [activeParamPopover, setActiveParamPopover] = useState(null); const saving = actions.create.isPending || actions.update.isPending; @@ -126,7 +110,8 @@ export default function LightAppManagementPage() { setDialogOpen(true); }; - const patchForm = (patch: Partial) => setForm((f) => ({ ...f, ...patch })); + const patchForm = (patch: Partial) => + setForm((f) => ({ ...f, ...patch })); const patchParam = (idx: number, patch: Partial) => 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 ( -
-
-
+
+
+
-

- 应用管理 -

-

- 注册轻应用。window.open 类型在「应用列表」以卡片展示;iframe 类型挂载到所选菜单下并注入侧边栏。 +

应用管理

+

+ 注册轻应用。window.open 类型在「应用列表」以卡片展示;iframe 类型挂载到所选菜单下并注入侧栏。

- + +
- - + 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 && ( + + )}
-
- +
+
- - - - - - - + + + + + + + - - {apps.length === 0 ? ( - - + {tableApps.map((app) => ( + + + + + + + + + + + + - ) : ( - apps.map((app) => ( - - + - - - - - - - )) + + + )}
名称类型地址 / 挂载参数启用操作
名称类型地址 / 挂载参数启用操作
- {isLoading ? "加载中…" : "暂无应用,点击右上角「注册应用」开始"} +
+
+ + {app.name} + + {app.description ? ( + {app.description} + ) : ( + 无描述说明 + )} +
+
+ {app.appType === "window_open" ? ( + + + 新页跳转 + + ) : ( + + + iframe 嵌套 + + )} + +
+ + {app.url} + + {app.appType === "iframe" && app.mountLocation && ( +
+ + 挂载: {mountLabel(app.mountLocation)} +
+ )} +
+
+
+ {app.routeParams.length > 0 ? ( + + ) : ( + 0个 + )} + + {activeParamPopover === app.appId && app.routeParams.length > 0 && ( + <> +
setActiveParamPopover(null)} + /> +
+
+ 路由参数明细 + + URL 拼接 + +
+
+ {app.routeParams.map((p, idx) => ( +
+ {p.key} + + ({getParamSourceLabel(p)}) + +
+ ))} +
+
+ + )} +
+
+
+ {app.enabled ? ( + <> + + + + + 已启用 + + ) : ( + <> + + 已禁用 + + )} +
+
+
+ + +
-
- {app.name} + ))} + + {tableApps.length === 0 && ( +
+
+
+
- {app.description && ( -
{app.description}
- )} -
- - {app.appType === "iframe" ? : } - {app.appType === "iframe" ? "iframe 嵌套" : "新页跳转"} - - -
{app.url}
- {app.appType === "iframe" && app.mountLocation && ( -
挂载:{mountLabel(app.mountLocation)}
- )} -
- {app.routeParams.length} 个 - - - {app.enabled ? "已启用" : "已停用"} - - -
- - +
+

+ {isLoading ? "加载中..." : "暂无匹配的组件应用"} +

+ {!isLoading && ( +

+ {search ? "请确认搜索关键词输入是否正确" : "点击右上角“注册应用”添加第一款轻应用"} +

+ )}
-
+ +
+ +
+

📌 智能多维挂载规则:

+
    +
  • + 当您注册 iframe 嵌套{" "} + 应用时,您需要在菜单一栏指定分类(如“资源管理”),组件将直接作为新导航菜单项加入该分类。 +
  • +
  • + 当路由挂载成功,点击左侧注入的子栏名称可在主区域调起{" "} + 真实 Iframe 预览器 + ,并自动将参数以加密/标准的QueryString传递过去。 +
  • +
+
+
- - - - {editing ? "编辑应用" : "注册应用"} - + {dialogOpen && ( +
+
setDialogOpen(false)} + /> -
- {/* 类型 */} - - - +
+
+

+ {editing ? "修改轻应用" : "注册应用"} +

+ +
- - patchForm({ name: e.target.value })} placeholder="如:舆情看板" /> - - - - patchForm({ url: e.target.value })} - placeholder="https://example.com/page 或 https://x#/route" - /> - - - {form.appType === "iframe" && ( - - - - )} - - -