Files
deerflow-code/frontend-web/src/strategy-components/api/light-apps.ts
T
fengxiangdebaoziandClaude Opus 4.8 74324f4585 轻应用主题参数:随当前主题色映射传值
- 主题参数改为按系统主题(red/dark-blue)映射取目标应用对应的值
- 应用管理弹窗用结构化映射编辑器(逐主题色配置目标值,默认同名)
- iframe 嵌套显式传入当前主题色,切主题时 src 重建
- 读取/保存时规范化映射串;解析支持 light/dark 等别名,取不到回退主题色名而非恒为 red

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

235 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 轻应用中心(应用管理 / 应用列表)API 客户端 + React Query hooks。
*
* 后端:`/api/light-apps`(见 app/gateway/routers/light_apps.py)。
* 全局共享、无用户隔离;读对所有登录用户开放,写仅 admin。
* snake_case ⇄ camelCase 在此边界收敛;路由参数项本身用 camelCase
* (key/paramType/source/customValue/themeValue),与后端 RouteParam 字段一致,原样透传。
*/
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { parseThemeMap, stringifyThemeMap } from "@/strategy-components/lib/light-app";
export type LightAppType = "window_open" | "iframe";
export type RouteParamType = "login" | "theme";
/** 路由参数项。登录参数运行时从 localStorage.userInfo 解析;主题参数随当前系统主题自动注入。 */
export interface LightAppRouteParam {
key: string;
paramType: RouteParamType;
/** 登录参数:accessToken / yUserId / userName / "other"。 */
source?: string | null;
/** 登录参数且 source==="other" 时的字面值。 */
customValue?: string | null;
/** 主题参数配置:映射串 `light=red;dark=darkBlue`(或纯值);运行时按当前系统主题取对应值(见 resolveThemeParamValue)。 */
themeValue?: string | null;
}
export interface LightApp {
appId: string;
name: string;
description: string | null;
appType: LightAppType;
url: string;
routeParams: LightAppRouteParam[];
/** iframe 挂载的父菜单 id(window_open 为 null)。 */
mountLocation: string | null;
icon: string | null;
enabled: boolean;
sortOrder: number;
createdAt: string | null;
updatedAt: string | null;
}
export interface LightAppInput {
name: string;
description?: string | null;
appType: LightAppType;
url: string;
routeParams: LightAppRouteParam[];
mountLocation?: string | null;
icon?: string | null;
enabled?: boolean;
sortOrder?: number;
}
export interface ListLightAppsParams {
appType?: LightAppType;
mountLocation?: string;
enabledOnly?: boolean;
search?: string;
}
const BASE = () => `${getBackendBaseURL()}/api/light-apps`;
interface ServerRouteParam {
key: string;
paramType: RouteParamType;
source?: string | null;
customValue?: string | null;
themeValue?: string | null;
}
interface ServerApp {
app_id: string;
name: string;
description: string | null;
app_type: LightAppType;
url: string;
route_params: ServerRouteParam[];
mount_location: string | null;
icon: string | null;
enabled: boolean;
sort_order: number;
created_at: string | null;
updated_at: string | null;
}
function toApp(s: ServerApp): LightApp {
return {
appId: s.app_id,
name: s.name,
description: s.description ?? null,
appType: s.app_type,
url: s.url,
routeParams: (s.route_params ?? []).map((p) => ({
key: p.key,
paramType: p.paramType,
source: p.source ?? null,
customValue: p.customValue ?? null,
// 主题参数读取时规范化为完整映射串(red=..;blue=..;dark-blue=..),
// 让旧的纯值/部分映射也能随主题切换,无需重新保存。
themeValue:
p.paramType === "theme"
? stringifyThemeMap(parseThemeMap(p.themeValue))
: (p.themeValue ?? null),
})),
mountLocation: s.mount_location ?? null,
icon: s.icon ?? null,
enabled: s.enabled ?? true,
sortOrder: s.sort_order ?? 0,
createdAt: s.created_at ?? null,
updatedAt: s.updated_at ?? null,
};
}
function toBody(input: Partial<LightAppInput>): Record<string, unknown> {
const body: Record<string, unknown> = {};
if (input.name !== undefined) body.name = input.name;
if (input.description !== undefined) body.description = input.description;
if (input.appType !== undefined) body.app_type = input.appType;
if (input.url !== undefined) body.url = input.url;
if (input.routeParams !== undefined) body.route_params = input.routeParams;
if (input.mountLocation !== undefined) body.mount_location = input.mountLocation;
if (input.icon !== undefined) body.icon = input.icon;
if (input.enabled !== undefined) body.enabled = input.enabled;
if (input.sortOrder !== undefined) body.sort_order = input.sortOrder;
return body;
}
async function readJson<T>(res: Response, ctx: string): Promise<T> {
if (!res.ok) {
const data = await res.json().catch(() => null as unknown);
const detail =
data && typeof data === "object" && "detail" in data
? String((data as { detail: unknown }).detail)
: `${ctx} failed (${res.status})`;
throw new Error(detail);
}
return (await res.json()) as T;
}
export async function listLightApps(params: ListLightAppsParams = {}): Promise<LightApp[]> {
const qs = new URLSearchParams();
if (params.appType) qs.set("app_type", params.appType);
if (params.mountLocation) qs.set("mount_location", params.mountLocation);
if (params.enabledOnly) qs.set("enabled_only", "true");
if (params.search) qs.set("search", params.search);
const query = qs.toString();
const res = await apiFetch(`${BASE()}${query ? `?${query}` : ""}`, { method: "GET" });
const data = await readJson<{ apps: ServerApp[] }>(res, "list light apps");
return (data.apps ?? []).map(toApp);
}
export async function getLightApp(appId: string): Promise<LightApp> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(appId)}`, { method: "GET" });
return toApp(await readJson<ServerApp>(res, "get light app"));
}
export async function createLightApp(input: LightAppInput): Promise<LightApp> {
const res = await apiFetch(BASE(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(toBody(input)),
});
return toApp(await readJson<ServerApp>(res, "create light app"));
}
export async function updateLightApp(
appId: string,
patch: Partial<LightAppInput>,
): Promise<LightApp> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(appId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(toBody(patch)),
});
return toApp(await readJson<ServerApp>(res, "update light app"));
}
export async function deleteLightApp(appId: string): Promise<void> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(appId)}`, { method: "DELETE" });
await readJson<{ success: boolean }>(res, "delete light app");
}
// ── React Query hooks(管理页 / 列表页 / 侧边栏共用缓存) ───────────────────
const LIGHT_APPS_KEY = "light-apps";
export function useLightApps(params: ListLightAppsParams = {}) {
return useQuery({
queryKey: [
LIGHT_APPS_KEY,
"list",
params.appType ?? "",
params.mountLocation ?? "",
params.enabledOnly ?? false,
params.search ?? "",
],
queryFn: () => listLightApps(params),
placeholderData: keepPreviousData,
refetchOnWindowFocus: false,
});
}
export function useLightApp(appId: string | undefined) {
return useQuery({
queryKey: [LIGHT_APPS_KEY, "detail", appId ?? ""],
queryFn: () => getLightApp(appId as string),
enabled: !!appId,
refetchOnWindowFocus: false,
});
}
export function useLightAppActions() {
const queryClient = useQueryClient();
const invalidate = () => queryClient.invalidateQueries({ queryKey: [LIGHT_APPS_KEY] });
return {
create: useMutation({ mutationFn: createLightApp, onSuccess: invalidate }),
update: useMutation({
mutationFn: ({ appId, patch }: { appId: string; patch: Partial<LightAppInput> }) =>
updateLightApp(appId, patch),
onSuccess: invalidate,
}),
remove: useMutation({ mutationFn: deleteLightApp, onSuccess: invalidate }),
};
}