feat: 简介模式菜单按类型 A/B 分套,兼容未配置的旧部署

两套网页可通过 runtime-config.js 的 VITE_COMPACT_MENU_TYPE 加载各自左侧菜单;缺省或旧页面不传参数时仍读取原来的类型 A 数据。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-08-19 16:59:34 +08:00
1 parent 5e1765d889
commit acf538ac01
21 files changed
+610 -103

No files matched your search

@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import test from "node:test";
import { applyCompactMenuOverrides, isDefaultCompactMenuType, normalizeCompactMenuType } from "./compact-menu-overrides";
import type { MenuItem } from "./sidebar-menu";
const I = (() => null) as unknown as MenuItem["icon"];
test("normalizeCompactMenuType defaults to A", () => {
assert.equal(normalizeCompactMenuType(undefined), "A");
assert.equal(normalizeCompactMenuType(""), "A");
assert.equal(normalizeCompactMenuType("default"), "A");
assert.equal(normalizeCompactMenuType("b"), "B");
assert.equal(normalizeCompactMenuType("2"), "B");
assert.equal(normalizeCompactMenuType("alt"), "B");
assert.equal(isDefaultCompactMenuType(undefined), true);
assert.equal(isDefaultCompactMenuType(""), true);
assert.equal(isDefaultCompactMenuType("B"), false);
});
test("applyCompactMenuOverrides splits sidebar vs settings", () => {
const items: MenuItem[] = [
{ id: "a", label: "对话", icon: I, path: "/a" },
{ id: "b", label: "智能体", icon: I, path: "/b" },
];
const applied = applyCompactMenuOverrides(items, [
{ nodeId: "b", placement: "settings", sortOrder: 0, label: "智能体设置" },
]);
assert.deepEqual(
applied.sidebarItems.map((item) => item.id),
["a"],
);
assert.equal(applied.settingsItems[0]?.id, "b");
assert.equal(applied.settingsItems[0]?.label, "智能体设置");
});
@@ -1,7 +1,41 @@
import type { MenuItem } from "./sidebar-menu";
import { readRuntimeEnv } from "@/env";
export type CompactMenuPlacement = "sidebar" | "settings";
export const COMPACT_MENU_TYPES = ["A", "B"] as const;
export type CompactMenuType = (typeof COMPACT_MENU_TYPES)[number];
export const DEFAULT_COMPACT_MENU_TYPE: CompactMenuType = "A";
const TYPE_ALIASES: Record<string, CompactMenuType> = {
A: "A",
"1": "A",
DEFAULT: "A",
B: "B",
"2": "B",
ALT: "B",
};
export function normalizeCompactMenuType(value?: string | null): CompactMenuType {
const raw = (value ?? "").trim().toUpperCase();
return TYPE_ALIASES[raw] ?? DEFAULT_COMPACT_MENU_TYPE;
}
export function isDefaultCompactMenuType(value?: string | null): boolean {
return normalizeCompactMenuType(value) === DEFAULT_COMPACT_MENU_TYPE;
}
/** Compact-menu variant for this website, from runtime-config.js.
* Missing / empty config is type A, which loads the original compact menu. */
export function getRuntimeCompactMenuType(): CompactMenuType {
return normalizeCompactMenuType(readRuntimeEnv("VITE_COMPACT_MENU_TYPE"));
}
export const COMPACT_MENU_TYPE_LABELS: Record<CompactMenuType, string> = {
A: "类型 A",
B: "类型 B",
};
export interface CompactMenuOverride {
nodeId: string;
placement: CompactMenuPlacement;
+5
View File
@@ -41,5 +41,10 @@ export const env = {
* flag so operations can switch the shell without rebuilding the frontend.
*/
FORCE_COLLAPSED_SIDEBAR: readRuntimeFlag("VITE_FORCE_COLLAPSED_SIDEBAR"),
/**
* Compact-menu variant for this website. Pair with the admin 简介模式
* type (A / B) so two deployments can show different left-nav items.
*/
COMPACT_MENU_TYPE: readRuntimeEnv("VITE_COMPACT_MENU_TYPE"),
GITHUB_OAUTH_TOKEN: readRuntimeEnv("VITE_GITHUB_OAUTH_TOKEN"),
};
+49 -9
View File
@@ -78,9 +78,13 @@ import {
type MenuOverride,
type MergedMenuNode,
} from "@/core/page-layout/menu-overrides";
import type {
CompactMenuOverride,
CompactMenuPlacement,
import {
COMPACT_MENU_TYPES,
COMPACT_MENU_TYPE_LABELS,
getRuntimeCompactMenuType,
type CompactMenuOverride,
type CompactMenuPlacement,
type CompactMenuType,
} from "@/core/page-layout/compact-menu-overrides";
import { sidebarMenuItems, type MenuItem } from "@/core/page-layout/sidebar-menu";
import { injectLightApps } from "@/core/page-layout/use-sidebar-menu";
@@ -975,8 +979,10 @@ export default function MenuManagementPage() {
}
function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) {
const { data: overrides = [], isLoading } = useCompactMenuOverrides();
const { save } = useCompactMenuOverrideActions();
const runtimeType = getRuntimeCompactMenuType();
const [menuType, setMenuType] = useState<CompactMenuType>(runtimeType);
const { data: overrides = [], isLoading } = useCompactMenuOverrides(menuType);
const { save } = useCompactMenuOverrideActions(menuType);
const initialRows = useMemo(() => compactRowsFromTree(baseTree, overrides), [baseTree, overrides]);
const [rows, setRows] = useState<CompactRow[]>(initialRows);
const [loadedKey, setLoadedKey] = useState("");
@@ -988,8 +994,9 @@ function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) {
(top.children ?? []).map((child) => [top.id, child.id, child.label]),
),
overrides,
menuType,
}),
[baseTree, overrides],
[baseTree, overrides, menuType],
);
useEffect(() => {
if (sourceKey !== loadedKey) {
@@ -1031,17 +1038,23 @@ function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) {
const handleSave = async () => {
try {
await save.mutateAsync(currentOverrides);
toast.success("简介菜单配置已保存");
toast.success(`简介菜单配置已保存(${COMPACT_MENU_TYPE_LABELS[menuType]})`);
} catch (err) {
toast.error(err instanceof Error ? err.message : "保存失败");
}
};
const handleReset = () => {
if (!confirm("确定要重置简介模式菜单为默认布局吗?保存后将清除简介模式自定义配置。")) return;
if (!confirm(`确定要重置${COMPACT_MENU_TYPE_LABELS[menuType]}的简介模式菜单为默认布局吗?保存后将清除该类型的自定义配置。`)) return;
setRows(compactRowsFromTree(baseTree, []));
};
const switchMenuType = (next: CompactMenuType) => {
if (next === menuType) return;
if (dirty && !confirm("当前类型有未保存的修改,切换后将丢失。确定切换吗?")) return;
setMenuType(next);
};
return (
<>
<div className="relative mb-6 overflow-hidden rounded-3xl border border-slate-200/60 bg-white p-6 shadow-sm md:p-8">
@@ -1082,9 +1095,36 @@ function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) {
</button>
</div>
</div>
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className="inline-flex rounded-2xl border border-slate-200 bg-slate-50 p-1 text-sm font-semibold">
{COMPACT_MENU_TYPES.map((type) => (
<button
key={type}
type="button"
onClick={() => switchMenuType(type)}
className={`rounded-xl px-4 py-1.5 transition-all ${
menuType === type ? "bg-red-600 text-white shadow-sm" : "text-slate-500 hover:text-slate-800"
}`}
>
{COMPACT_MENU_TYPE_LABELS[type]}
{runtimeType === type ? (
<span className={`ml-1.5 text-[10px] font-medium ${menuType === type ? "text-white/80" : "text-slate-400"}`}>
当前网页
</span>
) : null}
</button>
))}
</div>
</div>
<p className="flex max-w-2xl items-start gap-2.5 rounded-2xl border border-slate-200/50 bg-slate-50/70 p-3.5 text-sm leading-relaxed text-slate-600">
<Info className="mt-0.5 size-4 shrink-0 text-slate-400" />
<span>这里配置的是简介模式工作区左侧菜单:可改显示名称、调整顺序,或迁移到「设置和更多」下拉中;全量模式菜单不受影响。</span>
<span>
这里配置的是简介模式工作区左侧菜单,按类型 A / B 分套保存。两套网页在{" "}
<code className="rounded bg-slate-200/70 px-1 py-0.5 text-xs">runtime-config.js</code> 里填写{" "}
<code className="rounded bg-slate-200/70 px-1 py-0.5 text-xs">VITE_COMPACT_MENU_TYPE</code> 为 A 或 B
即可加载对应配置;全量模式菜单不受影响。当前网页正在使用
{COMPACT_MENU_TYPE_LABELS[runtimeType]}。
</span>
</p>
</div>
</div>
@@ -3,7 +3,12 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { OptType, writeYLog } from "@/core/log/y-log";
import type { CompactMenuOverride } from "@/core/page-layout/compact-menu-overrides";
import {
getRuntimeCompactMenuType,
isDefaultCompactMenuType,
type CompactMenuOverride,
type CompactMenuType,
} from "@/core/page-layout/compact-menu-overrides";
const BASE = () => `${getBackendBaseURL()}/api/compact-menu-overrides`;
@@ -12,6 +17,7 @@ interface ServerCompactOverride {
placement: "sidebar" | "settings";
sort_order: number;
label: string | null;
menu_type?: string | null;
}
function toOverride(s: ServerCompactOverride): CompactMenuOverride {
@@ -44,27 +50,40 @@ async function readJson<T>(res: Response, ctx: string): Promise<T> {
return (await res.json()) as T;
}
export async function listCompactMenuOverrides(): Promise<CompactMenuOverride[]> {
const res = await apiFetch(BASE(), { method: "GET" });
export async function listCompactMenuOverrides(
menuType: CompactMenuType = getRuntimeCompactMenuType(),
): Promise<CompactMenuOverride[]> {
// 旧页面 / 未配置 VITE_COMPACT_MENU_TYPE 时不带参数,后端按类型 A 返回原来的数据。
const url = isDefaultCompactMenuType(menuType)
? BASE()
: `${BASE()}?menu_type=${encodeURIComponent(menuType)}`;
const res = await apiFetch(url, { method: "GET" });
const data = await readJson<{ overrides: ServerCompactOverride[] }>(res, "list compact menu overrides");
return (data.overrides ?? []).map(toOverride);
}
export async function replaceCompactMenuOverrides(
overrides: CompactMenuOverride[],
menuType: CompactMenuType = getRuntimeCompactMenuType(),
): Promise<CompactMenuOverride[]> {
const body: { overrides: ServerCompactOverride[]; menu_type?: CompactMenuType } = {
overrides: overrides.map(toBody),
};
if (!isDefaultCompactMenuType(menuType)) {
body.menu_type = menuType;
}
const res = await apiFetch(BASE(), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ overrides: overrides.map(toBody) }),
body: JSON.stringify(body),
});
const data = await readJson<{ overrides: ServerCompactOverride[] }>(res, "save compact menu overrides");
const saved = (data.overrides ?? []).map(toOverride);
writeYLog({
optType: OptType.edit,
optDetail: "CM助手-简介-菜单管理-保存",
optDetail: `CM助手-简介-菜单管理-保存-类型${menuType}`,
url: "/api/compact-menu-overrides",
reqParam: { overrides },
reqParam: { menuType, overrides },
returnParam: saved,
});
return saved;
@@ -72,21 +91,23 @@ export async function replaceCompactMenuOverrides(
const COMPACT_MENU_OVERRIDES_KEY = "compact-menu-overrides";
export function useCompactMenuOverrides() {
export function useCompactMenuOverrides(menuType?: CompactMenuType) {
const type = menuType ?? getRuntimeCompactMenuType();
return useQuery({
queryKey: [COMPACT_MENU_OVERRIDES_KEY],
queryFn: listCompactMenuOverrides,
queryKey: [COMPACT_MENU_OVERRIDES_KEY, type],
queryFn: () => listCompactMenuOverrides(type),
refetchOnWindowFocus: false,
staleTime: 60_000,
});
}
export function useCompactMenuOverrideActions() {
export function useCompactMenuOverrideActions(menuType?: CompactMenuType) {
const queryClient = useQueryClient();
const type = menuType ?? getRuntimeCompactMenuType();
return {
save: useMutation({
mutationFn: replaceCompactMenuOverrides,
onSuccess: () => queryClient.invalidateQueries({ queryKey: [COMPACT_MENU_OVERRIDES_KEY] }),
mutationFn: (overrides: CompactMenuOverride[]) => replaceCompactMenuOverrides(overrides, type),
onSuccess: () => queryClient.invalidateQueries({ queryKey: [COMPACT_MENU_OVERRIDES_KEY, type] }),
}),
};
}