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,445 +1,44 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import CommandArgumentForm from '../main/CommandArgumentForm.vue';
import CommandSelectForm from '../main/CommandSelectForm.vue';
import { getNpcColor } from '../../utils/npcColor';
type CommandOption = { value: string | number; label: string; color?: string };
type CommandInputField = {
key: string;
label: string;
kind: 'text' | 'number' | 'boolean' | 'select' | 'numberTuple' | 'hidden';
required: boolean;
min?: number;
max?: number;
step?: number;
constValue?: string | number;
options?: CommandOption[];
optionSource?: 'cities' | 'nations' | 'generals' | 'crewTypes' | 'armTypes' | 'nationTypes' | 'colors' | 'items';
tupleLabels?: string[];
};
type CommandAvailability = {
key: string;
name: string;
reqArg: boolean;
status: 'available' | 'blocked' | 'needsInput' | 'unknown';
possible: boolean;
reason?: string;
inputFields: CommandInputField[];
};
type CommandTable = {
general: Array<{ category: string; values: CommandAvailability[] }>;
nation: Array<{ category: string; values: CommandAvailability[] }>;
inputOptions: {
cities: CommandOption[];
nations: CommandOption[];
generals: CommandOption[];
crewTypes: CommandOption[];
armTypes: CommandOption[];
nationTypes: CommandOption[];
colors: CommandOption[];
items: Record<string, CommandOption[]>;
};
};
type TurnRow = { index: number; time: string; action: string; isRest: boolean };
import { computed } from 'vue';
import ReservedCommandEditor from '../command/ReservedCommandEditor.vue';
import type { CommandPatternEntry, CommandTable, ReservedCommandRow } from '../command/types';
const props = defineProps<{
officerLevelText: string;
name: string | null;
npcState: number | null;
rows: TurnRow[];
rows: Array<ReservedCommandRow & { actionCode?: string }>;
commandTable: CommandTable | null;
loading: boolean;
generalId: number;
officerLevel: number;
mobile?: boolean;
}>();
const commandRows = computed(() => props.rows.map((row) => ({ ...row, action: row.actionCode ?? row.action })));
const emit = defineEmits<{
(event: 'reserve', payload: { index: number; action: string; args: Record<string, unknown> }): void;
(event: 'reserve-bulk', entries: CommandPatternEntry[]): void;
(event: 'shift', amount: number): void;
(event: 'repeat', amount: number): void;
}>();
const pickerTurnIndex = ref<number | null>(null);
const selectedCommand = ref<CommandAvailability | null>(null);
const commandArgs = ref<Record<string, unknown>>({});
const commandArgsValid = ref(false);
const editMode = ref(false);
const repeatAmount = ref(0);
const nationCategoryOrder = ['휴식', '인사', '외교', '특수', '전략', '국가'];
const nationOnlyTable = computed(() => {
if (!props.commandTable) return null;
const groupByCategory = new Map(props.commandTable.nation.map((group) => [group.category, group]));
const orderedGroups = nationCategoryOrder.map(
(category) => groupByCategory.get(category) ?? { category, values: [] }
);
const extraGroups = props.commandTable.nation.filter((group) => !nationCategoryOrder.includes(group.category));
return { ...props.commandTable, general: [], nation: [...orderedGroups, ...extraGroups] };
});
const nameColor = computed(() => (props.npcState !== null ? getNpcColor(props.npcState) : undefined));
const closePicker = () => {
pickerTurnIndex.value = null;
selectedCommand.value = null;
commandArgs.value = {};
commandArgsValid.value = false;
};
const openPicker = (turnIndex: number) => {
pickerTurnIndex.value = turnIndex;
selectedCommand.value = null;
commandArgs.value = {};
commandArgsValid.value = false;
};
const selectCommand = (commandKey: string) => {
const command =
props.commandTable?.nation.flatMap((group) => group.values).find((entry) => entry.key === commandKey) ?? null;
if (!command || pickerTurnIndex.value === null) return;
selectedCommand.value = command;
commandArgs.value = {};
commandArgsValid.value = !command.reqArg;
if (!command.reqArg) reserveSelected();
};
const reserveSelected = () => {
if (pickerTurnIndex.value === null || !selectedCommand.value || !commandArgsValid.value) return;
emit('reserve', {
index: pickerTurnIndex.value,
action: selectedCommand.value.key,
args: commandArgs.value,
});
closePicker();
};
</script>
<template>
<article class="chief-editor" :class="{ mobile: props.mobile }" data-testid="chief-command-editor">
<header v-if="!props.mobile" class="editor-header legacy-bg1">
<span>{{ props.officerLevelText }} :</span>
<strong :style="{ color: nameColor }">{{ props.name ?? '-' }}</strong>
</header>
<div class="editor-body">
<aside class="editor-controls">
<div v-if="props.mobile" class="mobile-identity legacy-bg1">
<strong :style="{ color: nameColor }">{{ props.name ?? '-' }}</strong>
<span>{{ props.officerLevelText }}</span>
</div>
<time>{{ props.rows[0]?.time ?? '--:--' }}</time>
<button type="button" @click="editMode = !editMode">{{ editMode ? '일반 모드' : '고급 모드' }}</button>
<select
v-model.number="repeatAmount"
class="repeat-control"
aria-label="반복 "
@change="repeatAmount > 0 && emit('repeat', repeatAmount)"
>
<option :value="0" disabled>반복</option>
<option v-for="amount in 6" :key="amount" :value="amount">{{ amount }}</option>
</select>
<button type="button" @click="emit('shift', -1)">당기기</button>
<button type="button" @click="emit('shift', 1)">미루기</button>
</aside>
<div class="editor-turns">
<div v-for="row in props.rows" :key="row.index" class="editor-turn-row">
<time>{{ row.time }}</time>
<strong>{{ row.action }}</strong>
<button
type="button"
class="edit-turn"
:aria-label="`${row.index + 1} 명령 입력`"
@click="openPicker(row.index)"
>
</button>
</div>
</div>
</div>
<div
v-if="pickerTurnIndex !== null"
:class="['command-picker', { 'has-command': selectedCommand }]"
data-testid="chief-command-picker"
>
<header>
<strong>{{ pickerTurnIndex + 1 }} 명령 입력</strong>
<button type="button" aria-label="명령 입력 닫기" @click="closePicker">×</button>
</header>
<CommandSelectForm
v-if="!selectedCommand"
:command-table="nationOnlyTable"
:loading="props.loading"
scope="nation"
@select="selectCommand"
/>
<button v-if="!selectedCommand" type="button" class="picker-close" @click="closePicker">닫기</button>
<template v-else>
<div class="selected-command">{{ selectedCommand.name }}</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"
/>
<div class="picker-actions">
<button type="button" @click="selectedCommand = null">명령 다시 선택</button>
<button type="button" :disabled="!commandArgsValid" @click="reserveSelected">입력</button>
</div>
</template>
</div>
</article>
<ReservedCommandEditor
data-testid="chief-command-editor"
scope="nation"
:rows="commandRows"
:command-table="props.commandTable"
:loading="props.loading"
:storage-key="`core2026:nation:${props.generalId}:${props.officerLevel}`"
:compact="true"
:mobile="props.mobile"
:title="props.officerLevelText"
:name="props.name"
:current-time="props.rows[0]?.time"
@reserve-bulk="emit('reserve-bulk', $event)"
@shift="emit('shift', $event)"
@repeat="emit('repeat', $event)"
/>
</template>
<style scoped>
.chief-editor {
position: relative;
min-width: 0;
color: #fff;
background: #000;
}
.editor-header {
box-sizing: border-box;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
font-size: 16.8px;
font-weight: 400;
}
.editor-body {
display: flex;
flex-direction: column;
}
.editor-controls {
order: 2;
min-height: 85px;
padding: 2px 0;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 3px;
align-items: stretch;
}
.editor-controls > time {
display: grid;
place-items: center;
border-radius: 4px;
background: #345c85;
font-variant-numeric: tabular-nums;
}
.editor-controls button,
.repeat-control {
min-height: 36px;
border: 0;
border-radius: 4px;
background: #444;
color: #fff;
font: inherit;
font-weight: 700;
}
.editor-controls button {
cursor: pointer;
}
.repeat-control {
padding: 0 8px;
text-align: center;
}
.editor-turns {
order: 1;
display: grid;
grid-template-rows: repeat(12, 30px);
}
.editor-turn-row {
display: grid;
grid-template-columns: 55px minmax(0, 1fr) 36px;
align-items: center;
min-width: 0;
}
.editor-turn-row > time {
height: 30px;
display: grid;
place-items: center;
background: #000;
font-variant-numeric: tabular-nums;
}
.editor-turn-row > strong {
height: 30px;
display: grid;
place-items: center;
overflow: hidden;
background: #0d204d;
font-weight: 400;
white-space: nowrap;
text-overflow: ellipsis;
}
.editor-turn-row:nth-child(odd) > strong {
background: #12295d;
}
.edit-turn {
align-self: stretch;
border: 0;
background: #444;
color: #fff;
cursor: pointer;
}
.command-picker {
position: absolute;
z-index: 20;
top: 54px;
left: 0;
box-sizing: border-box;
width: 100%;
height: 344px;
overflow: auto;
border: 0;
padding: 0;
background: #303030;
}
.command-picker > header {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
.command-picker.has-command {
padding: 8px;
}
.command-picker.has-command > header {
position: static;
width: auto;
height: auto;
display: flex;
justify-content: space-between;
align-items: center;
clip: auto;
margin-bottom: 8px;
}
.command-picker.has-command > header button {
width: 32px;
height: 28px;
}
.command-picker :deep(.command-form) {
gap: 4px;
padding-top: 0;
}
.command-picker :deep(.category-list) {
grid-template-columns: repeat(3, 1fr);
gap: 4px 2px;
}
.command-picker :deep(.category-btn) {
min-width: 0;
height: 35px;
border: 0;
border-radius: 4px;
padding: 4px;
background: #00a879;
color: #fff;
font-size: 16px;
font-weight: 700;
}
.command-picker :deep(.category-btn.active) {
background: #00bf91;
}
.command-picker :deep(.command-grid) {
grid-template-columns: repeat(2, 1fr);
gap: 4px;
margin-top: 4px;
}
.command-picker :deep(.command-item) {
min-height: 39px;
border: 1px solid #888;
border-radius: 5px;
padding: 5px;
display: grid;
place-items: center;
background: transparent;
color: #fff;
text-align: center;
font-size: 16px;
}
.command-picker :deep(.command-status) {
display: none;
}
.picker-close {
position: absolute;
right: 0;
bottom: 7px;
width: 65px;
height: 35px;
border: 0;
border-radius: 4px;
background: #444;
color: #fff;
font: inherit;
font-weight: 700;
}
.selected-command {
margin-bottom: 6px;
padding: 6px 8px;
background: #0d204d;
font-weight: 700;
}
.picker-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin-top: 8px;
}
.picker-actions button {
min-height: 34px;
}
.mobile-identity {
display: grid;
grid-column: 1 / -1;
min-height: 60px;
place-items: center;
}
.chief-editor.mobile .editor-header {
display: none;
}
.chief-editor.mobile {
margin-top: 10px;
}
.chief-editor.mobile .editor-body {
height: 360px;
display: grid;
grid-template-columns: 109px 391px;
}
.chief-editor.mobile .editor-controls {
order: initial;
min-height: 0;
padding: 0;
grid-template-columns: 1fr;
align-content: start;
}
.chief-editor.mobile .editor-controls > time {
min-height: 36px;
}
.chief-editor.mobile .editor-controls > button {
min-height: 36px;
margin-top: 5px;
}
.chief-editor.mobile .repeat-control {
min-height: 36px;
margin-top: 5px;
}
.chief-editor.mobile .editor-turns {
order: initial;
padding-top: 10px;
}
.chief-editor.mobile .editor-turn-row {
grid-template-columns: 74px minmax(0, 1fr) 53px;
}
.chief-editor.mobile .command-picker {
position: absolute;
top: 30px;
left: 130px;
width: 370px;
height: 327px;
}
</style>