XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEServerManager

【Java】我的世界XFE服务器管理器

公开
关注 0 Fork 0 Star 0
UTF-8
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>;
}