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

XFEServerManager

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

公开
关注 0 Fork 0 Star 0
UTF-8
import {
  useEffect,
  useMemo,
  useRef,
  useState,
  type InputHTMLAttributes,
  type MouseEvent,
} from 'react';
import { Braces, Clock3, Search, X } from 'lucide-react';
import { ModalPortal } from './modal-portal';
import {
  RICH_TEXT_VARIABLE_CATALOG,
  RICH_TEXT_VARIABLE_CATEGORY_LABELS,
  type RichTextVariableDefinition,
} from './rich-text-variables';
import { Input } from './ui';

export function richTextVariableExpression(key: string): string {
  const trimmed = key.trim();
  if (!trimmed) return '';
  if (trimmed.startsWith('{') && trimmed.endsWith('}')) return trimmed;
  return `{${trimmed.replace(/^\{+|\}+$/g, '')}}`;
}

export function variableInsertionExpression(item: RichTextVariableDefinition): string {
  const example = item.example?.trim() ?? '';
  return /^\{[^{}]+\}$/.test(example) ? example : richTextVariableExpression(item.key);
}

export function variableIdentity(key: string): string {
  return richTextVariableExpression(key).slice(1, -1);
}

const TIME_PATTERN_MAX_RUN: Readonly<Record<string, number>> = {
  u: 19, y: 19, M: 5, d: 2, H: 2, h: 2, m: 2, s: 2, S: 9,
  E: 5, e: 5, a: 1, X: 5, x: 5, Z: 5, z: 4,
};

/** Browser equivalent of TriggerTimeContext.validatePattern's bounded Java-time subset. */
function timePatternIsValid(pattern: string): boolean {
  if (!pattern.trim() || pattern.length > 64) return false;
  let quoted = false;
  let optionalDepth = 0;
  for (let index = 0; index < pattern.length; index += 1) {
    const value = pattern[index];
    const code = value.charCodeAt(0);
    if (code <= 0x1f || (code >= 0x7f && code <= 0x9f) || value === '{' || value === '}') return false;
    if (value === "'") {
      if (pattern[index + 1] === "'") index += 1;
      else quoted = !quoted;
      continue;
    }
    if (quoted) continue;
    if (value === '[') {
      optionalDepth += 1;
      continue;
    }
    if (value === ']') {
      if (optionalDepth === 0) return false;
      optionalDepth -= 1;
      continue;
    }
    if (value === '#') return false;
    if (!/[A-Za-z]/.test(value)) continue;
    if (value === 'Y' || value === 'D' || TIME_PATTERN_MAX_RUN[value] === undefined) return false;
    let runEnd = index + 1;
    while (pattern[runEnd] === value) runEnd += 1;
    if (runEnd - index > TIME_PATTERN_MAX_RUN[value]) return false;
    index = runEnd - 1;
  }
  return !quoted && optionalDepth === 0;
}

/** Mirrors TriggerEvaluator.validateTemplateVariables for both plain and encoded rich text. */
export function templateTimeFormatsAreValid(template: string): boolean {
  for (const prefix of ['{server.time:', '{event.time:']) {
    let offset = 0;
    while (true) {
      const start = template.indexOf(prefix, offset);
      if (start < 0) break;
      const close = template.indexOf('}', start + prefix.length);
      if (close < 0 || !timePatternIsValid(template.slice(start + prefix.length, close))) return false;
      offset = close + 1;
    }
  }
  return true;
}

function dynamicVariableDefinition(expression: string): RichTextVariableDefinition {
  const key = variableIdentity(expression);
  const commandArgument = key.startsWith('args.');
  return {
    key,
    nameZh: commandArgument ? `指令参数 ${key.slice(5)}` : `当前事件变量 ${key}`,
    nameEn: commandArgument ? `Command argument ${key.slice(5)}` : `Current event variable ${key}`,
    descriptionZh: commandArgument
      ? '来自当前玩家指令触发方式中声明并按空格解析的参数。'
      : '由当前触发事件提供的动态变量;仅在该事件具备对应值时可用。',
    descriptionEn: commandArgument
      ? 'Declared by the current player-command trigger and parsed as a space-separated argument.'
      : 'Dynamic variable supplied by the current trigger event when that context is available.',
    category: commandArgument ? 'command' : 'event',
    scopes: commandArgument ? ['player.command_trigger'] : ['current-event'],
    example: expression,
  };
}

/** Merge the offline catalog, an authoritative server catalog, and current-event variables. */
export function mergeRichTextVariableCatalog(
  catalog: readonly RichTextVariableDefinition[] = [],
  variables: readonly string[] = [],
): RichTextVariableDefinition[] {
  const merged = new Map<string, RichTextVariableDefinition>();
  const authoritativeCatalog = catalog.length > 0 ? catalog : RICH_TEXT_VARIABLE_CATALOG;
  for (const item of authoritativeCatalog) {
    if (item.sensitive === true || item.templateAllowed === false) continue;
    const identity = variableIdentity(item.key);
    if (!identity) continue;
    const previous = merged.get(identity);
    merged.set(identity, {
      ...previous,
      ...item,
      key: identity,
      scopes: item.scopes?.length ? item.scopes : previous?.scopes ?? ['global'],
    });
  }
  for (const expression of variables) {
    const identity = variableIdentity(expression);
    if (identity && !merged.has(identity)) merged.set(identity, dynamicVariableDefinition(expression));
  }
  return [...merged.values()];
}

const SCOPE_LABELS: Readonly<Record<string, string>> = {
  '*': '全部事件 / All events',
  'player.*': '所有玩家事件 / All player events',
  global: '全局 / Global',
  world: '世界上下文 / World context',
  'player-event': '玩家事件 / Player event',
  event: '事件上下文 / Event context',
  'event-specific': '特定事件 / Event specific',
  command: '指令上下文 / Command context',
  'current-event': '当前事件 / Current event',
  'player.command_trigger': '玩家指令 / Player command',
  schedule: '定时触发 / Schedule',
  'block-event': '方块事件 / Block event',
  'entity-event': '实体事件 / Entity event',
  'item-event': '物品事件 / Item event',
  'damage-event': '伤害事件 / Damage event',
};

const VARIABLE_TYPE_LABELS: Readonly<Record<string, string>> = {
  string: '文本 / String',
  number: '数值 / Number',
  boolean: '布尔值 / Boolean',
  instant: '时间点 / Instant',
  datetime: '日期时间 / Date & time',
};

function searchableVariableText(item: RichTextVariableDefinition): string {
  const category = RICH_TEXT_VARIABLE_CATEGORY_LABELS[item.category];
  return [item.key, richTextVariableExpression(item.key), item.nameZh, item.nameEn,
    item.descriptionZh, item.descriptionEn, category?.zh, category?.en,
    item.example, item.sampleValue, item.type, item.formatHint, ...item.scopes,
    ...(item.events ?? [])].filter(Boolean).join(' ').toLocaleLowerCase();
}

export function VariableCatalogDialog({ variables, onSelect, onClose, selectionHint }: {
  variables: readonly RichTextVariableDefinition[];
  onSelect: (variable: RichTextVariableDefinition) => void;
  onClose: () => void;
  selectionHint?: string;
}) {
  const [query, setQuery] = useState('');
  const [category, setCategory] = useState('all');
  const filteredVariables = useMemo(() => {
    const normalizedQuery = query.trim().toLocaleLowerCase();
    return variables.filter((item) => (
      (category === 'all' || item.category === category)
      && (!normalizedQuery || searchableVariableText(item).includes(normalizedQuery))
    ));
  }, [category, query, variables]);
  const categories = useMemo(() => [...new Set(variables.map((item) => item.category))], [variables]);
  const categoryCounts = useMemo(() => variables.reduce<Record<string, number>>((counts, item) => {
    counts[item.category] = (counts[item.category] ?? 0) + 1;
    return counts;
  }, {}), [variables]);

  useEffect(() => {
    const listener = (event: KeyboardEvent) => {
      if (event.key !== 'Escape') return;
      event.preventDefault();
      onClose();
    };
    document.addEventListener('keydown', listener);
    return () => document.removeEventListener('keydown', listener);
  }, [onClose]);

  return <ModalPortal>
    <div className="variable-browser-backdrop" onMouseDown={(event) => {
      if (event.target === event.currentTarget) onClose();
    }}>
      <section className="variable-browser" role="dialog" aria-modal="true"
        aria-label="插入变量 / Insert variable">
        <header className="variable-browser__header">
          <div><span><Braces size={15} />变量目录 / Variable catalog</span>
            <small>{variables.length} 个可插入变量 · {selectionHint ?? '点击任意条目插入到原光标或选区'}</small></div>
          <button type="button" aria-label="关闭变量目录 / Close variable catalog"
            onClick={onClose}><X size={18} /></button>
        </header>

        <div className="variable-browser__format-help" role="note">
          <Clock3 size={17} />
          <div><strong>服务器时间格式 / Server time format</strong>
            <p>推荐 <code>{'{server.time:uuuu-MM-dd HH:mm:ss}'}</code>。<code>MM</code> 是月份,
              <code>mm</code> 是分钟,<code>HH</code> 是 24 小时制;<code>YYYY</code> 是周历年,
              跨年附近会产生歧义且服务端不接受,请使用 <code>uuuu</code>(<code>yyyy</code> 也可用)。
              日期请用 <code>dd</code>;<code>DD</code> 是年内日序,同样不接受。</p>
            <small>Recommended pattern: uuuu-MM-dd HH:mm:ss. Pattern letters are case-sensitive.</small>
          </div>
        </div>

        <div className="variable-browser__workspace">
          <nav className="variable-browser__categories" aria-label="变量分类 / Variable categories">
            <strong>变量分类<small>Categories</small></strong>
            <button type="button" className={category === 'all' ? 'is-active' : ''}
              aria-current={category === 'all' ? 'page' : undefined} onClick={() => setCategory('all')}>
              <span>全部<small>All</small></span><em>{variables.length}</em>
            </button>
            {categories.map((item) => {
              const label = RICH_TEXT_VARIABLE_CATEGORY_LABELS[item];
              return <button type="button" key={item} className={category === item ? 'is-active' : ''}
                aria-current={category === item ? 'page' : undefined} onClick={() => setCategory(item)}>
                <span>{label?.zh ?? item}<small>{label?.en ?? item}</small></span>
                <em>{categoryCounts[item] ?? 0}</em>
              </button>;
            })}
          </nav>
          <div className="variable-browser__catalog">
            <div className="variable-browser__filters">
              <label className="variable-browser__search"><Search size={15} /><Input type="search" autoFocus
                aria-label="搜索变量 / Search variables" value={query}
                onChange={(event) => setQuery(event.target.value)}
                placeholder="搜索名称、代码或说明 / Search name, code, or description" /></label>
            </div>

            <div className="variable-browser__summary" aria-live="polite">
              显示 {filteredVariables.length} / {variables.length} · Showing {filteredVariables.length} of {variables.length}
            </div>
            <div className="variable-browser__results">
              {filteredVariables.map((item) => {
                const expression = variableInsertionExpression(item);
                const itemCategory = RICH_TEXT_VARIABLE_CATEGORY_LABELS[item.category];
                return <button type="button" className="variable-browser__item" key={variableIdentity(item.key)}
                  aria-label={`插入变量 ${expression}:${item.nameZh} / ${item.nameEn}`}
                  onClick={() => onSelect(item)}>
                  <span className="variable-browser__item-heading"><span><strong>{item.nameZh}</strong>
                    <small>{item.nameEn}</small></span><code>{expression}</code></span>
                  <span className="variable-browser__item-description">{item.descriptionZh}<small>{item.descriptionEn}</small></span>
                  <span className="variable-browser__item-meta">
                    <span>{itemCategory ? `${itemCategory.zh} / ${itemCategory.en}` : item.category}</span>
                    {item.scopes.map((scope) => <span key={scope}>{SCOPE_LABELS[scope] ?? scope}</span>)}
                    {item.events?.some((event) => event !== '*') && <span
                      title={item.events.join(', ')}>适用 / Events: {item.events.join(', ')}</span>}
                    {item.type && <span>类型 / Type: {VARIABLE_TYPE_LABELS[item.type] ?? item.type}</span>}
                    {item.sampleValue && <span>示例值 / Sample: <code>{item.sampleValue}</code></span>}
                  </span>
                  {item.formatHint && <span className="variable-browser__item-hint">格式提示 / Format: {item.formatHint}</span>}
                </button>;
              })}
              {filteredVariables.length === 0 && <div className="variable-browser__empty">
                <Search size={22} /><strong>没有匹配的变量 / No matching variables</strong>
                <span>请尝试名称、变量代码、用途或分类关键词。</span>
              </div>}
            </div>
          </div>
        </div>
      </section>
    </div>
  </ModalPortal>;
}

interface VariableTextInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value'> {
  value: string;
  onValueChange: (value: string) => void;
  parameter: string;
  variables?: readonly string[];
  variableCatalog?: readonly RichTextVariableDefinition[];
  zh?: boolean;
}

/** Plain text input with a variable catalog that inserts at the saved browser selection. */
export function VariableTextInput({
  value,
  onValueChange,
  parameter,
  variables = [],
  variableCatalog = [],
  zh = true,
  ...inputProps
}: VariableTextInputProps) {
  const [open, setOpen] = useState(false);
  const inputRef = useRef<HTMLInputElement>(null);
  const launcherRef = useRef<HTMLButtonElement>(null);
  const selection = useRef({ start: value.length, end: value.length });
  const availableVariables = useMemo(
    () => mergeRichTextVariableCatalog(variableCatalog, variables),
    [variableCatalog, variables],
  );

  const rememberSelection = () => {
    const input = inputRef.current;
    if (!input) return;
    selection.current = {
      start: input.selectionStart ?? value.length,
      end: input.selectionEnd ?? input.selectionStart ?? value.length,
    };
  };
  const close = () => {
    setOpen(false);
    globalThis.requestAnimationFrame?.(() => launcherRef.current?.focus());
  };
  const insert = (item: RichTextVariableDefinition) => {
    const start = Math.max(0, Math.min(selection.current.start, value.length));
    const end = Math.max(start, Math.min(selection.current.end, value.length));
    const expression = variableInsertionExpression(item);
    const next = `${value.slice(0, start)}${expression}${value.slice(end)}`;
    const caret = start + expression.length;
    selection.current = { start: caret, end: caret };
    onValueChange(next);
    setOpen(false);
    globalThis.requestAnimationFrame?.(() => {
      inputRef.current?.focus();
      inputRef.current?.setSelectionRange(caret, caret);
    });
  };

  const label = zh ? `为 ${parameter} 插入变量` : `Insert variable into ${parameter}`;
  return <div className="variable-text-input">
    <input {...inputProps} ref={inputRef} className={`input ${inputProps.className ?? ''}`}
      aria-label={inputProps['aria-label'] ?? parameter} value={value}
      onChange={(event) => {
        onValueChange(event.target.value);
        selection.current = {
          start: event.target.selectionStart ?? event.target.value.length,
          end: event.target.selectionEnd ?? event.target.selectionStart ?? event.target.value.length,
        };
      }}
      onSelect={(event) => {
        rememberSelection();
        inputProps.onSelect?.(event);
      }}
      onKeyUp={(event) => {
        rememberSelection();
        inputProps.onKeyUp?.(event);
      }} />
    <button ref={launcherRef} type="button" className="variable-text-input__button"
      aria-label={`${label} / ${zh ? `Insert variable into ${parameter}` : `为 ${parameter} 插入变量`}`}
      title={label} aria-haspopup="dialog" aria-expanded={open}
      onMouseDown={(event: MouseEvent<HTMLButtonElement>) => { rememberSelection(); event.preventDefault(); }}
      onClick={() => setOpen(true)}><Braces size={15} /><span>{zh ? '插入变量' : 'Variable'}</span></button>
    {open && <VariableCatalogDialog variables={availableVariables} onClose={close} onSelect={insert}
      selectionHint={zh ? `点击变量插入到 ${parameter} 的原光标或选区`
        : `Select a variable to insert it at the saved ${parameter} cursor or selection`} />}
  </div>;
}