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

XFEServerManager

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

公开
关注 0 Fork 0 Star 0
UTF-8
import { Box, ChevronDown, Copy, FolderPlus, Layers3, Plus, Shield, Trash2, Users } from 'lucide-react';
import type { CommandPolicyRule, PolicyCommandCatalogEntry, PolicyRuleTier, PolicySelectorSort, PolicySelectorType, PolicySourceKind } from '../types';
import { CommandCatalogPicker } from './command-catalog-picker';
import { Button, Field, Input, Select, Textarea } from './ui';

const tiers: PolicyRuleTier[] = ['DEFAULT', 'OP_LEVEL', 'ROLE', 'UUID', 'HARD_SAFETY'];
const roles = ['observer', 'helper', 'moderator', 'administrator', 'owner'];
const sources: PolicySourceKind[] = ['PLAYER', 'CONSOLE', 'COMMAND_BLOCK', 'FUNCTION', 'SCHEDULED_FUNCTION', 'WEB_CONSOLE', 'SYSTEM', 'UNKNOWN'];
const selectorTypes: Array<[PolicySelectorType, string]> = [
  ['SELF', '@s'], ['NEAREST_PLAYER', '@p'], ['RANDOM_PLAYER', '@r'], ['ALL_PLAYERS', '@a'],
  ['ALL_ENTITIES', '@e'], ['DIRECT', '直接名称/UUID'], ['UNKNOWN', '未知选择器'],
];
const selectorSorts: PolicySelectorSort[] = ['NEAREST', 'FURTHEST', 'RANDOM', 'ARBITRARY', 'UNSPECIFIED'];
const DEFAULT_GROUP_ID = 'default';
const DEFAULT_GROUP_NAME = '默认策略组 / Default policy group';

interface PolicyGroupView {
  id: string;
  name: string;
  enabled: boolean;
  rules: CommandPolicyRule[];
}

function strings(value: unknown): string[] {
  return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
}

function numberOrNull(value: unknown): number | null {
  return typeof value === 'number' && Number.isFinite(value) ? value : null;
}

export function newPolicyRule(index = 0, group: Pick<PolicyGroupView, 'id' | 'name' | 'enabled'> = {
  id: DEFAULT_GROUP_ID, name: DEFAULT_GROUP_NAME, enabled: true,
}): CommandPolicyRule {
  return {
    id: `rule-${Date.now().toString(36)}-${index + 1}`,
    enabled: true,
    groupId: group.id,
    groupName: group.name,
    groupEnabled: group.enabled,
    tier: 'DEFAULT',
    subject: { actorIds: [], roles: [], minimumOpLevel: null, maximumOpLevel: null, sources: [], onlineMode: null },
    command: { commandIds: [], pathPrefix: [] },
    effect: 'DENY',
    constraints: {
      selector: {
        deniedSelectorTypes: [], deniedSorts: [], allowBroadSelectorWithExplicitType: false,
        maximumTargets: null, maximumDistance: null, allowedEntityTypes: [], deniedEntityTypes: [],
        allowTagFilters: true, allowNbtFilters: true, allowPredicateFilters: true,
      },
      item: {
        allowedItemIds: [], deniedItemIds: [], allowedItemTags: [], deniedItemTags: [], maximumCount: null,
        allowedDataKeys: [], deniedDataKeys: [],
      },
      allowedFunctionIds: [], denyUnlistedFunctions: false,
    },
    reason: 'Administrative policy rule',
  };
}

export function normalizePolicyRule(raw: unknown, index: number): CommandPolicyRule {
  const base = newPolicyRule(index);
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return base;
  const value = raw as Record<string, unknown>;
  const subject = value.subject && typeof value.subject === 'object' ? value.subject as Record<string, unknown> : {};
  const command = value.command && typeof value.command === 'object' ? value.command as Record<string, unknown> : {};
  const constraints = value.constraints && typeof value.constraints === 'object' ? value.constraints as Record<string, unknown> : {};
  const selector = constraints.selector && typeof constraints.selector === 'object' ? constraints.selector as Record<string, unknown> : {};
  const item = constraints.item && typeof constraints.item === 'object' ? constraints.item as Record<string, unknown> : {};
  return {
    ...base,
    id: typeof value.id === 'string' && value.id.trim() ? value.id : base.id,
    enabled: value.enabled !== false,
    groupId: typeof value.groupId === 'string' && value.groupId.trim() ? value.groupId : DEFAULT_GROUP_ID,
    groupName: typeof value.groupName === 'string' && value.groupName.trim() ? value.groupName : DEFAULT_GROUP_NAME,
    groupEnabled: value.groupEnabled !== false,
    tier: tiers.includes(value.tier as PolicyRuleTier) ? value.tier as PolicyRuleTier : base.tier,
    effect: ['PASS_THROUGH', 'DENY', 'GRANT', 'CONSTRAIN'].includes(String(value.effect)) ? value.effect as CommandPolicyRule['effect'] : base.effect,
    reason: typeof value.reason === 'string' && value.reason.trim() ? value.reason : base.reason,
    subject: {
      actorIds: strings(subject.actorIds), roles: strings(subject.roles),
      minimumOpLevel: numberOrNull(subject.minimumOpLevel), maximumOpLevel: numberOrNull(subject.maximumOpLevel),
      sources: strings(subject.sources).filter((entry): entry is PolicySourceKind => sources.includes(entry as PolicySourceKind)),
      onlineMode: typeof subject.onlineMode === 'boolean' ? subject.onlineMode : null,
    },
    command: { commandIds: strings(command.commandIds), pathPrefix: strings(command.pathPrefix) },
    constraints: {
      selector: {
        deniedSelectorTypes: strings(selector.deniedSelectorTypes).filter((entry): entry is PolicySelectorType => selectorTypes.some(([value]) => value === entry)),
        deniedSorts: strings(selector.deniedSorts).filter((entry): entry is PolicySelectorSort => selectorSorts.includes(entry as PolicySelectorSort)),
        allowBroadSelectorWithExplicitType: selector.allowBroadSelectorWithExplicitType === true,
        maximumTargets: numberOrNull(selector.maximumTargets), maximumDistance: numberOrNull(selector.maximumDistance),
        allowedEntityTypes: strings(selector.allowedEntityTypes), deniedEntityTypes: strings(selector.deniedEntityTypes),
        allowTagFilters: selector.allowTagFilters !== false, allowNbtFilters: selector.allowNbtFilters !== false,
        allowPredicateFilters: selector.allowPredicateFilters !== false,
      },
      item: {
        allowedItemIds: strings(item.allowedItemIds), deniedItemIds: strings(item.deniedItemIds),
        allowedItemTags: strings(item.allowedItemTags), deniedItemTags: strings(item.deniedItemTags),
        maximumCount: numberOrNull(item.maximumCount), allowedDataKeys: strings(item.allowedDataKeys),
        deniedDataKeys: strings(item.deniedDataKeys),
      },
      allowedFunctionIds: strings(constraints.allowedFunctionIds),
      denyUnlistedFunctions: constraints.denyUnlistedFunctions === true,
    },
  };
}

interface PolicyRuleEditorProps {
  rules: CommandPolicyRule[];
  onChange: (rules: CommandPolicyRule[]) => void;
  commandCatalog?: PolicyCommandCatalogEntry[];
  commandCatalogLoading?: boolean;
  commandCatalogError?: string;
  onReloadCommandCatalog?: () => void;
}

export function PolicyRuleEditor({
  rules,
  onChange,
  commandCatalog = [],
  commandCatalogLoading = false,
  commandCatalogError,
  onReloadCommandCatalog = () => undefined,
}: PolicyRuleEditorProps) {
  const groups = policyGroups(rules);
  const commitGroups = (next: PolicyGroupView[]) => onChange(next.flatMap((group) => group.rules.map((rule) => ({
    ...rule, groupId: group.id, groupName: group.name, groupEnabled: group.enabled,
  }))));
  const updateGroup = (groupIndex: number, change: Partial<PolicyGroupView>) => commitGroups(
    groups.map((group, index) => index === groupIndex ? { ...group, ...change } : group),
  );
  const addGroup = () => {
    const id = `group-${Date.now().toString(36)}-${groups.length + 1}`;
    const group: PolicyGroupView = { id, name: `策略组 ${groups.length + 1} / Policy group ${groups.length + 1}`, enabled: true, rules: [] };
    group.rules = [newPolicyRule(rules.length, group)];
    commitGroups([...groups, group]);
  };
  return <div className="policy-rule-editor">
    <div className="policy-rule-editor__header"><div><strong>策略组 / Policy groups</strong><small>每组可独立启停并包含多条规则;发布时所有启用组会原子合并。同层冲突时 DENY 优先。</small></div><Button type="button" variant="secondary" onClick={addGroup}><FolderPlus size={15} />新增策略组 / Add group</Button></div>
    {groups.length === 0 && <div className="policy-rule-empty">当前草稿没有策略组。点击“新增策略组”开始配置。</div>}
    {groups.map((group, groupIndex) => <details className={`policy-group-card${group.enabled ? '' : ' policy-group-card--disabled'}`} key={groupIndex} open>
      <summary><Layers3 size={17} /><span><strong>{group.name}</strong><small>{group.id} · {group.rules.length} 条规则 · {group.enabled ? '已启用' : '已停用'}</small></span><ChevronDown className="policy-rule-card__chevron" /></summary>
      <div className="policy-group-card__body">
        <div className="form-grid"><Field label="策略组名称 / Group name"><Input maxLength={128} value={group.name} onChange={(event) => updateGroup(groupIndex, { name: event.target.value })} required /></Field><Field label="策略组 ID"><Input maxLength={64} pattern="[A-Za-z0-9._-]+" value={group.id} onChange={(event) => updateGroup(groupIndex, { id: event.target.value })} required /></Field></div>
        <div className="policy-rule-actions"><label className="toggle-row toggle-row--compact"><input type="checkbox" checked={group.enabled} onChange={(event) => updateGroup(groupIndex, { enabled: event.target.checked })} /><span><strong>启用整个策略组 / Enable group</strong><small>停用后保留规则,但不会参与命令决策</small></span></label><Button type="button" variant="ghost" onClick={() => {
          const copyId = `${group.id}-copy-${Date.now().toString(36)}`;
          const copied: PolicyGroupView = { ...structuredClone(group), id: copyId, name: `${group.name}(副本)`, rules: group.rules.map((rule, index) => ({ ...structuredClone(rule), id: `${rule.id}-copy-${index + 1}`, groupId: copyId })) };
          commitGroups([...groups.slice(0, groupIndex + 1), copied, ...groups.slice(groupIndex + 1)]);
        }}><Copy size={14} />复制策略组 / Duplicate</Button><Button type="button" variant="ghost" onClick={() => commitGroups(groups.filter((_, index) => index !== groupIndex))}><Trash2 size={14} />删除策略组 / Delete</Button></div>
        <div className="policy-group-card__rules-heading"><strong>组内规则 / Rules</strong><Button type="button" variant="secondary" onClick={() => updateGroup(groupIndex, { rules: [...group.rules, newPolicyRule(rules.length + group.rules.length, group)] })}><Plus size={15} />新增规则 / Add rule</Button></div>
        {group.rules.map((rule, ruleIndex) => <RuleCard key={`${rule.id}:${ruleIndex}`} rule={rule} index={ruleIndex}
          commandCatalog={commandCatalog} commandCatalogLoading={commandCatalogLoading}
          commandCatalogError={commandCatalogError} onReloadCommandCatalog={onReloadCommandCatalog}
          onChange={(next) => updateGroup(groupIndex, { rules: group.rules.map((item, index) => index === ruleIndex ? next : item) })}
          onDelete={() => {
            const nextRules = group.rules.filter((_, index) => index !== ruleIndex);
            if (nextRules.length === 0) commitGroups(groups.filter((_, index) => index !== groupIndex));
            else updateGroup(groupIndex, { rules: nextRules });
          }}
          onDuplicate={() => updateGroup(groupIndex, { rules: [...group.rules.slice(0, ruleIndex + 1), { ...structuredClone(rule), id: `${rule.id}-copy-${Date.now().toString(36)}` }, ...group.rules.slice(ruleIndex + 1)] })} />)}
      </div>
    </details>)}
    <details className="policy-json-preview"><summary>高级:只读 JSON 预览 / Advanced read-only JSON</summary><pre>{JSON.stringify(rules, null, 2)}</pre></details>
  </div>;
}

function policyGroups(rules: CommandPolicyRule[]): PolicyGroupView[] {
  const groups = new Map<string, PolicyGroupView>();
  for (const rule of rules) {
    const existing = groups.get(rule.groupId);
    if (existing) existing.rules.push(rule);
    else groups.set(rule.groupId, {
      id: rule.groupId,
      name: rule.groupName,
      enabled: rule.groupEnabled,
      rules: [rule],
    });
  }
  return [...groups.values()];
}

function RuleCard({
  rule,
  index,
  commandCatalog,
  commandCatalogLoading,
  commandCatalogError,
  onReloadCommandCatalog,
  onChange,
  onDelete,
  onDuplicate,
}: {
  rule: CommandPolicyRule;
  index: number;
  commandCatalog: PolicyCommandCatalogEntry[];
  commandCatalogLoading: boolean;
  commandCatalogError?: string;
  onReloadCommandCatalog: () => void;
  onChange: (rule: CommandPolicyRule) => void;
  onDelete: () => void;
  onDuplicate: () => void;
}) {
  const patch = (change: Partial<CommandPolicyRule>) => onChange({ ...rule, ...change });
  const subject = (change: Partial<CommandPolicyRule['subject']>) => patch({ subject: { ...rule.subject, ...change } });
  const command = (change: Partial<CommandPolicyRule['command']>) => patch({ command: { ...rule.command, ...change } });
  const selector = (change: Partial<CommandPolicyRule['constraints']['selector']>) => patch({ constraints: { ...rule.constraints, selector: { ...rule.constraints.selector, ...change } } });
  const item = (change: Partial<CommandPolicyRule['constraints']['item']>) => patch({ constraints: { ...rule.constraints, item: { ...rule.constraints.item, ...change } } });
  const constraints = (change: Partial<CommandPolicyRule['constraints']>) => patch({ constraints: { ...rule.constraints, ...change } });
  return <details className={`policy-rule-card${rule.enabled ? '' : ' policy-rule-card--disabled'}`} open>
    <summary><span className="policy-rule-card__index">{index + 1}</span><span><strong>{rule.id}</strong><small>{rule.tier} · {rule.effect} · {rule.enabled ? '已启用' : '已停用'}</small></span><ChevronDown className="policy-rule-card__chevron" /></summary>
    <div className="policy-rule-card__body">
      <div className="policy-rule-actions"><label className="toggle-row toggle-row--compact"><input type="checkbox" checked={rule.enabled} onChange={(event) => patch({ enabled: event.target.checked })} /><span><strong>启用规则</strong></span></label><Button type="button" variant="ghost" onClick={onDuplicate}><Copy size={14} />复制</Button><Button type="button" variant="ghost" onClick={onDelete}><Trash2 size={14} />删除</Button></div>
      <div className="form-grid form-grid--three"><Field label="规则 ID"><Input value={rule.id} onChange={(event) => patch({ id: event.target.value })} required pattern="[A-Za-z0-9._-]+" /></Field><Field label="优先层级"><Select value={rule.tier} onChange={(event) => patch({ tier: event.target.value as PolicyRuleTier })}>{tiers.map((tier) => <option key={tier}>{tier}</option>)}</Select></Field><Field label="决策效果"><Select value={rule.effect} onChange={(event) => patch({ effect: event.target.value as CommandPolicyRule['effect'] })}><option value="PASS_THROUGH">PASS_THROUGH · 交给原版</option><option value="DENY">DENY · 拒绝</option><option value="GRANT">GRANT · 最小权限授权</option><option value="CONSTRAIN">CONSTRAIN · 带约束允许</option></Select></Field></div>
      <Field label="规则理由 / Reason"><Input value={rule.reason} onChange={(event) => patch({ reason: event.target.value })} required maxLength={1000} /></Field>
      <section className="policy-rule-section"><h4><Users size={16} />适用主体 / Subject</h4><div className="form-grid form-grid--three"><ListField label="玩家 UUID(每行一个)" value={rule.subject.actorIds} onChange={(actorIds) => subject({ actorIds })} /><Field label="最低 OP"><NullableNumber value={rule.subject.minimumOpLevel} min={0} max={4} onChange={(minimumOpLevel) => subject({ minimumOpLevel })} /></Field><Field label="最高 OP"><NullableNumber value={rule.subject.maximumOpLevel} min={0} max={4} onChange={(maximumOpLevel) => subject({ maximumOpLevel })} /></Field></div><CheckboxSet label="角色(任一)" options={roles.map((value) => [value, value])} value={rule.subject.roles} onChange={(selected) => subject({ roles: selected })} /><CheckboxSet label="命令来源(任一)" options={sources.map((value) => [value, value])} value={rule.subject.sources} onChange={(selected) => subject({ sources: selected as PolicySourceKind[] })} /><Field label="正版验证状态"><Select value={rule.subject.onlineMode === null ? 'any' : String(rule.subject.onlineMode)} onChange={(event) => subject({ onlineMode: event.target.value === 'any' ? null : event.target.value === 'true' })}><option value="any">任意 / Any</option><option value="true">online-mode</option><option value="false">offline-mode</option></Select></Field></section>
      <section className="policy-rule-section">
        <div className="policy-command-heading">
          <div><h4><Shield size={16} />命令匹配 / Command</h4><small>可继续手工填写,也可从服务器与触发器实时命令目录中选择。</small></div>
          <CommandCatalogPicker commands={commandCatalog} selectedCommandIds={rule.command.commandIds}
            loading={commandCatalogLoading} error={commandCatalogError} onReload={onReloadCommandCatalog}
            onAdd={(commandId) => {
              if (!rule.command.commandIds.includes(commandId)) {
                command({ commandIds: [...rule.command.commandIds, commandId] });
              }
            }} />
        </div>
        <div className="form-grid"><ListField label="命令 ID(如 minecraft:kill)" value={rule.command.commandIds} onChange={(commandIds) => command({ commandIds })} /><ListField label="命令路径前缀(按文字顺序)" value={rule.command.pathPrefix} onChange={(pathPrefix) => command({ pathPrefix })} /></div>
      </section>
      {rule.effect === 'CONSTRAIN' && <>
        <section className="policy-rule-section"><h4><Users size={16} />选择器约束 / Selectors</h4><CheckboxSet label="禁止的选择器" options={selectorTypes} value={rule.constraints.selector.deniedSelectorTypes} onChange={(selected) => selector({ deniedSelectorTypes: selected as PolicySelectorType[] })} /><CheckboxSet label="禁止的排序" options={selectorSorts.map((value) => [value, value])} value={rule.constraints.selector.deniedSorts} onChange={(selected) => selector({ deniedSorts: selected as PolicySelectorSort[] })} /><label className="toggle-row"><input type="checkbox" checked={rule.constraints.selector.allowBroadSelectorWithExplicitType} onChange={(event) => selector({ allowBroadSelectorWithExplicitType: event.target.checked })} /><span><strong>带明确 type 时允许广域 @e/@a</strong><small>例如允许 @e[type=minecraft:zombie]</small></span></label><div className="form-grid"><Field label="最大目标数"><NullableNumber min={1} value={rule.constraints.selector.maximumTargets} onChange={(maximumTargets) => selector({ maximumTargets })} /></Field><Field label="最大距离"><NullableNumber min={0} step="any" value={rule.constraints.selector.maximumDistance} onChange={(maximumDistance) => selector({ maximumDistance })} /></Field><ListField label="允许实体类型" value={rule.constraints.selector.allowedEntityTypes} onChange={(allowedEntityTypes) => selector({ allowedEntityTypes })} /><ListField label="禁止实体类型" value={rule.constraints.selector.deniedEntityTypes} onChange={(deniedEntityTypes) => selector({ deniedEntityTypes })} /></div><div className="constraint-toggles"><Toggle label="允许 tag 过滤" checked={rule.constraints.selector.allowTagFilters} onChange={(allowTagFilters) => selector({ allowTagFilters })} /><Toggle label="允许 NBT 过滤" checked={rule.constraints.selector.allowNbtFilters} onChange={(allowNbtFilters) => selector({ allowNbtFilters })} /><Toggle label="允许 predicate 过滤" checked={rule.constraints.selector.allowPredicateFilters} onChange={(allowPredicateFilters) => selector({ allowPredicateFilters })} /></div></section>
        <section className="policy-rule-section"><h4><Box size={16} />物品与函数约束 / Items & functions</h4><div className="form-grid"><ListField label="允许物品 ID" value={rule.constraints.item.allowedItemIds} onChange={(allowedItemIds) => item({ allowedItemIds })} /><ListField label="禁止物品 ID" value={rule.constraints.item.deniedItemIds} onChange={(deniedItemIds) => item({ deniedItemIds })} /><ListField label="允许物品标签" value={rule.constraints.item.allowedItemTags} onChange={(allowedItemTags) => item({ allowedItemTags })} /><ListField label="禁止物品标签" value={rule.constraints.item.deniedItemTags} onChange={(deniedItemTags) => item({ deniedItemTags })} /><Field label="单次最大数量"><NullableNumber min={1} value={rule.constraints.item.maximumCount} onChange={(maximumCount) => item({ maximumCount })} /></Field><ListField label="允许 NBT/Data Component 键" value={rule.constraints.item.allowedDataKeys} onChange={(allowedDataKeys) => item({ allowedDataKeys })} /><ListField label="禁止 NBT/Data Component 键" value={rule.constraints.item.deniedDataKeys} onChange={(deniedDataKeys) => item({ deniedDataKeys })} /><ListField label="允许函数 ID" value={rule.constraints.allowedFunctionIds} onChange={(allowedFunctionIds) => constraints({ allowedFunctionIds })} /></div><Toggle label="拒绝未列出的 function / schedule function" checked={rule.constraints.denyUnlistedFunctions} onChange={(denyUnlistedFunctions) => constraints({ denyUnlistedFunctions })} /></section>
      </>}
    </div>
  </details>;
}

function parseLines(value: string): string[] {
  return value.split(/[\n,]+/).map((entry) => entry.trim()).filter(Boolean);
}

function ListField({ label, value, onChange }: { label: string; value: string[]; onChange: (value: string[]) => void }) {
  return <Field label={label}><Textarea rows={3} value={value.join('\n')} onChange={(event) => onChange(parseLines(event.target.value))} placeholder="留空 = 任意 / unrestricted" /></Field>;
}

function NullableNumber({ value, onChange, min, max, step }: { value: number | null; onChange: (value: number | null) => void; min?: number; max?: number; step?: string }) {
  return <Input type="number" value={value ?? ''} min={min} max={max} step={step} placeholder="不限" onChange={(event) => onChange(event.target.value === '' ? null : Number(event.target.value))} />;
}

function CheckboxSet({ label, options, value, onChange }: { label: string; options: Array<readonly [string, string]>; value: string[]; onChange: (value: string[]) => void }) {
  return <fieldset className="checkbox-set"><legend>{label}</legend><div>{options.map(([key, display]) => <label key={key}><input type="checkbox" checked={value.includes(key)} onChange={(event) => onChange(event.target.checked ? [...value, key] : value.filter((item) => item !== key))} /><span>{display}</span></label>)}</div></fieldset>;
}

function Toggle({ label, checked, onChange }: { label: string; checked: boolean; onChange: (checked: boolean) => void }) {
  return <label className="toggle-row toggle-row--compact"><input type="checkbox" checked={checked} onChange={(event) => onChange(event.target.checked)} /><span><strong>{label}</strong></span></label>;
}