feat: rebuild Ref-compatible command editors

This commit is contained in:
2026-08-05 09:02:55 +00:00
parent f731f1f38e
commit 26045f8d63
16 changed files with 1815 additions and 923 deletions
@@ -1,376 +1,68 @@
<script setup lang="ts">
import { ref } from 'vue';
import CommandArgumentForm from './CommandArgumentForm.vue';
import CommandSelectForm from './CommandSelectForm.vue';
interface TurnCommandOption {
value: string | number;
label: string;
color?: string;
}
interface TurnCommandInputField {
key: string;
label: string;
kind: 'text' | 'number' | 'boolean' | 'select' | 'numberTuple' | 'hidden';
required: boolean;
min?: number;
max?: number;
step?: number;
constValue?: string | number;
options?: TurnCommandOption[];
optionSource?: 'cities' | 'nations' | 'generals' | 'crewTypes' | 'armTypes' | 'nationTypes' | 'colors' | 'items';
tupleLabels?: string[];
}
interface TurnCommandAvailability {
key: string;
name: string;
reqArg: boolean;
status: 'available' | 'blocked' | 'needsInput' | 'unknown';
possible: boolean;
reason?: string;
inputFields: TurnCommandInputField[];
}
interface TurnCommandGroup {
category: string;
values: TurnCommandAvailability[];
}
interface TurnCommandTable {
general: TurnCommandGroup[];
nation: TurnCommandGroup[];
inputOptions: {
cities: TurnCommandOption[];
nations: TurnCommandOption[];
generals: TurnCommandOption[];
crewTypes: TurnCommandOption[];
armTypes: TurnCommandOption[];
nationTypes: TurnCommandOption[];
colors: TurnCommandOption[];
items: Record<string, TurnCommandOption[]>;
};
}
interface SelectedCityInfo {
id: number;
name: string;
nationName: string;
regionName: string;
}
interface ReservedTurnEntry {
index: number;
action: string;
args: unknown;
}
interface GeneralInfo {
id: number;
nationId: number;
officerLevel: number;
}
import { computed } from 'vue';
import { addMinutes } from 'date-fns';
import ReservedCommandEditor from '../command/ReservedCommandEditor.vue';
import type { CommandPatternEntry, CommandTable, ReservedCommandRow } from '../command/types';
const props = defineProps<{
commandTable: TurnCommandTable | null;
commandTable: CommandTable | null;
loading: boolean;
selectedCity: SelectedCityInfo | null;
reservedGeneralTurns: ReservedTurnEntry[] | null;
reservedNationTurns: ReservedTurnEntry[] | null;
general: GeneralInfo | null;
reservedGeneralTurns: Array<{ index: number; action: string; args?: unknown }> | null;
general: { id: number; turnTime?: string } | null;
currentYear?: number;
currentMonth?: number;
turnTermMinutes?: number;
storageKey?: string;
}>();
const emit = defineEmits<{
(event: 'set-general-turn', payload: { index: number; action: string; args: Record<string, unknown> }): void;
(event: 'set-general-turns', entries: CommandPatternEntry[]): void;
(event: 'shift-general-turns', amount: number): void;
(event: 'set-nation-turn', payload: { index: number; action: string; args: Record<string, unknown> }): void;
(event: 'shift-nation-turns', amount: number): void;
(event: 'repeat-general-turns', amount: number): void;
}>();
const activeCategory = ref('');
const selectedCommand = ref<TurnCommandAvailability | null>(null);
const selectedScope = ref<'general' | 'nation' | null>(null);
const commandArgs = ref<Record<string, unknown>>({});
const commandArgsValid = ref(false);
const handleSelect = (commandKey: string) => {
if (!props.commandTable) {
selectedCommand.value = null;
selectedScope.value = null;
return;
const labelMap = computed(() => {
const result = new Map<string, string>([['휴식', '휴식']]);
for (const group of props.commandTable?.general ?? []) {
for (const command of group.values) result.set(command.key, command.name);
}
for (const scope of ['general', 'nation'] as const) {
for (const group of props.commandTable[scope]) {
const match = group.values.find((entry) => entry.key === commandKey);
if (match) {
selectedCommand.value = match;
selectedScope.value = scope;
commandArgs.value = {};
commandArgsValid.value = !match.reqArg;
return;
}
}
}
selectedCommand.value = null;
selectedScope.value = null;
};
return result;
});
const canReserveSelected = (scope: 'general' | 'nation') => {
if (!selectedCommand.value) {
return false;
}
if (selectedScope.value !== scope) return false;
if (!selectedCommand.value.possible) {
return false;
}
if (!['available', 'needsInput'].includes(selectedCommand.value.status)) {
return false;
}
return commandArgsValid.value;
};
const reserveGeneralTurn = (index: number) => {
if (!selectedCommand.value) {
return;
}
emit('set-general-turn', { index, action: selectedCommand.value.key, args: commandArgs.value });
};
const reserveNationTurn = (index: number) => {
if (!selectedCommand.value) {
return;
}
emit('set-nation-turn', { index, action: selectedCommand.value.key, args: commandArgs.value });
};
const clearGeneralTurn = (index: number) => {
emit('set-general-turn', { index, action: '휴식', args: {} });
};
const clearNationTurn = (index: number) => {
emit('set-nation-turn', { index, action: '휴식', args: {} });
};
const canNationReserve = () => Boolean(props.general && props.general.nationId > 0 && props.general.officerLevel >= 5);
const rows = computed<ReservedCommandRow[]>(() => {
const base = props.general?.turnTime ? new Date(props.general.turnTime) : null;
const term = props.turnTermMinutes ?? 0;
const baseYear = props.currentYear ?? 0;
const baseMonth = props.currentMonth ?? 1;
return (props.reservedGeneralTurns ?? []).map((turn, offset) => {
const absoluteMonth = baseYear * 12 + baseMonth - 1 + offset;
const date = base && Number.isFinite(base.getTime()) ? addMinutes(base, offset * term) : null;
return {
...turn,
args: turn.args ?? {},
label: labelMap.value.get(turn.action) ?? turn.action,
year: Math.floor(absoluteMonth / 12),
month: (absoluteMonth % 12) + 1,
time: date
? term >= 5
? `${String(date.getUTCHours()).padStart(2, '0')}:${String(date.getUTCMinutes()).padStart(2, '0')}`
: `${String(date.getUTCMinutes()).padStart(2, '0')}:${String(date.getUTCSeconds()).padStart(2, '0')}`
: '--:--',
};
});
});
</script>
<template>
<div class="command-panel">
<div class="command-selection">
<div class="label">선택 도시</div>
<div class="value">
<span v-if="props.selectedCity">
{{ props.selectedCity.name }} · {{ props.selectedCity.nationName }} ·
{{ props.selectedCity.regionName }}
</span>
<span v-else>선택된 도시 없음</span>
</div>
</div>
<details class="command-editor">
<summary>고급 모드로</summary>
<CommandSelectForm
:command-table="props.commandTable"
:loading="props.loading"
:active-category="activeCategory"
@update:active-category="activeCategory = $event"
@select="handleSelect"
/>
<div class="command-selected">
<div class="label">선택 명령</div>
<div v-if="selectedCommand" class="value">
<div class="name">{{ selectedCommand.name }}</div>
<div class="meta">
<span>{{ selectedCommand.status === 'available' ? '가능' : '제한' }}</span>
<span v-if="selectedCommand.reqArg">추가 입력 필요</span>
</div>
</div>
<div v-else class="value muted">명령을 선택하세요.</div>
</div>
<CommandArgumentForm
v-if="selectedCommand?.reqArg && props.commandTable"
:command-key="selectedCommand.key"
:fields="selectedCommand.inputFields"
:options="props.commandTable.inputOptions"
@update:args="commandArgs = $event"
@update:valid="commandArgsValid = $event"
/>
</details>
<div class="reserved-section general-reserved">
<div class="reserved-header">
<span>일반 예턴</span>
<div class="reserved-actions">
<button @click="emit('shift-general-turns', -1)">앞당김</button>
<button @click="emit('shift-general-turns', 1)">밀기</button>
</div>
</div>
<div v-if="!props.reservedGeneralTurns" class="muted">예턴을 불러오지 못했습니다.</div>
<div v-else class="reserved-list">
<div v-for="turn in props.reservedGeneralTurns" :key="turn.index" class="reserved-item">
<div class="turn-label">#{{ turn.index + 1 }}</div>
<div class="turn-action">{{ turn.action }}</div>
<div class="turn-buttons">
<button :disabled="!canReserveSelected('general')" @click="reserveGeneralTurn(turn.index)">
배치
</button>
<button class="ghost" @click="clearGeneralTurn(turn.index)">휴식</button>
</div>
</div>
</div>
</div>
<details class="reserved-section nation-reserved">
<summary>국가 예턴</summary>
<div class="reserved-header">
<span>국가 예턴 편집</span>
<div class="reserved-actions">
<button :disabled="!canNationReserve()" @click="emit('shift-nation-turns', -1)">앞당김</button>
<button :disabled="!canNationReserve()" @click="emit('shift-nation-turns', 1)">밀기</button>
</div>
</div>
<div v-if="!canNationReserve()" class="muted">국가 예턴은 최고위 관직부터 가능합니다.</div>
<div v-else-if="!props.reservedNationTurns" class="muted">예턴을 불러오지 못했습니다.</div>
<div v-else class="reserved-list">
<div v-for="turn in props.reservedNationTurns" :key="turn.index" class="reserved-item">
<div class="turn-label">#{{ turn.index + 1 }}</div>
<div class="turn-action">{{ turn.action }}</div>
<div class="turn-buttons">
<button :disabled="!canReserveSelected('nation')" @click="reserveNationTurn(turn.index)">
배치
</button>
<button class="ghost" @click="clearNationTurn(turn.index)">휴식</button>
</div>
</div>
</div>
</details>
</div>
<ReservedCommandEditor
scope="general"
:rows="rows"
:command-table="props.commandTable"
:loading="props.loading"
:storage-key="props.storageKey ?? `core2026:general:${props.general?.id ?? 0}`"
:current-time="rows[0]?.time"
@reserve-bulk="emit('set-general-turns', $event)"
@shift="emit('shift-general-turns', $event)"
@repeat="emit('repeat-general-turns', $event)"
/>
</template>
<style scoped>
.command-panel {
display: flex;
flex-direction: column;
gap: 4px;
}
.command-editor > summary {
min-height: 28px;
padding: 4px 8px;
background: #444;
color: #fff;
cursor: pointer;
text-align: center;
}
.command-selection {
display: grid;
grid-template-columns: 64px minmax(0, 1fr);
min-height: 24px;
border: 1px solid #666;
font-size: 12px;
}
.command-selection .label {
padding: 2px 5px;
background: #173d27;
color: #fff;
text-align: center;
}
.command-selection .value {
overflow: hidden;
padding: 2px 5px;
white-space: nowrap;
text-overflow: ellipsis;
}
.command-selected {
border: 1px solid #666;
padding: 3px 5px;
display: grid;
grid-template-columns: 64px minmax(0, 1fr);
font-size: 12px;
}
.command-selected .label {
color: rgba(232, 221, 196, 0.6);
}
.command-selected .meta {
display: flex;
gap: 8px;
font-size: 0.7rem;
color: rgba(232, 221, 196, 0.6);
}
.reserved-section {
display: flex;
flex-direction: column;
gap: 0;
}
.reserved-header {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.75rem;
font-weight: 600;
}
.reserved-actions {
display: flex;
gap: 6px;
}
.reserved-actions button {
border: 1px solid rgba(201, 164, 90, 0.3);
padding: 4px 6px;
font-size: 0.7rem;
}
.reserved-list {
display: flex;
flex-direction: column;
gap: 0;
max-height: 420px;
overflow-y: auto;
}
.nation-reserved > summary {
min-height: 28px;
padding: 4px 8px;
background: #444;
cursor: pointer;
}
.reserved-item {
border: 1px solid rgba(201, 164, 90, 0.2);
min-height: 30px;
padding: 2px 4px;
display: grid;
grid-template-columns: 50px 1fr auto;
gap: 6px;
align-items: center;
font-size: 0.75rem;
}
.turn-label {
color: rgba(232, 221, 196, 0.6);
}
.turn-buttons {
display: flex;
gap: 4px;
}
.turn-buttons button {
border: 1px solid rgba(201, 164, 90, 0.3);
padding: 4px 6px;
font-size: 0.7rem;
}
.ghost {
background: transparent;
}
.muted {
color: rgba(232, 221, 196, 0.6);
font-size: 0.75rem;
}
</style>
@@ -26,6 +26,7 @@ const props = defineProps<{
loading: boolean;
activeCategory?: string;
scope?: 'all' | 'general' | 'nation';
allowBlocked?: boolean;
}>();
const emit = defineEmits<{
@@ -33,26 +34,42 @@ const emit = defineEmits<{
(event: 'update:activeCategory', category: string): void;
}>();
const nationCategoryOrder = ['휴식', '인사', '외교', '특수', '전략', '기타'] as const;
const effectiveScope = computed(() => {
if (props.commandTable?.general.length === 0 && props.commandTable.nation.length > 0) return 'nation';
if (props.commandTable?.nation.length === 0 && props.commandTable.general.length > 0) return 'general';
return props.scope ?? 'all';
});
const scopedGroups = computed(() => {
if (!props.commandTable) return { general: [] as CommandGroup[], nation: [] as CommandGroup[] };
const nationCommands = props.commandTable.nation.flatMap((group) =>
group.values.map((command) => ({ category: group.category === '국가' ? '특수' : group.category, command }))
);
const nation = nationCategoryOrder.map((category) => ({
category,
values: nationCommands.filter((entry) => entry.category === category).map((entry) => entry.command),
}));
return { general: props.commandTable.general, nation };
});
const categories = computed(() => {
if (!props.commandTable) {
return [] as Array<{ id: string; label: string; category: string; groupType: 'general' | 'nation' }>;
}
const general = props.commandTable.general.map((group) => ({
const general = scopedGroups.value.general.map((group) => ({
id: `general:${group.category}`,
label: group.category,
category: group.category,
groupType: 'general' as const,
}));
const nation = props.commandTable.nation.map((group) => ({
const nation = scopedGroups.value.nation.map((group) => ({
id: `nation:${group.category}`,
label: `국가:${group.category}`,
label: effectiveScope.value === 'nation' ? group.category : `국가:${group.category}`,
category: group.category,
groupType: 'nation' as const,
}));
if (props.scope === 'general') return general;
if (props.scope === 'nation') {
return nation.map((entry) => ({ ...entry, label: entry.category === '국가' ? '기타' : entry.category }));
}
if (effectiveScope.value === 'general') return general;
if (effectiveScope.value === 'nation') return nation;
return [...general, ...nation];
});
@@ -64,7 +81,7 @@ const selectedGroup = computed(() => {
const [scope, ...categoryParts] = selectedCategory.value.split(':');
const category = categoryParts.join(':');
return (
props.commandTable[scope === 'nation' ? 'nation' : 'general'].find((group) => group.category === category) ??
scopedGroups.value[scope === 'nation' ? 'nation' : 'general'].find((group) => group.category === category) ??
null
);
});
@@ -128,8 +145,9 @@ const commandTitle = (command: CommandAvailability) =>
'command-item',
command.status === 'available' ? 'ok' : '',
command.status === 'blocked' ? 'blocked' : '',
command.status === 'blocked' && props.allowBlocked ? 'reservable' : '',
]"
:disabled="!command.possible"
:disabled="!props.allowBlocked && !command.possible"
:title="commandTitle(command)"
@click="emit('select', command.key)"
>
@@ -204,6 +222,12 @@ const commandTitle = (command: CommandAvailability) =>
cursor: not-allowed;
}
.command-item.blocked.reservable {
color: #d8ccb1;
opacity: 1;
cursor: pointer;
}
.command-name {
font-weight: 600;
}