feat: 简介模式菜单按类型 A/B 分套,兼容未配置的旧部署
两套网页可通过 runtime-config.js 的 VITE_COMPACT_MENU_TYPE 加载各自左侧菜单;缺省或旧页面不传参数时仍读取原来的类型 A 数据。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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;
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
@@ -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] }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user