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>;
}
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>;
}