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

XFEServerManager

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

公开
关注 0 Fork 0 Star 0
UTF-8
import { Blocks, CheckCheck, CopyPlus, Download, FileBox, Music2, Plus, RotateCcw, Save, Search, Trash2, Upload, X } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ModalPortal } from '../components/modal-portal';
import { Badge, Button, EmptyState, Field, Input, PageHeader, Panel, ResourceState, Select, Toast, formatBytes } from '../components/ui';
import { useApiResource, useServer } from '../context/server-context';
import { useI18n } from '../lib/i18n';
import { hasCapability } from '../lib/permissions';
import type { ContentArchetype, ContentAsset, ContentBehavior, ContentCatalog, ContentDefinition, ContentInput, ContentKind, ContentLocalized, ContentProperty, ContentValidation, ContentWorkspace, ContentWorkspaceView } from '../types';

const kindNames: Record<ContentKind, [string, string]> = { ITEM: ['物品', 'Items'], BLOCK: ['方块', 'Blocks'], ENTITY: ['生物', 'Entities'] };
const optionNames: Record<string, string> = {
  common: '普通', uncommon: '优秀', rare: '稀有', epic: '史诗',
  wood: '木质', wooden: '木质', stone: '石质', iron: '铁质', gold: '金质', golden: '金质', diamond: '钻石', netherite: '下界合金',
  leather: '皮革', chainmail: '锁链', turtle: '海龟',
  never: '从不', always: '总是', true: '是', false: '否',
};
const message = (cause: unknown) => cause instanceof Error ? cause.message : String(cause);
const localized = (entry: ContentLocalized, zh: boolean, description = false) => description
  ? (zh ? entry.descriptionZh : entry.descriptionEn) : (zh ? entry.nameZh : entry.nameEn);
const defaults = (properties: ContentProperty[]) => Object.fromEntries(properties.map((field) => [field.key, field.defaultValue]));
const toInput = (workspace: ContentWorkspace): ContentInput => ({
  expectedRevision: workspace.revision, enabled: workspace.enabled, definitions: workspace.definitions,
});
const eventSupports = (event: ContentCatalog['events'][number], definition: ContentDefinition) => event.kinds.includes(definition.kind)
  && (event.archetypes.length === 0 || event.archetypes.includes(definition.archetype));
const actionSupports = (action: ContentCatalog['actions'][number], definition: ContentDefinition, event: string) =>
  action.kinds.includes(definition.kind) && action.events.includes(event);
function compatibleValue(field: ContentProperty, value: string | undefined): string {
  if (value === undefined) return field.defaultValue;
  if (field.options.length && !field.options.includes(value)) return field.defaultValue;
  if (['int', 'integer', 'number'].includes(field.type)
    && (value.trim() === '' || !Number.isFinite(Number(value)) || (field.type !== 'number' && !Number.isInteger(Number(value)))
      || (field.min !== null && Number(value) < field.min) || (field.max !== null && Number(value) > field.max))) return field.defaultValue;
  return value;
}

function uniqueId(existing: ContentDefinition[], stem: string): string {
  const base = stem.replace(/^.*:/, '').replace(/[^a-z0-9_]/g, '_').slice(0, 48) || 'content';
  let number = 1;
  while (existing.some((item) => item.id === `xfesmcontent:${base}_${number}`)) number += 1;
  return `xfesmcontent:${base}_${number}`;
}

interface References {
  menus: Array<{ id: string; name: string }>;
  triggers: Array<{ id: string; name: string }>;
  assets: ContentAsset[];
}

function PropertyField({ field, value, onChange, zh, disabled, references, idPrefix }: {
  field: ContentProperty; value: string; onChange: (value: string) => void;
  zh: boolean; disabled: boolean; references: References; idPrefix: string;
}) {
  const name = localized(field, zh) || field.key;
  const source = field.type === 'menu_ref' ? references.menus : field.type === 'trigger_ref' ? references.triggers : undefined;
  const optionList = field.options ?? [];
  const listId = `${idPrefix}-${field.key}`;
  let control;
  if (source) {
    control = <Select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)}>
      <option value="">{zh ? '请选择已有对象' : 'Select an existing object'}</option>
      {value && !source.some((item) => item.id === value) && <option value={value}>{zh ? '未找到 / 无权读取:' : 'Missing / unavailable: '}{value}</option>}
      {source.map((item) => <option key={item.id} value={item.id}>{item.name} · {item.id.slice(0, 8)}</option>)}
    </Select>;
  } else if (field.type === 'boolean') {
    control = <Select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)}>
      <option value="true">{zh ? '是' : 'Yes'}</option><option value="false">{zh ? '否' : 'No'}</option>
    </Select>;
  } else if (optionList.length > 0 || field.type === 'select') {
    control = <Select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)}>
      {!optionList.includes(value) && <option value={value}>{value || (zh ? '请选择' : 'Select')}</option>}
      {optionList.map((option) => <option key={option} value={option}>{zh ? optionNames[option] ?? option : option}</option>)}
    </Select>;
  } else {
    const numeric = ['int', 'integer', 'number'].includes(field.type);
    control = <Input type={numeric ? 'number' : 'text'} value={value} disabled={disabled} maxLength={1024}
      min={numeric ? field.min ?? undefined : undefined} max={numeric ? field.max ?? undefined : undefined}
      step={numeric ? field.type === 'number' ? 'any' : '1' : undefined}
      list={field.type === 'resource' ? listId : undefined}
      onChange={(event) => onChange(event.target.value)} />;
  }
  return <div className="content-property"><Field label={<>{name}{field.requiresRestart && <small>{zh ? '重启生效' : 'Restart'}</small>}</>}
    hint={localized(field, zh, true)}>{control}</Field>
    {source?.length === 0 && <p className="content-help">{zh ? '请先在 UI 菜单或触发器页面创建对象,并确认读取权限。' : 'Create the menu or trigger first, and check read permissions.'}</p>}
    {field.type === 'resource' && <datalist id={listId}>{references.assets
      .filter((asset) => /sound|music/.test(field.key) ? asset.mimeType.startsWith('audio/') : asset.mimeType === 'image/png')
      .map((asset) => <option key={asset.id} value={asset.resourceLocation}>{asset.name}</option>)}</datalist>}
  </div>;
}

type Confirmation = { type: 'delete' | 'publish' | 'rollback' | 'reload' } | { type: 'archetype'; archetype: ContentArchetype };

export function ContentPage() {
  const { api, session } = useServer();
  const { locale } = useI18n();
  const zh = locale === 'zh-CN';
  const writable = session?.actor?.roles.includes('owner') === true;
  const resource = useApiResource(() => Promise.all([api.contentCatalog(), api.contentWorkspace()]), [api]);
  const related = useApiResource(async () => {
    const [menus, triggers, assets] = await Promise.allSettled([
      hasCapability(session?.actor, 'menu_read') ? api.menuWorkspace().then((value) => value.menus) : Promise.resolve([]),
      api.triggerWorkspace().then((value) => value.groups.flatMap((group) => group.triggers)), api.contentAssets(),
    ]);
    return {
      menus: menus.status === 'fulfilled' ? menus.value : [], triggers: triggers.status === 'fulfilled' ? triggers.value : [],
      assets: assets.status === 'fulfilled' ? assets.value : [],
      unavailable: [menus, triggers, assets].some((result) => result.status === 'rejected'),
    };
  }, [api, session?.actor?.id]);
  const catalog = resource.data?.[0];
  const [view, setView] = useState<ContentWorkspaceView>();
  const [draft, setDraft] = useState<ContentWorkspace>();
  const [dirty, setDirty] = useState(false);
  const [selectedIndex, setSelectedIndex] = useState(0);
  const [kind, setKind] = useState<ContentKind | 'ALL'>('ALL');
  const [query, setQuery] = useState('');
  const [newArchetype, setNewArchetype] = useState('');
  const [templateId, setTemplateId] = useState('');
  const [validation, setValidation] = useState<ContentValidation>();
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState('');
  const [notice, setNotice] = useState('');
  const [confirmation, setConfirmation] = useState<Confirmation>();
  const dialogRef = useRef<HTMLElement>(null);
  const [rollbackRevision, setRollbackRevision] = useState('');
  const [assets, setAssets] = useState<ContentAsset[]>([]);
  const references: References = { menus: related.data?.menus ?? [], triggers: related.data?.triggers ?? [], assets };
  const selected = draft?.definitions[selectedIndex];
  const archetype = catalog?.archetypes.find((item) => item.kind === selected?.kind && item.id === selected.archetype);
  const editable = writable && !busy;
  const candidateArchetypes = catalog?.archetypes.filter((item) => kind === 'ALL' || item.kind === kind) ?? [];
  const candidateArchetype = candidateArchetypes.find((item) => `${item.kind}:${item.id}` === newArchetype) ?? candidateArchetypes[0];
  const filtered = useMemo(() => draft?.definitions.map((item, index) => ({ item, index }))
    .filter(({ item }) => (kind === 'ALL' || item.kind === kind)
      && `${item.name} ${item.id} ${item.description}`.toLocaleLowerCase().includes(query.toLocaleLowerCase())) ?? [], [draft, kind, query]);

  useEffect(() => {
    if (!resource.data) return;
    setView((current) => current ?? resource.data![1]);
    setDraft((current) => current ?? resource.data![1].workspace);
  }, [resource.data]);
  useEffect(() => {
    if (!view?.update || !['SCHEDULED', 'ARMING', 'SAVING', 'RESTARTING'].includes(view.update.state)) return;
    let cancelled = false;
    let inFlight = false;
    const timer = window.setInterval(() => {
      if (inFlight) return;
      inFlight = true;
      void api.contentWorkspace().then((next) => {
        if (!cancelled) setView(next); // never replace the user's unsaved draft during a countdown
      }).catch(() => { /* a safe restart temporarily disconnects the control plane */ }).finally(() => { inFlight = false; });
    }, 3000);
    return () => { cancelled = true; window.clearInterval(timer); };
  }, [api, view?.update?.state]);
  useEffect(() => {
    if (related.data) setAssets((current) => Array.from(new Map([...related.data!.assets, ...current].map((asset) => [asset.id, asset])).values()));
  }, [related.data]);
  useEffect(() => {
    if (!dirty) return;
    const beforeUnload = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = ''; };
    window.addEventListener('beforeunload', beforeUnload);
    return () => window.removeEventListener('beforeunload', beforeUnload);
  }, [dirty]);
  useEffect(() => {
    if (!confirmation) return;
    const previous = document.activeElement;
    const dialog = dialogRef.current;
    const focusable = () => Array.from(dialog?.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), [tabindex="0"]') ?? []);
    focusable()[0]?.focus();
    const handle = (event: KeyboardEvent) => {
      if (event.key === 'Escape') { event.preventDefault(); setConfirmation(undefined); }
      if (event.key === 'Tab') {
        const elements = focusable(); const first = elements[0]; const last = elements.at(-1);
        if (event.shiftKey && (document.activeElement === first || !dialog?.contains(document.activeElement))) { event.preventDefault(); last?.focus(); }
        else if (!event.shiftKey && (document.activeElement === last || !dialog?.contains(document.activeElement))) { event.preventDefault(); first?.focus(); }
      }
    };
    document.addEventListener('keydown', handle);
    return () => { document.removeEventListener('keydown', handle); if (previous instanceof HTMLElement) previous.focus(); };
  }, [confirmation]);

  const edit = (next: ContentWorkspace) => { setDraft(next); setDirty(true); setValidation(undefined); setNotice(''); };
  const updateSelected = (next: ContentDefinition) => {
    if (draft) edit({ ...draft, definitions: draft.definitions.map((item, index) => index === selectedIndex ? next : item) });
  };
  const accept = (next: ContentWorkspaceView) => { setView(next); setDraft(next.workspace); setDirty(false); setValidation(undefined); };
  const perform = async (task: () => Promise<void>) => {
    if (busy) return;
    setBusy(true); setError(''); setNotice('');
    try { await task(); } catch (cause) { setError(message(cause)); } finally { setBusy(false); }
  };
  const save = useCallback(async () => {
    if (!draft || !writable || busy) return;
    setBusy(true); setError(''); setNotice('');
    try {
      const result = await api.validateContent(toInput(draft));
      setValidation(result);
      if (!result.valid) return;
      const saved = await api.saveContent(toInput(draft));
      setView(saved); setDraft(saved.workspace); setDirty(false);
      setNotice(zh ? '草稿已保存。发布、同步内容包并重启后才会在游戏中生效。' : 'Draft saved. Publish, sync the pack and restart to apply it in game.');
    } catch (cause) { setError(message(cause)); } finally { setBusy(false); }
  }, [api, draft, writable, busy, zh]);
  useEffect(() => {
    const handle = (event: KeyboardEvent) => {
      if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') { event.preventDefault(); if (!confirmation) void save(); }
    };
    document.addEventListener('keydown', handle);
    return () => document.removeEventListener('keydown', handle);
  }, [save, confirmation]);

  const add = (definition?: ContentDefinition) => {
    if (!draft || !editable || draft.definitions.length >= 256 || (!definition && !candidateArchetype)) return;
    const target = candidateArchetype;
    const created: ContentDefinition = definition ? structuredClone(definition) : {
      id: '', kind: target.kind, archetype: target.id, name: localized(target, zh), description: '',
      properties: defaults(target.properties), behaviors: [],
    };
    created.id = uniqueId(draft.definitions, definition?.id || created.kind.toLowerCase());
    created.behaviors = created.behaviors.map((behavior, index) => ({ ...behavior, id: `behavior_${index + 1}` }));
    edit({ ...draft, definitions: [...draft.definitions, created] });
    setSelectedIndex(draft.definitions.length); setKind('ALL'); setQuery('');
  };
  const updateBehavior = (index: number, value: ContentBehavior) => {
    if (selected) updateSelected({ ...selected, behaviors: selected.behaviors.map((item, current) => current === index ? value : item) });
  };
  const addBehavior = () => {
    if (!selected || !catalog || selected.behaviors.length >= 32) return;
    const event = catalog.events.find((item) => eventSupports(item, selected)
      && catalog.actions.some((action) => actionSupports(action, selected, item.id)));
    const action = catalog.actions.find((item) => event && actionSupports(item, selected, event.id));
    if (!event || !action) return;
    let id = 1; while (selected.behaviors.some((item) => item.id === `behavior_${id}`)) id += 1;
    updateSelected({ ...selected, behaviors: [...selected.behaviors, { id: `behavior_${id}`, event: event.id,
      action: action.id, parameters: defaults(action.parameters), cooldownTicks: 0, cancelVanilla: false }] });
  };
  const confirm = () => {
    if (!confirmation || !draft || busy || (!writable && confirmation.type !== 'reload')) return;
    const current = confirmation; setConfirmation(undefined);
    if (current.type === 'delete') {
      edit({ ...draft, definitions: draft.definitions.filter((_, index) => index !== selectedIndex) });
      setSelectedIndex(Math.max(0, selectedIndex - 1));
    } else if (current.type === 'archetype' && selected) {
      const next = current.archetype;
      updateSelected({ ...selected, archetype: next.id,
        properties: Object.fromEntries(next.properties.map((field) => [field.key, compatibleValue(field, selected.properties[field.key])])) });
    } else if (current.type === 'reload') {
      void perform(async () => { accept(await api.contentWorkspace()); setSelectedIndex(0); });
    } else if (current.type === 'publish') {
      void perform(async () => {
        const result = await api.validateContent(toInput(draft)); setValidation(result);
        if (!result.valid) return;
        const published = await api.publishContent(draft.revision);
        accept(published);
        setNotice(!draft.enabled
          ? (zh ? '已发布禁用草稿,不会注册内容。要启用新内容,请先开启完整内容模式,保存并重新发布。' : 'Published a disabled draft; no content will be registered. Enable full content mode, save and publish again to activate new content.')
          : published.update?.state === 'SCHEDULED'
            ? (zh ? '版本已发布,服务器将在 30 秒后保存并自动重启。客户端再次连接时会检查并下载当前生效版本。' : 'Published. The managed server will save and restart in 30 seconds. Clients check and download the active version when connecting.')
            : (zh ? '版本已发布。客户端会自动检查和下载;服务端尚未接管自动重启,请查看下方检测结果。' : 'Published. Clients check and download automatically; inspect restart detection below for server activation.'));
      });
    } else if (current.type === 'rollback') {
      void perform(async () => {
        accept(await api.rollbackContent(draft.revision, Number(rollbackRevision))); setSelectedIndex(0);
        setNotice(zh ? '已恢复历史修订。请核对发布状态;已发布的内容仍需重新同步并重启。' : 'Revision restored. Check publication status; published content still requires pack sync and restart.');
      });
    }
  };
  const download = () => perform(async () => {
    const exported = await api.exportContent();
    const bytes = Uint8Array.from(atob(exported.dataBase64), (character) => character.charCodeAt(0));
    const url = URL.createObjectURL(new Blob([bytes], { type: exported.mimeType }));
    const link = document.createElement('a'); link.href = url; link.download = exported.fileName; link.click();
    window.setTimeout(() => URL.revokeObjectURL(url), 1000);
    setNotice(`${zh ? '已导出修订' : 'Exported revision'} ${exported.revision} · SHA-256: ${exported.sha256}`);
  });
  const upload = (file?: File) => {
    if (!file || !editable) return;
    const mimeType = /\.ogg$/i.test(file.name) ? 'audio/ogg' : file.type;
    if (!['image/png', 'audio/ogg'].includes(mimeType) || file.size > 8 * 1024 * 1024) {
      setError(zh ? '仅支持不超过 8 MiB 的 PNG 贴图或 OGG Vorbis 音频。' : 'Only PNG textures or OGG Vorbis audio up to 8 MiB are supported.'); return;
    }
    void perform(async () => {
      const dataBase64 = await new Promise<string>((resolve, reject) => {
        const reader = new FileReader(); reader.onload = () => resolve(String(reader.result).split(',')[1]);
        reader.onerror = () => reject(reader.error); reader.readAsDataURL(file);
      });
      const asset = await api.uploadContentAsset(file.name, mimeType, dataBase64);
      setAssets((current) => [...current.filter((entry) => entry.id !== asset.id), asset]);
      setNotice(zh ? '素材已上传,可在贴图或声音字段选择。保存、发布并重启生效后,客户端连接时会获取新资源。' : 'Asset uploaded. Select it, save and publish; clients fetch the new resources after the server activates them on restart.');
    });
  };

  return <div className="page content-page">
    <PageHeader eyebrow="CONTENT STUDIO" title={zh ? '内容创作工作台' : 'Content studio'}
      description={zh ? '创建独立注册的物品、方块和生物;使用属性与事件行为组合玩法。' : 'Create registered items, blocks and entities; compose gameplay with properties and event behaviors.'}
      actions={<><Badge tone={writable ? 'info' : 'neutral'}>{writable ? 'owner' : zh ? '只读' : 'Read only'}</Badge>
        <Button variant="secondary" disabled={busy || !draft} onClick={() => void perform(async () => { if (draft) setValidation(await api.validateContent(toInput(draft))); })}><CheckCheck size={16} />{zh ? '校验' : 'Validate'}</Button>
        <Button disabled={!editable || !draft || !dirty} onClick={() => void save()}><Save size={16} />{zh ? '保存草稿' : 'Save draft'} <small>Ctrl+S</small></Button></>} />
    <ResourceState loading={resource.loading && !draft} error={resource.error} onRetry={resource.reload}>
      {catalog && draft && view && <>
        <section className="content-release-banner" aria-label={zh ? '发布状态' : 'Publication status'}>
          <FileBox size={25} /><div><strong>{!draft.enabled ? (zh ? '当前草稿未启用内容模式' : 'Content mode is disabled in this draft') : view.restartRequired ? (zh ? '待同步内容包并重启' : 'Pack sync and restart required') : (zh ? '完整内容模式需要配套客户端' : 'Full content requires the companion client')}</strong>
            <p>{zh ? '客户端连接前会从游戏端口检查并下载服务器当前生效的内容,无需管理员逐个分发 ZIP。首次需要玩家确认信任;注册项在下次启动时应用。受管进程支持自动重启,未接管时不会强制退出。' : 'Clients check and download active content through the game port before login, with first-use trust confirmation. Registries apply on next startup. Managed processes can restart automatically; unmanaged processes are never forced to exit.'}</p>
            {view.update && <p role="status"><strong>{zh ? '自动更新检测:' : 'Update detection: '}{view.update.managed ? (zh ? '支持受管重启' : 'Managed restart ready') : (zh ? '尚未接管重启' : 'Restart not managed')}</strong>{' · '}{view.update.reason}
              {view.update.state === 'SCHEDULED' && ` ${view.update.countdownSeconds} ${zh ? '秒' : 'seconds'}`}</p>}
            {!draft.enabled && <p>{zh ? '当前仅能发布禁用草稿,不注册内容。请先在下方“工作台与版本恢复”中开启完整内容模式,再保存和发布。' : 'Publishing this draft will not register content. Enable full content mode under “Workspace and revision recovery” below, then save and publish.'}</p>}
            <div className="content-revisions"><Badge>{zh ? '草稿' : 'Draft'} r{draft.revision}{dirty ? (zh ? ' · 未保存' : ' · Unsaved') : ''}</Badge>
              <Badge>{zh ? '已发布' : 'Published'} {view.publishedRevision == null ? '—' : `r${view.publishedRevision}`}</Badge>
              <Badge>{zh ? '本次启动已加载' : 'Loaded at startup'} {view.loadedRevision == null ? '—' : `r${view.loadedRevision}`}</Badge></div>
          </div><div className="content-release-actions"><Button variant="secondary" disabled={!editable || dirty} onClick={() => setConfirmation({ type: 'publish' })}>{zh ? '发布已保存版本' : 'Publish saved revision'}</Button>
            <Button variant="secondary" disabled={busy || view.publishedRevision == null} onClick={() => void download()}><Download size={16} />{zh ? '导出已发布安装包' : 'Export published pack'}</Button></div>
        </section>
        {!writable && <p className="content-help">{zh ? '仅 owner 可以修改、上传、发布或回滚内容;高风险操作由服务器要求近期双因素认证并写入审计。' : 'Only owners can edit, upload, publish or roll back content. The server requires recent two-factor authentication and audit logging.'}</p>}
        {error && <div className="content-feedback content-feedback--error" role="alert">{error}<p>{zh ? '你的未保存修改仍保留在页面中。修订冲突时请先保留修改,再重新读取工作台。' : 'Your unsaved edits remain here. On a revision conflict, preserve them before reloading the workspace.'}</p></div>}
        {validation && <section className={`content-feedback ${validation.valid ? '' : 'content-feedback--error'}`} aria-label={zh ? '校验结果' : 'Validation results'}>
          <strong>{validation.valid ? (zh ? '校验通过' : 'Validation passed') : (zh ? '校验未通过' : 'Validation failed')}</strong>
          {validation.errors.length > 0 && <ul>{validation.errors.map((item, index) => <li key={index}>{item}</li>)}</ul>}
          {validation.warnings.length > 0 && <ul>{validation.warnings.map((item, index) => <li key={index}>{item}</li>)}</ul>}
        </section>}
        <div className="content-workspace">
          <aside className="content-library panel">
            <div className="content-section-heading"><h2>{zh ? '内容库' : 'Library'}</h2><Badge>{draft.definitions.length}</Badge></div>
            <label className="content-search"><Search size={16} /><Input type="search" aria-label={zh ? '搜索内容' : 'Search content'} placeholder={zh ? '名称或注册 ID…' : 'Name or registry ID…'} value={query} onChange={(event) => setQuery(event.target.value)} /></label>
            <div className="content-kind-filter" role="group" aria-label={zh ? '内容分类' : 'Content categories'}>
              {(['ALL', 'ITEM', 'BLOCK', 'ENTITY'] as const).map((value) => <button key={value} type="button" aria-pressed={kind === value} onClick={() => setKind(value)}>{value === 'ALL' ? zh ? '全部' : 'All' : kindNames[value][zh ? 0 : 1]}</button>)}
            </div>
            <div className="content-list">{filtered.map(({ item, index }) => <button key={`${index}:${item.id}`} type="button" aria-pressed={selectedIndex === index} onClick={() => setSelectedIndex(index)}>
              <Blocks size={18} /><span><strong>{item.name || (zh ? '未命名' : 'Untitled')}</strong><small>{item.id}</small></span><Badge>{kindNames[item.kind][zh ? 0 : 1]}</Badge>
            </button>)}{filtered.length === 0 && <p className="content-help">{zh ? '没有匹配的内容。选择下面的类型或模板开始创建。' : 'No matching content. Pick a type or template below to create one.'}</p>}</div>
            <Field label={zh ? '新建类型' : 'New content type'}><Select disabled={!editable} value={candidateArchetype ? `${candidateArchetype.kind}:${candidateArchetype.id}` : ''} onChange={(event) => setNewArchetype(event.target.value)}>
              {candidateArchetypes.map((item) => <option key={`${item.kind}:${item.id}`} value={`${item.kind}:${item.id}`}>{kindNames[item.kind][zh ? 0 : 1]} · {localized(item, zh)}</option>)}
            </Select></Field>
            <Button variant="secondary" disabled={!editable || !candidateArchetype || draft.definitions.length >= 256} onClick={() => add()}><Plus size={16} />{zh ? '创建内容' : 'Create content'}</Button>
            {(catalog.templates?.length ?? 0) > 0 && <><Field label={zh ? '官方玩法模板' : 'Gameplay templates'}><Select value={templateId} disabled={!editable} onChange={(event) => setTemplateId(event.target.value)}>
              <option value="">{zh ? '选择模板…' : 'Select a template…'}</option>{catalog.templates.map((item) => <option key={item.id} value={item.id}>{localized(item, zh)}</option>)}</Select></Field>
              {templateId && <p className="content-help">{localized(catalog.templates.find((item) => item.id === templateId)!, zh, true)}</p>}
              <Button variant="secondary" disabled={!editable || !templateId || draft.definitions.length >= 256} onClick={() => { const template = catalog.templates.find((item) => item.id === templateId); if (template) add(template.definition); }}>{zh ? '从模板创建' : 'Create from template'}</Button></>}
            {draft.definitions.length >= 256 && <p className="content-help">{zh ? '已达到 256 个内容定义的上限。' : 'The 256-definition limit has been reached.'}</p>}
          </aside>
          <section className="content-editor" aria-label={zh ? '内容编辑器' : 'Content editor'}>
            {selected ? <>
              <Panel title={selected.name || (zh ? '未命名内容' : 'Untitled content')} action={<div className="button-row">
                <Button variant="secondary" disabled={!editable || draft.definitions.length >= 256} onClick={() => add({ ...selected, name: `${selected.name.slice(0, 90)}${zh ? ' 副本' : ' copy'}` })}><CopyPlus size={16} />{zh ? '复制内容' : 'Duplicate'}</Button>
                <Button variant="danger" disabled={!editable} onClick={() => setConfirmation({ type: 'delete' })}><Trash2 size={16} />{zh ? '删除内容' : 'Delete'}</Button></div>}>
                <div className="content-fields"><Field label={zh ? '显示名称' : 'Display name'}><Input value={selected.name} maxLength={96} disabled={!editable} onChange={(event) => updateSelected({ ...selected, name: event.target.value })} /></Field>
                  <Field label={zh ? '注册 ID' : 'Registry ID'} hint={zh ? 'xfesmcontent:小写名称。已发布的 ID、类型与原型不可移除或更换;违规草稿会被拒绝发布,以保护世界存档。' : 'xfesmcontent:lowercase_name. Published IDs, kinds and archetypes cannot be removed or replaced; incompatible drafts are rejected at publication to protect saved worlds.'}><Input value={selected.id} maxLength={80} disabled={!editable} onChange={(event) => updateSelected({ ...selected, id: event.target.value })} /></Field>
                  <Field label={zh ? '内容原型' : 'Archetype'} hint={archetype ? localized(archetype, zh, true) : (zh ? '当前服务器不支持此原型;请勿丢弃已有数据。' : 'This archetype is unavailable on the server; existing data is preserved.')}>
                    <Select value={selected.archetype} disabled={!editable} onChange={(event) => { const next = catalog.archetypes.find((item) => item.kind === selected.kind && item.id === event.target.value); if (next) setConfirmation({ type: 'archetype', archetype: next }); }}>
                      {!archetype && <option value={selected.archetype}>{selected.archetype}</option>}{catalog.archetypes.filter((item) => item.kind === selected.kind).map((item) => <option key={item.id} value={item.id}>{localized(item, zh)}</option>)}
                    </Select></Field>
                  <Field label={zh ? '说明' : 'Description'}><Input maxLength={2048} value={selected.description} disabled={!editable} onChange={(event) => updateSelected({ ...selected, description: event.target.value })} /></Field>
                </div>
              </Panel>
              {archetype && <Panel title={zh ? '类型属性' : 'Type properties'} action={<Badge>{zh ? '服务端能力目录' : 'Server capability catalog'}</Badge>}>
                <p className="content-help">{zh ? '下面仅列出当前平台已经实现的属性。值会在服务端再次校验;标记“重启生效”的字段不会立即修改在线世界。' : 'Only properties implemented on this platform are shown. Values are validated again on the server; restart-marked fields do not change the live world immediately.'}</p>
                <div className="content-fields">{archetype.properties.map((field) => <PropertyField key={field.key} field={field} value={selected.properties[field.key] ?? field.defaultValue}
                  onChange={(value) => updateSelected({ ...selected, properties: { ...selected.properties, [field.key]: value } })} zh={zh} disabled={!editable} references={references} idPrefix="content-property" />)}</div>
                {Object.keys(selected.properties).some((key) => !archetype.properties.some((field) => field.key === key)) && <details className="content-advanced"><summary>{zh ? '目录未识别的属性(保留,不丢弃)' : 'Unrecognized properties (preserved)'}</summary>
                  <pre>{JSON.stringify(Object.fromEntries(Object.entries(selected.properties).filter(([key]) => !archetype.properties.some((field) => field.key === key))), null, 2)}</pre></details>}
              </Panel>}
              <Panel title={zh ? '事件与自定义行为' : 'Events and custom behaviors'} action={<Button variant="secondary" disabled={!editable || selected.behaviors.length >= 32 || !catalog.events.some((event) => eventSupports(event, selected) && catalog.actions.some((action) => actionSupports(action, selected, event.id)))} onClick={addBehavior}><Plus size={16} />{zh ? '添加行为' : 'Add behavior'}</Button>}>
                <p className="content-help">{zh ? '从事件 → 动作组成行为,可绑定已有菜单或触发器。复杂条件、变量与多步骤流程在触发器中编辑;这里负责连接内容事件。冷却单位为 tick(20 tick ≈ 1 秒)。' : 'Bind events to actions, existing menus or triggers. Author conditions and multi-step logic in triggers, then connect them here. Cooldowns use ticks (20 ticks ≈ 1 second).'}</p>
                {selected.behaviors.length === 0 && <p className="content-empty-behavior">{zh ? '尚未绑定行为。可以添加右键开菜单、坐下、播放声音或调用触发器等已支持的动作。' : 'No behaviors yet. Add supported actions such as opening a menu, sitting, playing a sound or invoking a trigger.'}</p>}
                <div className="content-behaviors">{selected.behaviors.map((behavior, index) => {
                  const eventDescriptor = catalog.events.find((item) => item.id === behavior.event);
                  const actionDescriptor = catalog.actions.find((item) => item.id === behavior.action);
                  return <section className="content-behavior" key={behavior.id} aria-label={`${zh ? '行为' : 'Behavior'} ${index + 1}`}>
                    <header><Badge>{index + 1}</Badge><strong>{eventDescriptor ? localized(eventDescriptor, zh) : behavior.event} → {actionDescriptor ? localized(actionDescriptor, zh) : behavior.action}</strong>
                      <Button variant="ghost" aria-label={`${zh ? '删除行为' : 'Remove behavior'} ${index + 1}`} disabled={!editable} onClick={() => updateSelected({ ...selected, behaviors: selected.behaviors.filter((_, current) => current !== index) })}><Trash2 size={16} /></Button></header>
                    <div className="content-fields"><Field label={zh ? '触发事件' : 'Event'} hint={eventDescriptor && localized(eventDescriptor, zh, true)}><Select disabled={!editable} value={behavior.event} onChange={(event) => {
                      const id = event.target.value; const supported = catalog.actions.filter((item) => actionSupports(item, selected, id));
                      const nextAction = supported.find((item) => item.id === behavior.action) ?? supported[0];
                      const canCancel = catalog.events.find((item) => item.id === id)?.cancellable === true;
                      updateBehavior(index, { ...behavior, event: id, cancelVanilla: canCancel && behavior.cancelVanilla,
                        action: nextAction?.id ?? '', parameters: nextAction?.id === behavior.action ? behavior.parameters : defaults(nextAction?.parameters ?? []) });
                    }}>{!catalog.events.some((item) => item.id === behavior.event && eventSupports(item, selected)) && <option value={behavior.event}>{behavior.event} · {zh ? '当前原型不支持' : 'Unavailable for this archetype'}</option>}
                      {catalog.events.filter((item) => eventSupports(item, selected)).map((item) => <option key={item.id} value={item.id}>{localized(item, zh)}</option>)}</Select></Field>
                      <Field label={zh ? '执行动作' : 'Action'} hint={actionDescriptor && localized(actionDescriptor, zh, true)}><Select disabled={!editable} value={behavior.action} onChange={(event) => {
                        const next = catalog.actions.find((item) => item.id === event.target.value);
                        if (next) updateBehavior(index, { ...behavior, action: next.id, parameters: defaults(next.parameters) });
                      }}>{!catalog.actions.some((item) => item.id === behavior.action && actionSupports(item, selected, behavior.event)) && <option value={behavior.action}>{behavior.action} · {zh ? '当前类型不支持' : 'Unavailable for this type'}</option>}
                        {catalog.actions.filter((item) => actionSupports(item, selected, behavior.event)).map((item) => <option key={item.id} value={item.id}>{localized(item, zh)}</option>)}</Select></Field>
                      <Field label={zh ? '冷却时间(tick)' : 'Cooldown (ticks)'} hint={`${Number.isFinite(behavior.cooldownTicks) ? behavior.cooldownTicks / 20 : '—'} ${zh ? '秒(20 TPS 时)' : 'seconds at 20 TPS'}`}><Input type="number" min={0} max={1728000} step={1} value={behavior.cooldownTicks} disabled={!editable} onChange={(event) => updateBehavior(index, { ...behavior, cooldownTicks: Number(event.target.value) })} /></Field>
                      <Field label={zh ? '阻止原版交互' : 'Cancel vanilla interaction'} hint={eventDescriptor?.cancellable ? (zh ? '动作触发时阻止对应原版交互,避免同时打开容器或使用物品。' : 'Prevent the corresponding vanilla interaction when the action fires.') : (zh ? '此事件不能取消。' : 'This event cannot be cancelled.')}><Select value={String(behavior.cancelVanilla)} disabled={!editable || !eventDescriptor?.cancellable} onChange={(event) => updateBehavior(index, { ...behavior, cancelVanilla: event.target.value === 'true' })}><option value="false">{zh ? '否' : 'No'}</option><option value="true">{zh ? '是' : 'Yes'}</option></Select></Field>
                      {actionDescriptor?.parameters.map((field) => <PropertyField key={field.key} field={field} value={behavior.parameters[field.key] ?? field.defaultValue} zh={zh} disabled={!editable} references={references} idPrefix={`behavior-${index}`}
                        onChange={(value) => updateBehavior(index, { ...behavior, parameters: { ...behavior.parameters, [field.key]: value } })} />)}
                    </div>
                  </section>;
                })}</div>
              </Panel>
            </> : <Panel><EmptyState icon={<Blocks />} title={zh ? '创建你的第一个内容' : 'Create your first content'} detail={zh ? '在左侧选择物品、工具装备、生物原型,或从座椅、播放器模板开始。' : 'Choose an item, equipment, tool, entity archetype or a gameplay template on the left.'} /></Panel>}
            <Panel title={zh ? '资源素材' : 'Assets'} action={<label className={`button button--secondary content-upload${editable ? '' : ' is-disabled'}`}><Upload size={16} />{zh ? '上传 PNG / OGG' : 'Upload PNG / OGG'}
              <input aria-label={zh ? '上传素材文件' : 'Upload asset file'} type="file" accept="image/png,audio/ogg,.ogg" disabled={!editable} onChange={(event) => { upload(event.target.files?.[0]); event.target.value = ''; }} /></label>}>
              <p className="content-help">{zh ? '每个文件最多 8 MiB,PNG 最大 2048 × 2048,音频使用 OGG Vorbis;素材库合计最多 64 MiB / 256 个。上传后在贴图或声音字段选择资源引用;实体渲染能力以目录支持为准。上传不等于客户端已同步。' : 'Up to 8 MiB per file, PNG up to 2048 × 2048, OGG Vorbis audio; the library holds up to 64 MiB / 256 assets. Select uploaded references in texture or sound fields. Entity rendering depends on catalog support. Uploading does not sync clients.'}</p>
              {related.data?.unavailable && <p className="content-feedback content-feedback--error">{zh ? '部分菜单、触发器或素材目录读取失败;已有绑定仍被保留。' : 'Some menu, trigger or asset lists could not be loaded; existing bindings are preserved.'}</p>}
              <div className="content-assets">{assets.map((asset) => <div key={asset.id}>{asset.mimeType.startsWith('audio/') ? <Music2 size={18} /> : <FileBox size={18} />}<span><strong>{asset.name}</strong><code>{asset.resourceLocation}</code></span><small>{formatBytes(asset.bytes)}</small></div>)}</div>
            </Panel>
            <Panel title={zh ? '工作台与版本恢复' : 'Workspace and revision recovery'}>
              <div className="content-fields"><Field label={zh ? '启用完整内容模式' : 'Enable full content mode'} hint={zh ? '此开关属于草稿,需要保存并发布。一旦发布启用的内容包,服务端会禁止发布关闭状态或移除既有 ID;修改前仍建议备份世界。' : 'This draft setting requires saving and publishing. Once an enabled pack is published, disabling it or removing registered IDs is rejected. Back up your world before changes.'}><Select value={String(draft.enabled)} disabled={!editable} onChange={(event) => edit({ ...draft, enabled: event.target.value === 'true' })}><option value="false">{zh ? '未启用' : 'Disabled'}</option><option value="true">{zh ? '已启用' : 'Enabled'}</option></Select></Field>
                <Field label={zh ? '要恢复的历史修订号' : 'Revision to restore'} hint={zh ? '恢复会创建新修订,不会直接修改在线世界。请使用实际存在的修订号。' : 'Restoration creates a new revision; it does not directly change the live world. Use an existing revision number.'}><Input type="number" min={0} max={Math.max(0, draft.revision - 1)} step={1} disabled={!editable} value={rollbackRevision} onChange={(event) => setRollbackRevision(event.target.value)} /></Field></div>
              <div className="button-row"><Button variant="secondary" disabled={!editable || dirty || rollbackRevision === '' || !Number.isInteger(Number(rollbackRevision)) || Number(rollbackRevision) < 0 || Number(rollbackRevision) >= draft.revision} onClick={() => setConfirmation({ type: 'rollback' })}><RotateCcw size={16} />{zh ? '恢复历史修订' : 'Restore revision'}</Button>
                <Button variant="ghost" disabled={busy} onClick={() => setConfirmation({ type: 'reload' })}>{zh ? '重新读取工作台' : 'Reload workspace'}</Button></div>
            </Panel>
          </section>
        </div>
      </>}
    </ResourceState>
    {notice && <Toast message={notice} onClose={() => setNotice('')} />}
    {confirmation && <ModalPortal><div className="modal-backdrop"><section ref={dialogRef} className="modal content-confirm" role="dialog" aria-modal="true" aria-labelledby="content-confirm-title">
      <header><h2 id="content-confirm-title">{zh ? '确认内容变更' : 'Confirm content change'}</h2><Button variant="ghost" aria-label={zh ? '关闭确认' : 'Close confirmation'} onClick={() => setConfirmation(undefined)}><X size={18} /></Button></header>
      <p>{confirmation.type === 'delete' ? (zh ? `删除“${selected?.name}”的草稿定义?此操作不清理游戏对象。为了保护旧世界存档,已发布过的 ID 必须保留:如果删除的是已发布内容,后续发布会被服务器拒绝,需恢复该定义。` : `Delete the draft definition “${selected?.name}”? This does not remove game objects. To protect saved worlds, published IDs must be retained: deleting a published definition causes the server to reject publication until it is restored.`)
        : confirmation.type === 'archetype' ? (zh ? `切换到“${localized(confirmation.archetype, zh)}”?保留有效共有属性,超出新范围的属性恢复默认值,移除新原型不支持的属性。切换后请重新校验事件和动作绑定。` : `Switch to “${localized(confirmation.archetype, zh)}”? Valid shared properties are retained, out-of-range values use new defaults and unsupported properties are removed. Validate event and action bindings again afterwards.`)
        : confirmation.type === 'reload' ? (zh ? '重新读取服务器草稿?本页面所有未保存修改将被放弃。' : 'Reload the server draft? All unsaved edits on this page will be discarded.')
        : confirmation.type === 'rollback' ? (zh ? `将当前内容恢复为历史修订 r${rollbackRevision}?这会新增修订;发布和客户端同步状态请在完成后核对。` : `Restore revision r${rollbackRevision}? This creates a new revision. Check publication and client sync status afterwards.`)
        : (zh ? `发布修订 r${draft?.revision}(${draft?.definitions.length} 个内容,${draft?.enabled ? '已启用' : '未启用'})?服务器会校验近期双因素认证并审计。内容定义、行为和素材将公开供客户端在登录前下载,请勿包含秘密。${view?.update?.managed ? '发布后 30 秒将保存世界、数据库并自动重启服务器。' : '尚未接管自动重启;发布后服务器会保持运行,需通过现有启动方式重启。'}客户端获取的是服务器重启后实际生效的版本。` : `Publish revision r${draft?.revision} (${draft?.definitions.length} definitions, ${draft?.enabled ? 'enabled' : 'disabled'})? Recent two-factor authentication and auditing are required. Definitions, behaviors and assets will be public before login; do not include secrets. ${view?.update?.managed ? 'The server will save and restart automatically after 30 seconds.' : 'Restart is not managed; the server stays running until restarted through its existing launcher.'} Clients fetch the active server revision.`)}</p>
      {confirmation.type === 'publish' && !draft?.enabled && <p className="content-feedback content-feedback--error" role="alert">{zh ? '仅发布禁用草稿,不注册内容。即使已经创建物品、方块或生物,它们也不会在游戏中出现;要启用,请取消并先开启完整内容模式、保存后再发布。' : 'This only publishes a disabled draft; no content is registered. Created items, blocks and entities will not appear in game. Cancel, enable full content mode and save before publishing to activate them.'}</p>}
      <div className="button-row"><Button variant="secondary" onClick={() => setConfirmation(undefined)}>{zh ? '取消' : 'Cancel'}</Button><Button variant={confirmation.type === 'delete' ? 'danger' : 'primary'} disabled={busy || (!writable && confirmation.type !== 'reload')} onClick={confirm}>{zh ? '确认继续' : 'Confirm'}</Button></div>
    </section></div></ModalPortal>}
  </div>;
}