feat: rebuild Ref-compatible command editors
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user