import {
AlignCenter, Box, CheckSquare, CopyPlus, Frame, Image as ImageIcon, LayoutPanelTop,
ListPlus, MousePointerClick, PanelTop, Plus, Save, Search, SeparatorHorizontal,
SlidersHorizontal, Square, Trash2, Type, Upload, X,
} from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent } from 'react';
import { ModalPortal } from '../components/modal-portal';
import { RichTextEditor, richTextPlainText } from '../components/rich-text-editor';
import { templateTimeFormatsAreValid, VariableTextInput } from '../components/variable-catalog-picker';
import { Badge, Button, EmptyState, Field, Input, PageHeader, Panel, ResourceState, Select, Textarea, Toast } from '../components/ui';
import { useApiResource, useServer } from '../context/server-context';
import { hasCapability } from '../lib/permissions';
import type {
MenuCatalog, MenuControl, MenuControlEvent, MenuDefinition, MenuImageCatalogItem, MenuWorkspace,
TriggerCatalog, TriggerVariableDefinition,
} from '../types';
const controlTypes: Array<{ type: MenuControl['type']; zh: string; en: string; icon: typeof Type }> = [
{ type: 'text', zh: '文本', en: 'Text', icon: Type },
{ type: 'rich_text', zh: '富文本', en: 'Rich text', icon: Type },
{ type: 'button', zh: '按钮', en: 'Button', icon: MousePointerClick },
{ type: 'icon_button', zh: '图标按钮', en: 'Icon button', icon: Square },
{ type: 'image', zh: '图片', en: 'Image', icon: ImageIcon },
{ type: 'panel', zh: '面板', en: 'Panel', icon: PanelTop },
{ type: 'border', zh: '边框', en: 'Border', icon: Frame },
{ type: 'progress', zh: '进度条', en: 'Progress', icon: SlidersHorizontal },
{ type: 'input', zh: '输入框', en: 'Input', icon: Box },
{ type: 'checkbox', zh: '复选框', en: 'Checkbox', icon: CheckSquare },
{ type: 'slider', zh: '滑块', en: 'Slider', icon: SlidersHorizontal },
{ type: 'item_slot', zh: '物品槽', en: 'Item slot', icon: Box },
{ type: 'separator', zh: '分隔线', en: 'Separator', icon: SeparatorHorizontal },
{ type: 'spacer', zh: '占位区', en: 'Spacer', icon: AlignCenter },
];
const positionLabels: Record<MenuDefinition['popupPosition'], string> = {
center: '居中 / Center', top: '顶部 / Top', bottom: '底部 / Bottom', left: '左侧 / Left',
right: '右侧 / Right', top_left: '左上 / Top left', top_right: '右上 / Top right',
bottom_left: '左下 / Bottom left', bottom_right: '右下 / Bottom right', custom: '自定义偏移 / Custom',
};
const eventLabels: Record<MenuControlEvent['event'], string> = {
click: '点击 / Click', double_click: '双击 / Double click', change: '值变化 / Change',
submit: '提交 / Submit', toggle: '切换 / Toggle', hover: '悬停 / Hover',
focus: '获得焦点 / Focus', blur: '失去焦点 / Blur',
};
const fallbackCatalog: MenuCatalog = {
controlTypes: controlTypes.map((item) => item.type),
popupPositions: Object.keys(positionLabels) as MenuDefinition['popupPosition'][],
controlEvents: Object.keys(eventLabels) as MenuControlEvent['event'][],
triggers: [],
};
function nowIso(): string { return new Date().toISOString(); }
function emptyMenu(): MenuDefinition {
const now = nowIso();
return {
id: '', name: '新菜单', description: '', width: 360, height: 240, popupPosition: 'center',
offsetX: 0, offsetY: 0, pauseGame: false, backgroundColor: '#101713e8', controls: [], revision: 0,
createdBy: '', createdAt: now, updatedAt: now,
};
}
function defaultProperties(type: MenuControl['type']): Record<string, string> {
const common = { opacity: '1', tooltip: '' };
switch (type) {
case 'text': return { ...common, text: '文本', textColor: '#ffffff', fontSize: '14', align: 'left' };
case 'rich_text': return { ...common, text: '富文本', textColor: '#ffffff', fontSize: '14', align: 'left' };
case 'button': return { ...common, label: '按钮', backgroundColor: '#183427', textColor: '#71e6a1', disabled: 'false' };
case 'icon_button': return { ...common, label: '图标', icon: 'item:minecraft:compass', backgroundColor: '#183427', disabled: 'false' };
case 'image': return { ...common, source: 'item:minecraft:grass_block', fit: 'contain', alt: '图片' };
case 'panel': return { ...common, backgroundColor: '#132019e8', radius: '6' };
case 'border': return { ...common, color: '#315644', thickness: '1', radius: '6' };
case 'progress': return { ...common, value: '50', max: '100', label: '{menu.value}', color: '#46d98a', backgroundColor: '#16231c' };
case 'input': return { ...common, placeholder: '请输入…', value: '', maxLength: '256', textColor: '#ffffff', backgroundColor: '#0b100e' };
case 'checkbox': return { ...common, label: '选项', checked: 'false', color: '#46d98a' };
case 'slider': return { ...common, label: '数值', min: '0', max: '100', step: '1', value: '50', color: '#46d98a' };
case 'item_slot': return { ...common, item: 'minecraft:diamond', count: '1', label: '' };
case 'separator': return { ...common, orientation: 'horizontal', color: '#315644', thickness: '1' };
case 'spacer': return common;
}
}
function defaultGeometry(type: MenuControl['type']): [number, number] {
if (type === 'image' || type === 'item_slot' || type === 'icon_button') return [40, 40];
if (type === 'separator') return [120, 4];
if (type === 'checkbox') return [140, 24];
if (type === 'panel' || type === 'border') return [180, 100];
if (type === 'spacer') return [80, 24];
return [160, type === 'rich_text' ? 56 : 30];
}
function createControl(type: MenuControl['type'], count: number): MenuControl {
const [width, height] = defaultGeometry(type);
return {
id: `${type}_${count + 1}`, type, x: 16 + count * 6, y: 16 + count * 6, width, height,
zIndex: count, visibleWhen: '', properties: defaultProperties(type), events: [],
};
}
function typeName(type: MenuControl['type']): string {
const found = controlTypes.find((item) => item.type === type);
return found ? `${found.zh} / ${found.en}` : type;
}
function numeric(value: string | undefined, fallback: number): number {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function controlEventDefault(type: MenuControl['type']): MenuControlEvent['event'] {
if (type === 'input') return 'submit';
if (type === 'checkbox') return 'toggle';
if (type === 'slider') return 'change';
return 'click';
}
function displayControl(control: MenuControl): string {
return richTextPlainText(control.properties.text ?? control.properties.label ?? control.properties.alt ?? control.id);
}
function menuIsValid(menu: MenuDefinition): boolean {
if (!menu.name.trim() || menu.name.trim().length > 96 || menu.description.trim().length > 1024
|| !Number.isInteger(menu.width) || menu.width < 120 || menu.width > 1920
|| !Number.isInteger(menu.height) || menu.height < 80 || menu.height > 1080
|| !/^#[0-9a-f]{6}(?:[0-9a-f]{2})?$/i.test(menu.backgroundColor)
|| menu.controls.length > 128) return false;
const ids = new Set<string>();
return menu.controls.every((control) => {
if (!/^[A-Za-z_][A-Za-z0-9_.-]{0,63}$/.test(control.id) || control.id === '__xfesm_root__'
|| ids.has(control.id) || !Number.isInteger(control.x) || !Number.isInteger(control.y)
|| !Number.isInteger(control.width) || !Number.isInteger(control.height)
|| control.x < 0 || control.y < 0 || control.width < 1 || control.height < 1
|| control.x + control.width > menu.width || control.y + control.height > menu.height
|| control.visibleWhen.length > 512 || !templateTimeFormatsAreValid(control.visibleWhen)
|| Object.keys(control.properties).length > 48
|| Object.entries(control.properties).some(([key, value]) => !/^[A-Za-z][A-Za-z0-9_.-]{0,63}$/.test(key)
|| value.length > 16_384 || !templateTimeFormatsAreValid(value))) return false;
ids.add(control.id);
const events = new Set<MenuControlEvent['event']>();
return control.events.length <= 8 && control.events.every((event) => {
if (events.has(event.event) || (!event.triggerId && !event.submenuId && !event.closeMenu)) return false;
events.add(event.event); return true;
});
});
}
function ImageResourcePreview({ api, item }: {
api: ReturnType<typeof useServer>['api']; item: MenuImageCatalogItem;
}) {
const host = useRef<HTMLSpanElement>(null);
const [dataUrl, setDataUrl] = useState(item.dataUrl ?? '');
const [unavailable, setUnavailable] = useState(false);
useEffect(() => {
if (item.dataUrl) { setDataUrl(item.dataUrl); setUnavailable(false); return; }
let active = true;
let requested = false;
const load = () => {
if (requested) return;
requested = true;
void api.menuImagePreview(item.reference)
.then((result) => { if (active) setDataUrl(result.dataUrl); })
.catch(() => { if (active) setUnavailable(true); });
};
if (typeof IntersectionObserver === 'undefined' || !host.current) {
load();
return () => { active = false; };
}
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) { load(); observer.disconnect(); }
}, { rootMargin: '160px' });
observer.observe(host.current);
return () => { active = false; observer.disconnect(); };
}, [api, item.dataUrl, item.reference]);
return <span ref={host} className={`menu-image-picker__preview menu-image-picker__preview--${item.type}`}
title={unavailable ? '该资源没有可用的贴图预览' : undefined}>
{dataUrl ? <img src={dataUrl} alt="" /> : item.type === 'item' || item.type === 'block'
? <Box /> : <ImageIcon />}
</span>;
}
export function ImagePicker({ api, value, onSelect, onClose }: {
api: ReturnType<typeof useServer>['api']; value: string; onSelect: (value: string) => void; onClose: () => void;
}) {
const [query, setQuery] = useState('');
const [items, setItems] = useState<MenuImageCatalogItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [uploading, setUploading] = useState(false);
useEffect(() => {
let active = true;
const timer = globalThis.setTimeout(() => {
setLoading(true);
api.menuImages(query).then((result) => { if (active) { setItems(result); setError(''); } })
.catch((cause: unknown) => { if (active) setError(cause instanceof Error ? cause.message : String(cause)); })
.finally(() => { if (active) setLoading(false); });
}, 180);
return () => { active = false; globalThis.clearTimeout(timer); };
}, [api, query]);
const upload = async (file?: File) => {
if (!file) return;
if (!['image/png', 'image/jpeg'].includes(file.type) || file.size > 256 * 1024) {
setError('仅支持不超过 256 KiB 的 PNG/JPEG 图片。'); return;
}
setUploading(true);
try {
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result)); reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
const created = await api.uploadMenuImage(file.name, dataUrl);
onSelect(created.reference); onClose();
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
} finally { setUploading(false); }
};
return <ModalPortal><div className="modal-backdrop menu-image-backdrop" onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}><section className="menu-image-picker" role="dialog" aria-modal="true" aria-label="选择菜单图片">
<header><div><strong>图片资源 / Image resources</strong><small>搜索上传图片、原版物品/方块和已安装模组贴图</small></div>
<button type="button" onClick={onClose} aria-label="关闭"><X /></button></header>
<div className="menu-image-picker__toolbar">
<label className="menu-image-picker__search"><Search size={16} /><Input autoFocus type="search" value={query}
onChange={(event) => setQuery(event.target.value)} placeholder="名称、资源 ID、模组命名空间…" /></label>
<label className="button button--secondary menu-upload-button"><Upload size={15} />{uploading ? '上传中…' : '上传图片'}
<input type="file" accept="image/png,image/jpeg" disabled={uploading}
onChange={(event) => void upload(event.target.files?.[0])} /></label>
</div>
{error && <div className="form-error" role="alert">{error}</div>}
<div className="menu-image-picker__results">
{items.map((item) => <button type="button" key={item.reference}
className={value === item.reference ? 'is-selected' : ''} onClick={() => { onSelect(item.reference); onClose(); }}>
<ImageResourcePreview api={api} item={item} />
<span><strong>{item.name}</strong><code>{item.reference}</code><small>{item.source}</small></span>
</button>)}
{!loading && items.length === 0 && <EmptyState icon={<ImageIcon />} title="没有匹配图片" detail="可上传 PNG/JPEG,或换一个资源 ID 搜索。" />}
{loading && <div className="resource-state">正在搜索…</div>}
</div>
</section></div></ModalPortal>;
}
function ControlPreview({ control, selected, imageData, onPointerDown, onSelect }: {
control: MenuControl; selected: boolean; imageData?: string;
onPointerDown: (event: PointerEvent<HTMLDivElement>) => void; onSelect: () => void;
}) {
const properties = control.properties;
const canvasWidth = Number(control.properties.__canvasWidth) || 1;
const canvasHeight = Number(control.properties.__canvasHeight) || 1;
const opacity = Math.max(0, Math.min(1, numeric(properties.opacity, 1)));
const style: CSSProperties = {
left: `${control.x / canvasWidth * 100}%`, top: `${control.y / canvasHeight * 100}%`,
width: `${control.width / canvasWidth * 100}%`, height: `${control.height / canvasHeight * 100}%`,
zIndex: control.zIndex + 1000,
opacity, color: properties.textColor || properties.color, background: properties.backgroundColor,
fontSize: numeric(properties.fontSize, 14), borderRadius: numeric(properties.radius, 4),
};
const label = displayControl(control);
let child;
switch (control.type) {
case 'image': child = imageData ? <img src={imageData} alt={properties.alt ?? ''} />
: <><ImageIcon /><small>{properties.source}</small></>; break;
case 'icon_button': child = <>{imageData ? <img src={imageData} alt="" /> : <ImageIcon size={16} />}
<span>{properties.label}</span></>; break;
case 'border': child = <span className="menu-control__border" style={{ borderColor: properties.color,
borderWidth: numeric(properties.thickness, 1) }} />; break;
case 'progress': {
const maximum = Math.max(1, numeric(properties.max, 100));
const progress = Math.max(0, Math.min(100, numeric(properties.value, 0) / maximum * 100));
child = <><span className="menu-control__progress" style={{ width: `${progress}%`, background: properties.color }} />
<span>{properties.label || `${Math.round(progress)}%`}</span></>; break;
}
case 'input': child = <span className="menu-control__input">{properties.value || properties.placeholder}</span>; break;
case 'checkbox': child = <><span className={`menu-control__check ${properties.checked === 'true' ? 'is-checked' : ''}`}>✓</span>
<span>{properties.label}</span></>; break;
case 'slider': {
const min = numeric(properties.min, 0); const max = Math.max(min + 1, numeric(properties.max, 100));
const percent = Math.max(0, Math.min(100, (numeric(properties.value, min) - min) / (max - min) * 100));
child = <><span>{properties.label}</span><span className="menu-control__track"><i style={{ left: `${percent}%` }} /></span></>; break;
}
case 'item_slot': child = <><Box /><small>{properties.item}</small><b>{properties.count}</b></>; break;
case 'separator': child = <span className="menu-control__separator" style={{ background: properties.color,
height: properties.orientation === 'vertical' ? '100%' : numeric(properties.thickness, 1),
width: properties.orientation === 'vertical' ? numeric(properties.thickness, 1) : '100%' }} />; break;
case 'spacer': child = <span>占位区</span>; break;
default: child = <span>{label}</span>;
}
return <div className={`menu-control menu-control--${control.type} ${selected ? 'is-selected' : ''}`}
style={style} title={properties.tooltip} onPointerDown={onPointerDown} onClick={(event) => { event.stopPropagation(); onSelect(); }}>
{child}<span className="menu-control__tag">{control.id}</span></div>;
}
function PropertyInput({ name, label, control, variables, update }: {
name: string; label: string; control: MenuControl; variables: TriggerVariableDefinition[];
update: (properties: Record<string, string>) => void;
}) {
const value = control.properties[name] ?? '';
return <Field label={label}><VariableTextInput parameter={name} value={value} variableCatalog={variables}
onValueChange={(next) => update({ ...control.properties, [name]: next })} /></Field>;
}
function MenusEditor({ workspace, catalog, triggerCatalog, reload }: {
workspace: MenuWorkspace; catalog: MenuCatalog; triggerCatalog?: TriggerCatalog; reload: () => void;
}) {
const { api, session } = useServer();
const canWrite = hasCapability(session?.actor, 'menu_write');
const [selectedId, setSelectedId] = useState(workspace.menus[0]?.id ?? 'new');
const [draft, setDraft] = useState<MenuDefinition>(() => workspace.menus[0] ?? emptyMenu());
const [selectedControlId, setSelectedControlId] = useState('');
const [busy, setBusy] = useState(false);
const [toast, setToast] = useState<{ message: string; tone: 'good' | 'danger' | 'warn' }>();
const [imagePicker, setImagePicker] = useState(false);
const [imageData, setImageData] = useState<Record<string, string>>({});
const canvasRef = useRef<HTMLDivElement>(null);
const latestDraft = useRef(draft); latestDraft.current = draft;
const variables = triggerCatalog?.variables ?? [];
const valid = menuIsValid(draft);
const selectedControl = draft.controls.find((item) => item.id === selectedControlId);
const dirtySource = selectedId === 'new' ? undefined : workspace.menus.find((item) => item.id === selectedId);
const dirty = selectedId === 'new' ? draft.controls.length > 0 || draft.name !== '新菜单'
: JSON.stringify(draft) !== JSON.stringify(dirtySource);
useEffect(() => {
if (selectedId === 'new') return;
const current = workspace.menus.find((item) => item.id === selectedId);
if (current) setDraft(current);
}, [selectedId, workspace]);
const choose = (menu: MenuDefinition) => {
setSelectedId(menu.id); setDraft(structuredClone(menu)); setSelectedControlId('');
};
const create = () => { setSelectedId('new'); setDraft(emptyMenu()); setSelectedControlId(''); };
const updateControl = (id: string, updater: (control: MenuControl) => MenuControl) => setDraft((current) => ({
...current, controls: current.controls.map((control) => control.id === id ? updater(control) : control),
}));
const addControl = (type: MenuControl['type']) => {
const next = createControl(type, draft.controls.length);
let suffix = 2;
while (draft.controls.some((item) => item.id === next.id)) next.id = `${type}_${draft.controls.length + suffix++}`;
next.x = Math.min(next.x, Math.max(0, draft.width - next.width));
next.y = Math.min(next.y, Math.max(0, draft.height - next.height));
setDraft((current) => ({ ...current, controls: [...current.controls, next] })); setSelectedControlId(next.id);
};
const removeControl = () => {
if (!selectedControl) return;
setDraft((current) => ({ ...current, controls: current.controls.filter((item) => item.id !== selectedControl.id) }));
setSelectedControlId('');
};
const duplicateControl = () => {
if (!selectedControl) return;
const copy = structuredClone(selectedControl); copy.id = `${selectedControl.id}_copy`;
let suffix = 2; while (draft.controls.some((item) => item.id === copy.id)) copy.id = `${selectedControl.id}_copy${suffix++}`;
copy.x = Math.min(draft.width - copy.width, copy.x + 8); copy.y = Math.min(draft.height - copy.height, copy.y + 8);
copy.zIndex = Math.max(0, ...draft.controls.map((item) => item.zIndex)) + 1;
setDraft((current) => ({ ...current, controls: [...current.controls, copy] })); setSelectedControlId(copy.id);
};
const beginMove = (event: PointerEvent<HTMLDivElement>, control: MenuControl) => {
if (!canWrite || !canvasRef.current) return;
event.preventDefault(); event.stopPropagation(); setSelectedControlId(control.id);
const rect = canvasRef.current.getBoundingClientRect(); const startX = event.clientX; const startY = event.clientY;
const originX = control.x; const originY = control.y;
const move = (next: globalThis.PointerEvent) => {
const logicalX = Math.round(originX + (next.clientX - startX) * latestDraft.current.width / rect.width);
const logicalY = Math.round(originY + (next.clientY - startY) * latestDraft.current.height / rect.height);
updateControl(control.id, (current) => ({ ...current,
x: Math.max(0, Math.min(latestDraft.current.width - current.width, logicalX)),
y: Math.max(0, Math.min(latestDraft.current.height - current.height, logicalY)) }));
};
const up = () => { globalThis.removeEventListener('pointermove', move); globalThis.removeEventListener('pointerup', up); };
globalThis.addEventListener('pointermove', move); globalThis.addEventListener('pointerup', up, { once: true });
};
useEffect(() => {
for (const control of draft.controls) {
const source = control.properties.source ?? control.properties.icon;
if (!source || imageData[source] !== undefined) continue;
void api.menuImagePreview(source)
.then((result) => setImageData((current) => ({ ...current, [source]: result.dataUrl })))
.catch(() => setImageData((current) => ({ ...current, [source]: '' })));
}
}, [api, draft.controls, imageData]);
const save = useCallback(async () => {
if (!canWrite || busy || !menuIsValid(draft)) return;
setBusy(true); setToast(undefined);
try {
const response = selectedId === 'new'
? await api.createMenu({ name: draft.name, description: draft.description, width: draft.width,
height: draft.height, popupPosition: draft.popupPosition, offsetX: draft.offsetX, offsetY: draft.offsetY,
pauseGame: draft.pauseGame, backgroundColor: draft.backgroundColor, controls: draft.controls })
: await api.updateMenu(draft);
setSelectedId(response.menu.id); setDraft(response.menu); reload();
setToast({ message: '菜单已保存并立即可供触发器使用', tone: 'good' });
} catch (cause) { setToast({ message: cause instanceof Error ? cause.message : String(cause), tone: 'danger' }); }
finally { setBusy(false); }
}, [api, busy, canWrite, draft, reload, selectedId]);
useEffect(() => {
const key = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') { event.preventDefault(); void save(); }
const target = event.target instanceof Element ? event.target : undefined;
const editing = target?.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])');
if ((event.key === 'Delete' || event.key === 'Backspace') && selectedControlId
&& !editing) {
event.preventDefault();
removeControl();
}
};
document.addEventListener('keydown', key); return () => document.removeEventListener('keydown', key);
});
const deleteMenu = async () => {
if (selectedId === 'new' || !canWrite || !confirm(`确定删除菜单“${draft.name}”吗?`)) return;
setBusy(true);
try { await api.deleteMenu(draft.id, draft.revision); create(); reload(); setToast({ message: '菜单已删除', tone: 'good' }); }
catch (cause) { setToast({ message: cause instanceof Error ? cause.message : String(cause), tone: 'danger' }); }
finally { setBusy(false); }
};
const updateSelected = (change: Partial<MenuControl>) => {
if (!selectedControl) return; updateControl(selectedControl.id, (current) => ({ ...current, ...change }));
};
const updateProperties = (properties: Record<string, string>) => updateSelected({ properties });
const addEvent = () => {
if (!selectedControl) return;
const available = catalog.controlEvents.find((event) => !selectedControl.events.some((item) => item.event === event))
?? controlEventDefault(selectedControl.type);
if (selectedControl.events.some((item) => item.event === available)) return;
updateSelected({ events: [...selectedControl.events, { event: available, triggerId: '', submenuId: '', closeMenu: true }] });
};
const updateEvent = (index: number, change: Partial<MenuControlEvent>) => {
if (!selectedControl) return;
updateSelected({ events: selectedControl.events.map((event, eventIndex) => eventIndex === index ? { ...event, ...change } : event) });
};
return <div className="menus-layout">
<aside className="menus-list panel">
<header><div><strong>菜单资源</strong><small>{workspace.totalMenus} 个</small></div>
{canWrite && <Button variant="secondary" onClick={create}><Plus size={14} />新建</Button>}</header>
<div>{workspace.menus.map((menu) => <button type="button" key={menu.id}
className={selectedId === menu.id ? 'is-active' : ''} onClick={() => choose(menu)}>
<span><strong>{menu.name}</strong><small>{menu.width}×{menu.height} · {menu.controls.length} 控件</small></span>
<Badge>{menu.revision}</Badge></button>)}
{workspace.menus.length === 0 && <EmptyState icon={<LayoutPanelTop />} title="尚未创建菜单" />}</div>
</aside>
<section className="menu-workbench">
<Panel className="menu-document-settings" title="菜单设置" action={<div className="page-header__actions">
{dirty && <Badge tone="warn">未保存</Badge>}
{selectedId !== 'new' && canWrite && <Button variant="danger" onClick={() => void deleteMenu()} disabled={busy}><Trash2 size={15} /></Button>}
<Button onClick={() => void save()} disabled={!canWrite || busy || !valid}><Save size={15} />{busy ? '保存中…' : '保存 Ctrl+S'}</Button>
</div>}>
{!valid && <div className="form-error" role="alert">请修正菜单尺寸、颜色、控件坐标、ID、变量或事件配置后再保存。</div>}
<div className="form-grid form-grid--4">
<Field label="菜单名称"><Input value={draft.name} maxLength={96} disabled={!canWrite}
onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></Field>
<Field label="宽度"><Input type="number" min={120} max={1920} value={draft.width} disabled={!canWrite}
onChange={(event) => setDraft({ ...draft, width: Number(event.target.value) })} /></Field>
<Field label="高度"><Input type="number" min={80} max={1080} value={draft.height} disabled={!canWrite}
onChange={(event) => setDraft({ ...draft, height: Number(event.target.value) })} /></Field>
<Field label="弹出位置"><Select value={draft.popupPosition} disabled={!canWrite}
onChange={(event) => setDraft({ ...draft, popupPosition: event.target.value as MenuDefinition['popupPosition'] })}>
{catalog.popupPositions.map((position) => <option key={position} value={position}>{positionLabels[position] ?? position}</option>)}</Select></Field>
<Field label="水平偏移"><Input type="number" min={-8192} max={8192} value={draft.offsetX} disabled={!canWrite}
onChange={(event) => setDraft({ ...draft, offsetX: Number(event.target.value) })} /></Field>
<Field label="垂直偏移"><Input type="number" min={-8192} max={8192} value={draft.offsetY} disabled={!canWrite}
onChange={(event) => setDraft({ ...draft, offsetY: Number(event.target.value) })} /></Field>
<Field label="背景色"><Input type="text" value={draft.backgroundColor} disabled={!canWrite}
onChange={(event) => setDraft({ ...draft, backgroundColor: event.target.value })} /></Field>
<label className="checkbox-row"><input type="checkbox" checked={draft.pauseGame} disabled={!canWrite}
onChange={(event) => setDraft({ ...draft, pauseGame: event.target.checked })} />单人游戏中暂停游戏</label>
</div>
<Field label="说明"><Textarea rows={2} maxLength={1024} value={draft.description} disabled={!canWrite}
onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></Field>
</Panel>
<div className="menu-builder-grid">
<Panel className="menu-canvas-panel" title="画布" action={<Badge tone="info">逻辑像素 {draft.width}×{draft.height}</Badge>}>
<div className="menu-control-palette">
{controlTypes.map((item) => { const Icon = item.icon; return <button type="button" key={item.type}
disabled={!canWrite} title={`${item.zh} / ${item.en}`} onClick={() => addControl(item.type)}>
<Icon size={15} /><span>{item.zh}</span></button>; })}
</div>
<div className="menu-canvas-scroll"><div ref={canvasRef} className="menu-canvas"
style={{ aspectRatio: `${draft.width}/${draft.height}`, background: draft.backgroundColor,
'--menu-width': draft.width, '--menu-height': draft.height } as CSSProperties}
onClick={() => setSelectedControlId('')}>
<div className="menu-canvas__grid" />
{draft.controls.map((control) => <ControlPreview key={control.id} control={{ ...control,
properties: { ...control.properties, __canvasWidth: String(draft.width), __canvasHeight: String(draft.height) } }}
selected={selectedControlId === control.id}
imageData={imageData[control.properties.source ?? control.properties.icon ?? '']}
onSelect={() => setSelectedControlId(control.id)} onPointerDown={(event) => beginMove(event, control)} />)}
{draft.controls.length === 0 && <div className="menu-canvas__empty"><ListPlus /><strong>添加控件开始设计</strong>
<span>控件可拖动,精确位置和尺寸可在右侧输入。</span></div>}
</div></div>
</Panel>
<Panel className="menu-inspector" title="控件属性" action={selectedControl && canWrite ? <div className="menu-inspector__actions">
<Button variant="ghost" onClick={duplicateControl}><CopyPlus size={14} /></Button>
<Button variant="danger" onClick={removeControl}><Trash2 size={14} /></Button></div> : undefined}>
{!selectedControl ? <EmptyState icon={<MousePointerClick />} title="选择一个控件" detail="在画布上点击控件以编辑属性和事件。" /> : <>
<div className="menu-inspector__heading"><Badge tone="info">{typeName(selectedControl.type)}</Badge><code>{selectedControl.id}</code></div>
<div className="form-grid form-grid--2">
<Field label="控件 ID"><Input value={selectedControl.id} disabled={!canWrite} maxLength={64}
onChange={(event) => { const previous = selectedControl.id; updateControl(previous, (control) => ({ ...control, id: event.target.value })); setSelectedControlId(event.target.value); }} /></Field>
<Field label="层级"><Input type="number" min={-1000} max={1000} value={selectedControl.zIndex} disabled={!canWrite}
onChange={(event) => updateSelected({ zIndex: Number(event.target.value) })} /></Field>
{(['x', 'y', 'width', 'height'] as const).map((field) => <Field key={field} label={field.toUpperCase()}><Input type="number" min={field === 'x' || field === 'y' ? 0 : 1}
value={selectedControl[field]} disabled={!canWrite} onChange={(event) => updateSelected({ [field]: Number(event.target.value) })} /></Field>)}
</div>
<Field label="显示条件(变量解析后 false/0/空则隐藏)"><VariableTextInput parameter="visibleWhen"
value={selectedControl.visibleWhen} variableCatalog={variables} disabled={!canWrite}
onValueChange={(value) => updateSelected({ visibleWhen: value })} /></Field>
{(selectedControl.type === 'text' || selectedControl.type === 'rich_text') && <RichTextEditor compact
value={selectedControl.properties.text ?? ''} onChange={(text) => updateProperties({ ...selectedControl.properties, text })}
variableCatalog={variables} />}
{['button', 'icon_button', 'progress', 'checkbox', 'slider'].includes(selectedControl.type)
&& <PropertyInput name="label" label="文字" control={selectedControl} variables={variables} update={updateProperties} />}
{['image', 'icon_button'].includes(selectedControl.type) && <Field label={selectedControl.type === 'image' ? '图片资源' : '按钮图标'}>
<div className="menu-resource-input"><VariableTextInput parameter="image" value={selectedControl.properties.source ?? selectedControl.properties.icon ?? ''}
variableCatalog={variables} disabled={!canWrite} onValueChange={(value) => updateProperties({ ...selectedControl.properties,
[selectedControl.type === 'image' ? 'source' : 'icon']: value })} />
<Button variant="secondary" onClick={() => setImagePicker(true)} disabled={!canWrite}><Search size={15} />搜索</Button></div></Field>}
{selectedControl.type === 'input' && <><PropertyInput name="placeholder" label="占位提示" control={selectedControl} variables={variables} update={updateProperties} />
<PropertyInput name="value" label="默认值" control={selectedControl} variables={variables} update={updateProperties} /></>}
{selectedControl.type === 'item_slot' && <><PropertyInput name="item" label="物品 ID" control={selectedControl} variables={variables} update={updateProperties} />
<PropertyInput name="count" label="数量" control={selectedControl} variables={variables} update={updateProperties} /></>}
{selectedControl.type === 'progress' && <><PropertyInput name="value" label="当前值" control={selectedControl} variables={variables} update={updateProperties} />
<PropertyInput name="max" label="最大值" control={selectedControl} variables={variables} update={updateProperties} /></>}
{selectedControl.type === 'slider' && <div className="form-grid form-grid--2">{['min', 'max', 'step', 'value'].map((name) =>
<PropertyInput key={name} name={name} label={name} control={selectedControl} variables={variables} update={updateProperties} />)}</div>}
{selectedControl.type === 'checkbox' && <PropertyInput name="checked" label="默认选中(true/false)" control={selectedControl} variables={variables} update={updateProperties} />}
<div className="form-grid form-grid--2">
{selectedControl.properties.textColor !== undefined && <PropertyInput name="textColor" label="文字颜色" control={selectedControl} variables={variables} update={updateProperties} />}
{selectedControl.properties.backgroundColor !== undefined && <PropertyInput name="backgroundColor" label="背景颜色" control={selectedControl} variables={variables} update={updateProperties} />}
{selectedControl.properties.color !== undefined && <PropertyInput name="color" label="强调颜色" control={selectedControl} variables={variables} update={updateProperties} />}
{selectedControl.properties.fontSize !== undefined && <PropertyInput name="fontSize" label="字号" control={selectedControl} variables={variables} update={updateProperties} />}
<PropertyInput name="opacity" label="透明度 0-1" control={selectedControl} variables={variables} update={updateProperties} />
<PropertyInput name="tooltip" label="悬停提示" control={selectedControl} variables={variables} update={updateProperties} />
</div>
<section className="menu-events"><header><div><strong>控件触发器</strong><small>事件值可在触发器中使用 {`{menu.value}`}</small></div>
<Button variant="secondary" onClick={addEvent} disabled={!canWrite || selectedControl.events.length >= catalog.controlEvents.length}><Plus size={14} />添加</Button></header>
{selectedControl.events.map((binding, index) => <div className="menu-event" key={`${binding.event}-${index}`}>
<div className="form-grid form-grid--2">
<Field label="事件"><Select value={binding.event} disabled={!canWrite} onChange={(event) => updateEvent(index, { event: event.target.value as MenuControlEvent['event'] })}>
{catalog.controlEvents.map((name) => <option key={name} value={name}>{eventLabels[name] ?? name}</option>)}</Select></Field>
<Field label="执行触发器"><Select value={binding.triggerId} disabled={!canWrite} onChange={(event) => updateEvent(index, { triggerId: event.target.value })}>
<option value="">广播 menu.control 事件</option>{catalog.triggers.map((trigger) => <option key={trigger.id} value={trigger.id}>{trigger.name}{trigger.enabled ? '' : '(已停用)'}</option>)}</Select></Field>
<Field label="打开子菜单"><Select value={binding.submenuId} disabled={!canWrite} onChange={(event) => updateEvent(index, { submenuId: event.target.value })}>
<option value="">不打开</option>{workspace.menus.filter((menu) => menu.id !== draft.id).map((menu) => <option key={menu.id} value={menu.id}>{menu.name}</option>)}</Select></Field>
<label className="checkbox-row"><input type="checkbox" checked={binding.closeMenu} disabled={!canWrite}
onChange={(event) => updateEvent(index, { closeMenu: event.target.checked })} />执行后关闭当前菜单</label>
</div><Button variant="ghost" disabled={!canWrite} onClick={() => updateSelected({ events: selectedControl.events.filter((_, item) => item !== index) })}><Trash2 size={14} />移除事件</Button>
</div>)}
{selectedControl.events.length === 0 && <p className="muted">该控件尚未绑定事件。</p>}
</section>
</>}
</Panel>
</div>
</section>
{imagePicker && selectedControl && <ImagePicker api={api}
value={selectedControl.properties.source ?? selectedControl.properties.icon ?? ''}
onClose={() => setImagePicker(false)} onSelect={(value) => updateProperties({ ...selectedControl.properties,
[selectedControl.type === 'image' ? 'source' : 'icon']: value })} />}
{toast && <Toast message={toast.message} tone={toast.tone} onClose={() => setToast(undefined)} />}
</div>;
}
export function MenusPage() {
const { api, session } = useServer();
const workspace = useApiResource(() => api.menuWorkspace(), [], ['menus-changed']);
const catalog = useApiResource(() => api.menuCatalog(), [], ['menus-changed', 'triggers-changed']);
const triggerCatalog = useApiResource(() => api.triggerCatalog(), [], ['triggers-changed']);
return <div className="page menus-page">
<PageHeader eyebrow="INTERACTIVE UI" title="UI 菜单" description="创建可定位的游戏内菜单、子菜单和交互控件,并将事件交给触发器执行。"
actions={<Badge tone={hasCapability(session?.actor, 'menu_write') ? 'good' : 'neutral'}>
{hasCapability(session?.actor, 'menu_write') ? '可编辑' : '只读'}</Badge>} />
<ResourceState loading={workspace.loading || catalog.loading} error={workspace.error ?? catalog.error}
onRetry={() => { workspace.reload(); catalog.reload(); }}>
{workspace.data && <MenusEditor workspace={workspace.data} catalog={catalog.data ?? fallbackCatalog}
triggerCatalog={triggerCatalog.data} reload={workspace.reload} />}
</ResourceState>
</div>;
}
import {
AlignCenter, Box, CheckSquare, CopyPlus, Frame, Image as ImageIcon, LayoutPanelTop,
ListPlus, MousePointerClick, PanelTop, Plus, Save, Search, SeparatorHorizontal,
SlidersHorizontal, Square, Trash2, Type, Upload, X,
} from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent } from 'react';
import { ModalPortal } from '../components/modal-portal';
import { RichTextEditor, richTextPlainText } from '../components/rich-text-editor';
import { templateTimeFormatsAreValid, VariableTextInput } from '../components/variable-catalog-picker';
import { Badge, Button, EmptyState, Field, Input, PageHeader, Panel, ResourceState, Select, Textarea, Toast } from '../components/ui';
import { useApiResource, useServer } from '../context/server-context';
import { hasCapability } from '../lib/permissions';
import type {
MenuCatalog, MenuControl, MenuControlEvent, MenuDefinition, MenuImageCatalogItem, MenuWorkspace,
TriggerCatalog, TriggerVariableDefinition,
} from '../types';
const controlTypes: Array<{ type: MenuControl['type']; zh: string; en: string; icon: typeof Type }> = [
{ type: 'text', zh: '文本', en: 'Text', icon: Type },
{ type: 'rich_text', zh: '富文本', en: 'Rich text', icon: Type },
{ type: 'button', zh: '按钮', en: 'Button', icon: MousePointerClick },
{ type: 'icon_button', zh: '图标按钮', en: 'Icon button', icon: Square },
{ type: 'image', zh: '图片', en: 'Image', icon: ImageIcon },
{ type: 'panel', zh: '面板', en: 'Panel', icon: PanelTop },
{ type: 'border', zh: '边框', en: 'Border', icon: Frame },
{ type: 'progress', zh: '进度条', en: 'Progress', icon: SlidersHorizontal },
{ type: 'input', zh: '输入框', en: 'Input', icon: Box },
{ type: 'checkbox', zh: '复选框', en: 'Checkbox', icon: CheckSquare },
{ type: 'slider', zh: '滑块', en: 'Slider', icon: SlidersHorizontal },
{ type: 'item_slot', zh: '物品槽', en: 'Item slot', icon: Box },
{ type: 'separator', zh: '分隔线', en: 'Separator', icon: SeparatorHorizontal },
{ type: 'spacer', zh: '占位区', en: 'Spacer', icon: AlignCenter },
];
const positionLabels: Record<MenuDefinition['popupPosition'], string> = {
center: '居中 / Center', top: '顶部 / Top', bottom: '底部 / Bottom', left: '左侧 / Left',
right: '右侧 / Right', top_left: '左上 / Top left', top_right: '右上 / Top right',
bottom_left: '左下 / Bottom left', bottom_right: '右下 / Bottom right', custom: '自定义偏移 / Custom',
};
const eventLabels: Record<MenuControlEvent['event'], string> = {
click: '点击 / Click', double_click: '双击 / Double click', change: '值变化 / Change',
submit: '提交 / Submit', toggle: '切换 / Toggle', hover: '悬停 / Hover',
focus: '获得焦点 / Focus', blur: '失去焦点 / Blur',
};
const fallbackCatalog: MenuCatalog = {
controlTypes: controlTypes.map((item) => item.type),
popupPositions: Object.keys(positionLabels) as MenuDefinition['popupPosition'][],
controlEvents: Object.keys(eventLabels) as MenuControlEvent['event'][],
triggers: [],
};
function nowIso(): string { return new Date().toISOString(); }
function emptyMenu(): MenuDefinition {
const now = nowIso();
return {
id: '', name: '新菜单', description: '', width: 360, height: 240, popupPosition: 'center',
offsetX: 0, offsetY: 0, pauseGame: false, backgroundColor: '#101713e8', controls: [], revision: 0,
createdBy: '', createdAt: now, updatedAt: now,
};
}
function defaultProperties(type: MenuControl['type']): Record<string, string> {
const common = { opacity: '1', tooltip: '' };
switch (type) {
case 'text': return { ...common, text: '文本', textColor: '#ffffff', fontSize: '14', align: 'left' };
case 'rich_text': return { ...common, text: '富文本', textColor: '#ffffff', fontSize: '14', align: 'left' };
case 'button': return { ...common, label: '按钮', backgroundColor: '#183427', textColor: '#71e6a1', disabled: 'false' };
case 'icon_button': return { ...common, label: '图标', icon: 'item:minecraft:compass', backgroundColor: '#183427', disabled: 'false' };
case 'image': return { ...common, source: 'item:minecraft:grass_block', fit: 'contain', alt: '图片' };
case 'panel': return { ...common, backgroundColor: '#132019e8', radius: '6' };
case 'border': return { ...common, color: '#315644', thickness: '1', radius: '6' };
case 'progress': return { ...common, value: '50', max: '100', label: '{menu.value}', color: '#46d98a', backgroundColor: '#16231c' };
case 'input': return { ...common, placeholder: '请输入…', value: '', maxLength: '256', textColor: '#ffffff', backgroundColor: '#0b100e' };
case 'checkbox': return { ...common, label: '选项', checked: 'false', color: '#46d98a' };
case 'slider': return { ...common, label: '数值', min: '0', max: '100', step: '1', value: '50', color: '#46d98a' };
case 'item_slot': return { ...common, item: 'minecraft:diamond', count: '1', label: '' };
case 'separator': return { ...common, orientation: 'horizontal', color: '#315644', thickness: '1' };
case 'spacer': return common;
}
}
function defaultGeometry(type: MenuControl['type']): [number, number] {
if (type === 'image' || type === 'item_slot' || type === 'icon_button') return [40, 40];
if (type === 'separator') return [120, 4];
if (type === 'checkbox') return [140, 24];
if (type === 'panel' || type === 'border') return [180, 100];
if (type === 'spacer') return [80, 24];
return [160, type === 'rich_text' ? 56 : 30];
}
function createControl(type: MenuControl['type'], count: number): MenuControl {
const [width, height] = defaultGeometry(type);
return {
id: `${type}_${count + 1}`, type, x: 16 + count * 6, y: 16 + count * 6, width, height,
zIndex: count, visibleWhen: '', properties: defaultProperties(type), events: [],
};
}
function typeName(type: MenuControl['type']): string {
const found = controlTypes.find((item) => item.type === type);
return found ? `${found.zh} / ${found.en}` : type;
}
function numeric(value: string | undefined, fallback: number): number {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function controlEventDefault(type: MenuControl['type']): MenuControlEvent['event'] {
if (type === 'input') return 'submit';
if (type === 'checkbox') return 'toggle';
if (type === 'slider') return 'change';
return 'click';
}
function displayControl(control: MenuControl): string {
return richTextPlainText(control.properties.text ?? control.properties.label ?? control.properties.alt ?? control.id);
}
function menuIsValid(menu: MenuDefinition): boolean {
if (!menu.name.trim() || menu.name.trim().length > 96 || menu.description.trim().length > 1024
|| !Number.isInteger(menu.width) || menu.width < 120 || menu.width > 1920
|| !Number.isInteger(menu.height) || menu.height < 80 || menu.height > 1080
|| !/^#[0-9a-f]{6}(?:[0-9a-f]{2})?$/i.test(menu.backgroundColor)
|| menu.controls.length > 128) return false;
const ids = new Set<string>();
return menu.controls.every((control) => {
if (!/^[A-Za-z_][A-Za-z0-9_.-]{0,63}$/.test(control.id) || control.id === '__xfesm_root__'
|| ids.has(control.id) || !Number.isInteger(control.x) || !Number.isInteger(control.y)
|| !Number.isInteger(control.width) || !Number.isInteger(control.height)
|| control.x < 0 || control.y < 0 || control.width < 1 || control.height < 1
|| control.x + control.width > menu.width || control.y + control.height > menu.height
|| control.visibleWhen.length > 512 || !templateTimeFormatsAreValid(control.visibleWhen)
|| Object.keys(control.properties).length > 48
|| Object.entries(control.properties).some(([key, value]) => !/^[A-Za-z][A-Za-z0-9_.-]{0,63}$/.test(key)
|| value.length > 16_384 || !templateTimeFormatsAreValid(value))) return false;
ids.add(control.id);
const events = new Set<MenuControlEvent['event']>();
return control.events.length <= 8 && control.events.every((event) => {
if (events.has(event.event) || (!event.triggerId && !event.submenuId && !event.closeMenu)) return false;
events.add(event.event); return true;
});
});
}
function ImageResourcePreview({ api, item }: {
api: ReturnType<typeof useServer>['api']; item: MenuImageCatalogItem;
}) {
const host = useRef<HTMLSpanElement>(null);
const [dataUrl, setDataUrl] = useState(item.dataUrl ?? '');
const [unavailable, setUnavailable] = useState(false);
useEffect(() => {
if (item.dataUrl) { setDataUrl(item.dataUrl); setUnavailable(false); return; }
let active = true;
let requested = false;
const load = () => {
if (requested) return;
requested = true;
void api.menuImagePreview(item.reference)
.then((result) => { if (active) setDataUrl(result.dataUrl); })
.catch(() => { if (active) setUnavailable(true); });
};
if (typeof IntersectionObserver === 'undefined' || !host.current) {
load();
return () => { active = false; };
}
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) { load(); observer.disconnect(); }
}, { rootMargin: '160px' });
observer.observe(host.current);
return () => { active = false; observer.disconnect(); };
}, [api, item.dataUrl, item.reference]);
return <span ref={host} className={`menu-image-picker__preview menu-image-picker__preview--${item.type}`}
title={unavailable ? '该资源没有可用的贴图预览' : undefined}>
{dataUrl ? <img src={dataUrl} alt="" /> : item.type === 'item' || item.type === 'block'
? <Box /> : <ImageIcon />}
</span>;
}
export function ImagePicker({ api, value, onSelect, onClose }: {
api: ReturnType<typeof useServer>['api']; value: string; onSelect: (value: string) => void; onClose: () => void;
}) {
const [query, setQuery] = useState('');
const [items, setItems] = useState<MenuImageCatalogItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [uploading, setUploading] = useState(false);
useEffect(() => {
let active = true;
const timer = globalThis.setTimeout(() => {
setLoading(true);
api.menuImages(query).then((result) => { if (active) { setItems(result); setError(''); } })
.catch((cause: unknown) => { if (active) setError(cause instanceof Error ? cause.message : String(cause)); })
.finally(() => { if (active) setLoading(false); });
}, 180);
return () => { active = false; globalThis.clearTimeout(timer); };
}, [api, query]);
const upload = async (file?: File) => {
if (!file) return;
if (!['image/png', 'image/jpeg'].includes(file.type) || file.size > 256 * 1024) {
setError('仅支持不超过 256 KiB 的 PNG/JPEG 图片。'); return;
}
setUploading(true);
try {
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result)); reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
const created = await api.uploadMenuImage(file.name, dataUrl);
onSelect(created.reference); onClose();
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
} finally { setUploading(false); }
};
return <ModalPortal><div className="modal-backdrop menu-image-backdrop" onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}><section className="menu-image-picker" role="dialog" aria-modal="true" aria-label="选择菜单图片">
<header><div><strong>图片资源 / Image resources</strong><small>搜索上传图片、原版物品/方块和已安装模组贴图</small></div>
<button type="button" onClick={onClose} aria-label="关闭"><X /></button></header>
<div className="menu-image-picker__toolbar">
<label className="menu-image-picker__search"><Search size={16} /><Input autoFocus type="search" value={query}
onChange={(event) => setQuery(event.target.value)} placeholder="名称、资源 ID、模组命名空间…" /></label>
<label className="button button--secondary menu-upload-button"><Upload size={15} />{uploading ? '上传中…' : '上传图片'}
<input type="file" accept="image/png,image/jpeg" disabled={uploading}
onChange={(event) => void upload(event.target.files?.[0])} /></label>
</div>
{error && <div className="form-error" role="alert">{error}</div>}
<div className="menu-image-picker__results">
{items.map((item) => <button type="button" key={item.reference}
className={value === item.reference ? 'is-selected' : ''} onClick={() => { onSelect(item.reference); onClose(); }}>
<ImageResourcePreview api={api} item={item} />
<span><strong>{item.name}</strong><code>{item.reference}</code><small>{item.source}</small></span>
</button>)}
{!loading && items.length === 0 && <EmptyState icon={<ImageIcon />} title="没有匹配图片" detail="可上传 PNG/JPEG,或换一个资源 ID 搜索。" />}
{loading && <div className="resource-state">正在搜索…</div>}
</div>
</section></div></ModalPortal>;
}
function ControlPreview({ control, selected, imageData, onPointerDown, onSelect }: {
control: MenuControl; selected: boolean; imageData?: string;
onPointerDown: (event: PointerEvent<HTMLDivElement>) => void; onSelect: () => void;
}) {
const properties = control.properties;
const canvasWidth = Number(control.properties.__canvasWidth) || 1;
const canvasHeight = Number(control.properties.__canvasHeight) || 1;
const opacity = Math.max(0, Math.min(1, numeric(properties.opacity, 1)));
const style: CSSProperties = {
left: `${control.x / canvasWidth * 100}%`, top: `${control.y / canvasHeight * 100}%`,
width: `${control.width / canvasWidth * 100}%`, height: `${control.height / canvasHeight * 100}%`,
zIndex: control.zIndex + 1000,
opacity, color: properties.textColor || properties.color, background: properties.backgroundColor,
fontSize: numeric(properties.fontSize, 14), borderRadius: numeric(properties.radius, 4),
};
const label = displayControl(control);
let child;
switch (control.type) {
case 'image': child = imageData ? <img src={imageData} alt={properties.alt ?? ''} />
: <><ImageIcon /><small>{properties.source}</small></>; break;
case 'icon_button': child = <>{imageData ? <img src={imageData} alt="" /> : <ImageIcon size={16} />}
<span>{properties.label}</span></>; break;
case 'border': child = <span className="menu-control__border" style={{ borderColor: properties.color,
borderWidth: numeric(properties.thickness, 1) }} />; break;
case 'progress': {
const maximum = Math.max(1, numeric(properties.max, 100));
const progress = Math.max(0, Math.min(100, numeric(properties.value, 0) / maximum * 100));
child = <><span className="menu-control__progress" style={{ width: `${progress}%`, background: properties.color }} />
<span>{properties.label || `${Math.round(progress)}%`}</span></>; break;
}
case 'input': child = <span className="menu-control__input">{properties.value || properties.placeholder}</span>; break;
case 'checkbox': child = <><span className={`menu-control__check ${properties.checked === 'true' ? 'is-checked' : ''}`}>✓</span>
<span>{properties.label}</span></>; break;
case 'slider': {
const min = numeric(properties.min, 0); const max = Math.max(min + 1, numeric(properties.max, 100));
const percent = Math.max(0, Math.min(100, (numeric(properties.value, min) - min) / (max - min) * 100));
child = <><span>{properties.label}</span><span className="menu-control__track"><i style={{ left: `${percent}%` }} /></span></>; break;
}
case 'item_slot': child = <><Box /><small>{properties.item}</small><b>{properties.count}</b></>; break;
case 'separator': child = <span className="menu-control__separator" style={{ background: properties.color,
height: properties.orientation === 'vertical' ? '100%' : numeric(properties.thickness, 1),
width: properties.orientation === 'vertical' ? numeric(properties.thickness, 1) : '100%' }} />; break;
case 'spacer': child = <span>占位区</span>; break;
default: child = <span>{label}</span>;
}
return <div className={`menu-control menu-control--${control.type} ${selected ? 'is-selected' : ''}`}
style={style} title={properties.tooltip} onPointerDown={onPointerDown} onClick={(event) => { event.stopPropagation(); onSelect(); }}>
{child}<span className="menu-control__tag">{control.id}</span></div>;
}
function PropertyInput({ name, label, control, variables, update }: {
name: string; label: string; control: MenuControl; variables: TriggerVariableDefinition[];
update: (properties: Record<string, string>) => void;
}) {
const value = control.properties[name] ?? '';
return <Field label={label}><VariableTextInput parameter={name} value={value} variableCatalog={variables}
onValueChange={(next) => update({ ...control.properties, [name]: next })} /></Field>;
}
function MenusEditor({ workspace, catalog, triggerCatalog, reload }: {
workspace: MenuWorkspace; catalog: MenuCatalog; triggerCatalog?: TriggerCatalog; reload: () => void;
}) {
const { api, session } = useServer();
const canWrite = hasCapability(session?.actor, 'menu_write');
const [selectedId, setSelectedId] = useState(workspace.menus[0]?.id ?? 'new');
const [draft, setDraft] = useState<MenuDefinition>(() => workspace.menus[0] ?? emptyMenu());
const [selectedControlId, setSelectedControlId] = useState('');
const [busy, setBusy] = useState(false);
const [toast, setToast] = useState<{ message: string; tone: 'good' | 'danger' | 'warn' }>();
const [imagePicker, setImagePicker] = useState(false);
const [imageData, setImageData] = useState<Record<string, string>>({});
const canvasRef = useRef<HTMLDivElement>(null);
const latestDraft = useRef(draft); latestDraft.current = draft;
const variables = triggerCatalog?.variables ?? [];
const valid = menuIsValid(draft);
const selectedControl = draft.controls.find((item) => item.id === selectedControlId);
const dirtySource = selectedId === 'new' ? undefined : workspace.menus.find((item) => item.id === selectedId);
const dirty = selectedId === 'new' ? draft.controls.length > 0 || draft.name !== '新菜单'
: JSON.stringify(draft) !== JSON.stringify(dirtySource);
useEffect(() => {
if (selectedId === 'new') return;
const current = workspace.menus.find((item) => item.id === selectedId);
if (current) setDraft(current);
}, [selectedId, workspace]);
const choose = (menu: MenuDefinition) => {
setSelectedId(menu.id); setDraft(structuredClone(menu)); setSelectedControlId('');
};
const create = () => { setSelectedId('new'); setDraft(emptyMenu()); setSelectedControlId(''); };
const updateControl = (id: string, updater: (control: MenuControl) => MenuControl) => setDraft((current) => ({
...current, controls: current.controls.map((control) => control.id === id ? updater(control) : control),
}));
const addControl = (type: MenuControl['type']) => {
const next = createControl(type, draft.controls.length);
let suffix = 2;
while (draft.controls.some((item) => item.id === next.id)) next.id = `${type}_${draft.controls.length + suffix++}`;
next.x = Math.min(next.x, Math.max(0, draft.width - next.width));
next.y = Math.min(next.y, Math.max(0, draft.height - next.height));
setDraft((current) => ({ ...current, controls: [...current.controls, next] })); setSelectedControlId(next.id);
};
const removeControl = () => {
if (!selectedControl) return;
setDraft((current) => ({ ...current, controls: current.controls.filter((item) => item.id !== selectedControl.id) }));
setSelectedControlId('');
};
const duplicateControl = () => {
if (!selectedControl) return;
const copy = structuredClone(selectedControl); copy.id = `${selectedControl.id}_copy`;
let suffix = 2; while (draft.controls.some((item) => item.id === copy.id)) copy.id = `${selectedControl.id}_copy${suffix++}`;
copy.x = Math.min(draft.width - copy.width, copy.x + 8); copy.y = Math.min(draft.height - copy.height, copy.y + 8);
copy.zIndex = Math.max(0, ...draft.controls.map((item) => item.zIndex)) + 1;
setDraft((current) => ({ ...current, controls: [...current.controls, copy] })); setSelectedControlId(copy.id);
};
const beginMove = (event: PointerEvent<HTMLDivElement>, control: MenuControl) => {
if (!canWrite || !canvasRef.current) return;
event.preventDefault(); event.stopPropagation(); setSelectedControlId(control.id);
const rect = canvasRef.current.getBoundingClientRect(); const startX = event.clientX; const startY = event.clientY;
const originX = control.x; const originY = control.y;
const move = (next: globalThis.PointerEvent) => {
const logicalX = Math.round(originX + (next.clientX - startX) * latestDraft.current.width / rect.width);
const logicalY = Math.round(originY + (next.clientY - startY) * latestDraft.current.height / rect.height);
updateControl(control.id, (current) => ({ ...current,
x: Math.max(0, Math.min(latestDraft.current.width - current.width, logicalX)),
y: Math.max(0, Math.min(latestDraft.current.height - current.height, logicalY)) }));
};
const up = () => { globalThis.removeEventListener('pointermove', move); globalThis.removeEventListener('pointerup', up); };
globalThis.addEventListener('pointermove', move); globalThis.addEventListener('pointerup', up, { once: true });
};
useEffect(() => {
for (const control of draft.controls) {
const source = control.properties.source ?? control.properties.icon;
if (!source || imageData[source] !== undefined) continue;
void api.menuImagePreview(source)
.then((result) => setImageData((current) => ({ ...current, [source]: result.dataUrl })))
.catch(() => setImageData((current) => ({ ...current, [source]: '' })));
}
}, [api, draft.controls, imageData]);
const save = useCallback(async () => {
if (!canWrite || busy || !menuIsValid(draft)) return;
setBusy(true); setToast(undefined);
try {
const response = selectedId === 'new'
? await api.createMenu({ name: draft.name, description: draft.description, width: draft.width,
height: draft.height, popupPosition: draft.popupPosition, offsetX: draft.offsetX, offsetY: draft.offsetY,
pauseGame: draft.pauseGame, backgroundColor: draft.backgroundColor, controls: draft.controls })
: await api.updateMenu(draft);
setSelectedId(response.menu.id); setDraft(response.menu); reload();
setToast({ message: '菜单已保存并立即可供触发器使用', tone: 'good' });
} catch (cause) { setToast({ message: cause instanceof Error ? cause.message : String(cause), tone: 'danger' }); }
finally { setBusy(false); }
}, [api, busy, canWrite, draft, reload, selectedId]);
useEffect(() => {
const key = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') { event.preventDefault(); void save(); }
const target = event.target instanceof Element ? event.target : undefined;
const editing = target?.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])');
if ((event.key === 'Delete' || event.key === 'Backspace') && selectedControlId
&& !editing) {
event.preventDefault();
removeControl();
}
};
document.addEventListener('keydown', key); return () => document.removeEventListener('keydown', key);
});
const deleteMenu = async () => {
if (selectedId === 'new' || !canWrite || !confirm(`确定删除菜单“${draft.name}”吗?`)) return;
setBusy(true);
try { await api.deleteMenu(draft.id, draft.revision); create(); reload(); setToast({ message: '菜单已删除', tone: 'good' }); }
catch (cause) { setToast({ message: cause instanceof Error ? cause.message : String(cause), tone: 'danger' }); }
finally { setBusy(false); }
};
const updateSelected = (change: Partial<MenuControl>) => {
if (!selectedControl) return; updateControl(selectedControl.id, (current) => ({ ...current, ...change }));
};
const updateProperties = (properties: Record<string, string>) => updateSelected({ properties });
const addEvent = () => {
if (!selectedControl) return;
const available = catalog.controlEvents.find((event) => !selectedControl.events.some((item) => item.event === event))
?? controlEventDefault(selectedControl.type);
if (selectedControl.events.some((item) => item.event === available)) return;
updateSelected({ events: [...selectedControl.events, { event: available, triggerId: '', submenuId: '', closeMenu: true }] });
};
const updateEvent = (index: number, change: Partial<MenuControlEvent>) => {
if (!selectedControl) return;
updateSelected({ events: selectedControl.events.map((event, eventIndex) => eventIndex === index ? { ...event, ...change } : event) });
};
return <div className="menus-layout">
<aside className="menus-list panel">
<header><div><strong>菜单资源</strong><small>{workspace.totalMenus} 个</small></div>
{canWrite && <Button variant="secondary" onClick={create}><Plus size={14} />新建</Button>}</header>
<div>{workspace.menus.map((menu) => <button type="button" key={menu.id}
className={selectedId === menu.id ? 'is-active' : ''} onClick={() => choose(menu)}>
<span><strong>{menu.name}</strong><small>{menu.width}×{menu.height} · {menu.controls.length} 控件</small></span>
<Badge>{menu.revision}</Badge></button>)}
{workspace.menus.length === 0 && <EmptyState icon={<LayoutPanelTop />} title="尚未创建菜单" />}</div>
</aside>
<section className="menu-workbench">
<Panel className="menu-document-settings" title="菜单设置" action={<div className="page-header__actions">
{dirty && <Badge tone="warn">未保存</Badge>}
{selectedId !== 'new' && canWrite && <Button variant="danger" onClick={() => void deleteMenu()} disabled={busy}><Trash2 size={15} /></Button>}
<Button onClick={() => void save()} disabled={!canWrite || busy || !valid}><Save size={15} />{busy ? '保存中…' : '保存 Ctrl+S'}</Button>
</div>}>
{!valid && <div className="form-error" role="alert">请修正菜单尺寸、颜色、控件坐标、ID、变量或事件配置后再保存。</div>}
<div className="form-grid form-grid--4">
<Field label="菜单名称"><Input value={draft.name} maxLength={96} disabled={!canWrite}
onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></Field>
<Field label="宽度"><Input type="number" min={120} max={1920} value={draft.width} disabled={!canWrite}
onChange={(event) => setDraft({ ...draft, width: Number(event.target.value) })} /></Field>
<Field label="高度"><Input type="number" min={80} max={1080} value={draft.height} disabled={!canWrite}
onChange={(event) => setDraft({ ...draft, height: Number(event.target.value) })} /></Field>
<Field label="弹出位置"><Select value={draft.popupPosition} disabled={!canWrite}
onChange={(event) => setDraft({ ...draft, popupPosition: event.target.value as MenuDefinition['popupPosition'] })}>
{catalog.popupPositions.map((position) => <option key={position} value={position}>{positionLabels[position] ?? position}</option>)}</Select></Field>
<Field label="水平偏移"><Input type="number" min={-8192} max={8192} value={draft.offsetX} disabled={!canWrite}
onChange={(event) => setDraft({ ...draft, offsetX: Number(event.target.value) })} /></Field>
<Field label="垂直偏移"><Input type="number" min={-8192} max={8192} value={draft.offsetY} disabled={!canWrite}
onChange={(event) => setDraft({ ...draft, offsetY: Number(event.target.value) })} /></Field>
<Field label="背景色"><Input type="text" value={draft.backgroundColor} disabled={!canWrite}
onChange={(event) => setDraft({ ...draft, backgroundColor: event.target.value })} /></Field>
<label className="checkbox-row"><input type="checkbox" checked={draft.pauseGame} disabled={!canWrite}
onChange={(event) => setDraft({ ...draft, pauseGame: event.target.checked })} />单人游戏中暂停游戏</label>
</div>
<Field label="说明"><Textarea rows={2} maxLength={1024} value={draft.description} disabled={!canWrite}
onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></Field>
</Panel>
<div className="menu-builder-grid">
<Panel className="menu-canvas-panel" title="画布" action={<Badge tone="info">逻辑像素 {draft.width}×{draft.height}</Badge>}>
<div className="menu-control-palette">
{controlTypes.map((item) => { const Icon = item.icon; return <button type="button" key={item.type}
disabled={!canWrite} title={`${item.zh} / ${item.en}`} onClick={() => addControl(item.type)}>
<Icon size={15} /><span>{item.zh}</span></button>; })}
</div>
<div className="menu-canvas-scroll"><div ref={canvasRef} className="menu-canvas"
style={{ aspectRatio: `${draft.width}/${draft.height}`, background: draft.backgroundColor,
'--menu-width': draft.width, '--menu-height': draft.height } as CSSProperties}
onClick={() => setSelectedControlId('')}>
<div className="menu-canvas__grid" />
{draft.controls.map((control) => <ControlPreview key={control.id} control={{ ...control,
properties: { ...control.properties, __canvasWidth: String(draft.width), __canvasHeight: String(draft.height) } }}
selected={selectedControlId === control.id}
imageData={imageData[control.properties.source ?? control.properties.icon ?? '']}
onSelect={() => setSelectedControlId(control.id)} onPointerDown={(event) => beginMove(event, control)} />)}
{draft.controls.length === 0 && <div className="menu-canvas__empty"><ListPlus /><strong>添加控件开始设计</strong>
<span>控件可拖动,精确位置和尺寸可在右侧输入。</span></div>}
</div></div>
</Panel>
<Panel className="menu-inspector" title="控件属性" action={selectedControl && canWrite ? <div className="menu-inspector__actions">
<Button variant="ghost" onClick={duplicateControl}><CopyPlus size={14} /></Button>
<Button variant="danger" onClick={removeControl}><Trash2 size={14} /></Button></div> : undefined}>
{!selectedControl ? <EmptyState icon={<MousePointerClick />} title="选择一个控件" detail="在画布上点击控件以编辑属性和事件。" /> : <>
<div className="menu-inspector__heading"><Badge tone="info">{typeName(selectedControl.type)}</Badge><code>{selectedControl.id}</code></div>
<div className="form-grid form-grid--2">
<Field label="控件 ID"><Input value={selectedControl.id} disabled={!canWrite} maxLength={64}
onChange={(event) => { const previous = selectedControl.id; updateControl(previous, (control) => ({ ...control, id: event.target.value })); setSelectedControlId(event.target.value); }} /></Field>
<Field label="层级"><Input type="number" min={-1000} max={1000} value={selectedControl.zIndex} disabled={!canWrite}
onChange={(event) => updateSelected({ zIndex: Number(event.target.value) })} /></Field>
{(['x', 'y', 'width', 'height'] as const).map((field) => <Field key={field} label={field.toUpperCase()}><Input type="number" min={field === 'x' || field === 'y' ? 0 : 1}
value={selectedControl[field]} disabled={!canWrite} onChange={(event) => updateSelected({ [field]: Number(event.target.value) })} /></Field>)}
</div>
<Field label="显示条件(变量解析后 false/0/空则隐藏)"><VariableTextInput parameter="visibleWhen"
value={selectedControl.visibleWhen} variableCatalog={variables} disabled={!canWrite}
onValueChange={(value) => updateSelected({ visibleWhen: value })} /></Field>
{(selectedControl.type === 'text' || selectedControl.type === 'rich_text') && <RichTextEditor compact
value={selectedControl.properties.text ?? ''} onChange={(text) => updateProperties({ ...selectedControl.properties, text })}
variableCatalog={variables} />}
{['button', 'icon_button', 'progress', 'checkbox', 'slider'].includes(selectedControl.type)
&& <PropertyInput name="label" label="文字" control={selectedControl} variables={variables} update={updateProperties} />}
{['image', 'icon_button'].includes(selectedControl.type) && <Field label={selectedControl.type === 'image' ? '图片资源' : '按钮图标'}>
<div className="menu-resource-input"><VariableTextInput parameter="image" value={selectedControl.properties.source ?? selectedControl.properties.icon ?? ''}
variableCatalog={variables} disabled={!canWrite} onValueChange={(value) => updateProperties({ ...selectedControl.properties,
[selectedControl.type === 'image' ? 'source' : 'icon']: value })} />
<Button variant="secondary" onClick={() => setImagePicker(true)} disabled={!canWrite}><Search size={15} />搜索</Button></div></Field>}
{selectedControl.type === 'input' && <><PropertyInput name="placeholder" label="占位提示" control={selectedControl} variables={variables} update={updateProperties} />
<PropertyInput name="value" label="默认值" control={selectedControl} variables={variables} update={updateProperties} /></>}
{selectedControl.type === 'item_slot' && <><PropertyInput name="item" label="物品 ID" control={selectedControl} variables={variables} update={updateProperties} />
<PropertyInput name="count" label="数量" control={selectedControl} variables={variables} update={updateProperties} /></>}
{selectedControl.type === 'progress' && <><PropertyInput name="value" label="当前值" control={selectedControl} variables={variables} update={updateProperties} />
<PropertyInput name="max" label="最大值" control={selectedControl} variables={variables} update={updateProperties} /></>}
{selectedControl.type === 'slider' && <div className="form-grid form-grid--2">{['min', 'max', 'step', 'value'].map((name) =>
<PropertyInput key={name} name={name} label={name} control={selectedControl} variables={variables} update={updateProperties} />)}</div>}
{selectedControl.type === 'checkbox' && <PropertyInput name="checked" label="默认选中(true/false)" control={selectedControl} variables={variables} update={updateProperties} />}
<div className="form-grid form-grid--2">
{selectedControl.properties.textColor !== undefined && <PropertyInput name="textColor" label="文字颜色" control={selectedControl} variables={variables} update={updateProperties} />}
{selectedControl.properties.backgroundColor !== undefined && <PropertyInput name="backgroundColor" label="背景颜色" control={selectedControl} variables={variables} update={updateProperties} />}
{selectedControl.properties.color !== undefined && <PropertyInput name="color" label="强调颜色" control={selectedControl} variables={variables} update={updateProperties} />}
{selectedControl.properties.fontSize !== undefined && <PropertyInput name="fontSize" label="字号" control={selectedControl} variables={variables} update={updateProperties} />}
<PropertyInput name="opacity" label="透明度 0-1" control={selectedControl} variables={variables} update={updateProperties} />
<PropertyInput name="tooltip" label="悬停提示" control={selectedControl} variables={variables} update={updateProperties} />
</div>
<section className="menu-events"><header><div><strong>控件触发器</strong><small>事件值可在触发器中使用 {`{menu.value}`}</small></div>
<Button variant="secondary" onClick={addEvent} disabled={!canWrite || selectedControl.events.length >= catalog.controlEvents.length}><Plus size={14} />添加</Button></header>
{selectedControl.events.map((binding, index) => <div className="menu-event" key={`${binding.event}-${index}`}>
<div className="form-grid form-grid--2">
<Field label="事件"><Select value={binding.event} disabled={!canWrite} onChange={(event) => updateEvent(index, { event: event.target.value as MenuControlEvent['event'] })}>
{catalog.controlEvents.map((name) => <option key={name} value={name}>{eventLabels[name] ?? name}</option>)}</Select></Field>
<Field label="执行触发器"><Select value={binding.triggerId} disabled={!canWrite} onChange={(event) => updateEvent(index, { triggerId: event.target.value })}>
<option value="">广播 menu.control 事件</option>{catalog.triggers.map((trigger) => <option key={trigger.id} value={trigger.id}>{trigger.name}{trigger.enabled ? '' : '(已停用)'}</option>)}</Select></Field>
<Field label="打开子菜单"><Select value={binding.submenuId} disabled={!canWrite} onChange={(event) => updateEvent(index, { submenuId: event.target.value })}>
<option value="">不打开</option>{workspace.menus.filter((menu) => menu.id !== draft.id).map((menu) => <option key={menu.id} value={menu.id}>{menu.name}</option>)}</Select></Field>
<label className="checkbox-row"><input type="checkbox" checked={binding.closeMenu} disabled={!canWrite}
onChange={(event) => updateEvent(index, { closeMenu: event.target.checked })} />执行后关闭当前菜单</label>
</div><Button variant="ghost" disabled={!canWrite} onClick={() => updateSelected({ events: selectedControl.events.filter((_, item) => item !== index) })}><Trash2 size={14} />移除事件</Button>
</div>)}
{selectedControl.events.length === 0 && <p className="muted">该控件尚未绑定事件。</p>}
</section>
</>}
</Panel>
</div>
</section>
{imagePicker && selectedControl && <ImagePicker api={api}
value={selectedControl.properties.source ?? selectedControl.properties.icon ?? ''}
onClose={() => setImagePicker(false)} onSelect={(value) => updateProperties({ ...selectedControl.properties,
[selectedControl.type === 'image' ? 'source' : 'icon']: value })} />}
{toast && <Toast message={toast.message} tone={toast.tone} onClose={() => setToast(undefined)} />}
</div>;
}
export function MenusPage() {
const { api, session } = useServer();
const workspace = useApiResource(() => api.menuWorkspace(), [], ['menus-changed']);
const catalog = useApiResource(() => api.menuCatalog(), [], ['menus-changed', 'triggers-changed']);
const triggerCatalog = useApiResource(() => api.triggerCatalog(), [], ['triggers-changed']);
return <div className="page menus-page">
<PageHeader eyebrow="INTERACTIVE UI" title="UI 菜单" description="创建可定位的游戏内菜单、子菜单和交互控件,并将事件交给触发器执行。"
actions={<Badge tone={hasCapability(session?.actor, 'menu_write') ? 'good' : 'neutral'}>
{hasCapability(session?.actor, 'menu_write') ? '可编辑' : '只读'}</Badge>} />
<ResourceState loading={workspace.loading || catalog.loading} error={workspace.error ?? catalog.error}
onRetry={() => { workspace.reload(); catalog.reload(); }}>
{workspace.data && <MenusEditor workspace={workspace.data} catalog={catalog.data ?? fallbackCatalog}
triggerCatalog={triggerCatalog.data} reload={workspace.reload} />}
</ResourceState>
</div>;
}