fix(frontend): restore chief center command UI parity
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
const frontendUrl = process.env.CHIEF_CENTER_LIVE_FRONTEND_URL ?? 'http://127.0.0.1:15160/hwe/';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: '.',
|
||||
testMatch: ['chiefCenterLive.spec.ts'],
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
timeout: 90_000,
|
||||
expect: { timeout: 15_000 },
|
||||
reporter: [['list']],
|
||||
outputDir: resolve(repositoryRoot, 'test-results/chief-center-live'),
|
||||
use: {
|
||||
baseURL: frontendUrl,
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { expect, test, type Browser, type Page } from '@playwright/test';
|
||||
import { encryptGameSessionToken } from '../../../packages/common/dist/auth/gameToken.js';
|
||||
import { createGamePostgresConnector } from '../../../packages/infra/dist/index.js';
|
||||
|
||||
const databaseUrl = process.env.CHIEF_CENTER_LIVE_DATABASE_URL;
|
||||
const gameTokenSecret = process.env.CHIEF_CENTER_LIVE_GAME_SECRET;
|
||||
const profile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'hwe:1010';
|
||||
const hasLiveFixture = Boolean(databaseUrl && gameTokenSecret);
|
||||
const gameSchema = profile.split(':', 1)[0] ?? '';
|
||||
|
||||
const resolveGameDatabaseUrl = (): string => {
|
||||
const parsed = new URL(databaseUrl!);
|
||||
const sourceSchema = parsed.searchParams.get('schema');
|
||||
if (!gameSchema || (sourceSchema !== 'public' && sourceSchema !== gameSchema)) {
|
||||
throw new Error(`Refusing unexpected chief-center schema: ${sourceSchema ?? '(missing)'}`);
|
||||
}
|
||||
parsed.searchParams.set('schema', gameSchema);
|
||||
return parsed.toString();
|
||||
};
|
||||
|
||||
const installSession = async (page: Page, userId: string, displayName: string): Promise<void> => {
|
||||
const now = new Date();
|
||||
const token = encryptGameSessionToken(
|
||||
{
|
||||
version: 1,
|
||||
profile,
|
||||
issuedAt: now.toISOString(),
|
||||
expiresAt: new Date(now.getTime() + 3_600_000).toISOString(),
|
||||
sessionId: `chief-center-live-${randomUUID()}`,
|
||||
user: {
|
||||
id: userId,
|
||||
username: userId,
|
||||
displayName,
|
||||
roles: ['user'],
|
||||
canUseGeneralPicture: false,
|
||||
},
|
||||
sanctions: {},
|
||||
identity: {
|
||||
kakaoVerified: true,
|
||||
canCreateGeneral: true,
|
||||
requiresKakaoVerification: false,
|
||||
graceEndsAt: null,
|
||||
},
|
||||
},
|
||||
gameTokenSecret!
|
||||
);
|
||||
await page.addInitScript(
|
||||
({ gameToken, gameProfile }) => {
|
||||
localStorage.setItem('sammo-game-token', gameToken);
|
||||
localStorage.setItem('sammo-game-profile', gameProfile);
|
||||
},
|
||||
{ gameToken: token, gameProfile: profile }
|
||||
);
|
||||
};
|
||||
|
||||
const newPage = async (browser: Browser, userId: string, displayName: string): Promise<Page> => {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1365, height: 900 },
|
||||
deviceScaleFactor: 1,
|
||||
locale: 'ko-KR',
|
||||
timezoneId: 'Asia/Seoul',
|
||||
colorScheme: 'dark',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await installSession(page, userId, displayName);
|
||||
return page;
|
||||
};
|
||||
|
||||
test('persists one chief command and exposes it to a normal nation user and another chief', async ({
|
||||
browser,
|
||||
}, testInfo) => {
|
||||
test.skip(!hasLiveFixture, 'isolated chief-center PostgreSQL and token secret are required');
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const connector = createGamePostgresConnector({ url: resolveGameDatabaseUrl() });
|
||||
await connector.connect();
|
||||
const db = connector.prisma;
|
||||
const editor = await db.general.findFirstOrThrow({ where: { name: 'GUI비교관리자' } });
|
||||
const candidates = await db.general.findMany({
|
||||
where: { nationId: editor.nationId, userId: null, id: { not: editor.id } },
|
||||
orderBy: { id: 'asc' },
|
||||
take: 2,
|
||||
});
|
||||
if (candidates.length !== 2) throw new Error('Two isolated visibility candidates are required.');
|
||||
const [viewer, otherChief] = candidates;
|
||||
const viewerUserId = `chief-center-viewer-${randomUUID()}`;
|
||||
const otherChiefUserId = `chief-center-peer-${randomUUID()}`;
|
||||
const originalTurns = await db.nationTurn.findMany({
|
||||
where: { nationId: editor.nationId, officerLevel: editor.officerLevel },
|
||||
orderBy: { turnIdx: 'asc' },
|
||||
});
|
||||
const originalRevision = await db.nationTurnRevision.findUnique({
|
||||
where: {
|
||||
nationId_officerLevel: { nationId: editor.nationId, officerLevel: editor.officerLevel },
|
||||
},
|
||||
});
|
||||
let selectedTargetId: number | undefined;
|
||||
|
||||
try {
|
||||
await db.$transaction([
|
||||
db.general.update({
|
||||
where: { id: viewer.id },
|
||||
data: {
|
||||
userId: viewerUserId,
|
||||
officerLevel: 1,
|
||||
npcState: 0,
|
||||
meta: { ...(viewer.meta as Record<string, unknown>), belong: 999 },
|
||||
penalty: {},
|
||||
},
|
||||
}),
|
||||
db.general.update({
|
||||
where: { id: otherChief.id },
|
||||
data: { userId: otherChiefUserId, officerLevel: 10, npcState: 0, penalty: {} },
|
||||
}),
|
||||
]);
|
||||
|
||||
const editorPage = await newPage(browser, editor.userId!, '사령부입력자');
|
||||
await editorPage.goto('chief-center');
|
||||
await expect(editorPage.getByRole('heading', { name: '사령부', exact: true })).toBeVisible();
|
||||
await editorPage.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||||
const picker = editorPage.getByTestId('chief-command-picker');
|
||||
await expect(picker).toBeVisible();
|
||||
await picker.getByRole('button', { name: '인사', exact: true }).click();
|
||||
const reward = picker.getByRole('button', { name: /포상/ });
|
||||
await expect(reward).toBeEnabled();
|
||||
await reward.click();
|
||||
const argumentForm = picker.getByTestId('command-argument-form');
|
||||
await argumentForm.getByRole('button', { name: '쌀', exact: true }).click();
|
||||
await argumentForm.locator('input[type=number]').fill('1');
|
||||
const selectableGeneralIds = await argumentForm
|
||||
.locator('select option')
|
||||
.evaluateAll((options) =>
|
||||
options
|
||||
.map((option) => Number((option as HTMLOptionElement).value))
|
||||
.filter((value) => Number.isInteger(value) && value > 0)
|
||||
);
|
||||
selectedTargetId = selectableGeneralIds.find((generalId) => generalId !== editor.id);
|
||||
if (!selectedTargetId) throw new Error('No reward target is available in the live command table.');
|
||||
await argumentForm.locator('select').selectOption(String(selectedTargetId));
|
||||
await picker.getByRole('button', { name: '입력', exact: true }).click();
|
||||
await expect(
|
||||
editorPage.getByTestId('chief-command-editor').locator('.editor-turn-row strong').first()
|
||||
).toHaveText('포상');
|
||||
|
||||
const persisted = await db.nationTurn.findUniqueOrThrow({
|
||||
where: {
|
||||
nationId_officerLevel_turnIdx: {
|
||||
nationId: editor.nationId,
|
||||
officerLevel: editor.officerLevel,
|
||||
turnIdx: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(persisted.actionCode).toBe('che_포상');
|
||||
expect(persisted.arg).toEqual({ isGold: false, amount: 1, destGeneralId: selectedTargetId });
|
||||
await editorPage.screenshot({ path: testInfo.outputPath('chief-editor-command-entered.png'), fullPage: true });
|
||||
|
||||
const viewerPage = await newPage(browser, viewerUserId, '일반국가원');
|
||||
await viewerPage.goto('chief-center');
|
||||
await expect(viewerPage.getByRole('heading', { name: '사령부', exact: true })).toBeVisible();
|
||||
await expect(viewerPage.getByTestId('chief-command-editor')).toHaveCount(0);
|
||||
await expect(viewerPage.locator('.chief-grid-row').first().getByText('포상', { exact: true })).toBeVisible();
|
||||
await viewerPage.screenshot({ path: testInfo.outputPath('chief-normal-user-visible.png'), fullPage: true });
|
||||
|
||||
const peerPage = await newPage(browser, otherChiefUserId, '다른수뇌');
|
||||
await peerPage.goto('chief-center');
|
||||
await expect(peerPage.getByTestId('chief-command-editor')).toBeVisible();
|
||||
await expect(peerPage.locator('.chief-grid-row').first().getByText('포상', { exact: true })).toBeVisible();
|
||||
await peerPage.screenshot({ path: testInfo.outputPath('chief-peer-visible.png'), fullPage: true });
|
||||
} finally {
|
||||
await db.$transaction(async (transaction) => {
|
||||
await transaction.nationTurn.deleteMany({
|
||||
where: { nationId: editor.nationId, officerLevel: editor.officerLevel },
|
||||
});
|
||||
if (originalTurns.length) await transaction.nationTurn.createMany({ data: originalTurns });
|
||||
await transaction.nationTurnRevision.deleteMany({
|
||||
where: { nationId: editor.nationId, officerLevel: editor.officerLevel },
|
||||
});
|
||||
if (originalRevision) await transaction.nationTurnRevision.create({ data: originalRevision });
|
||||
await transaction.general.update({
|
||||
where: { id: viewer.id },
|
||||
data: {
|
||||
userId: viewer.userId,
|
||||
officerLevel: viewer.officerLevel,
|
||||
npcState: viewer.npcState,
|
||||
meta: viewer.meta,
|
||||
penalty: viewer.penalty,
|
||||
},
|
||||
});
|
||||
await transaction.general.update({
|
||||
where: { id: otherChief.id },
|
||||
data: {
|
||||
userId: otherChief.userId,
|
||||
officerLevel: otherChief.officerLevel,
|
||||
npcState: otherChief.npcState,
|
||||
meta: otherChief.meta,
|
||||
penalty: otherChief.penalty,
|
||||
},
|
||||
});
|
||||
});
|
||||
await connector.disconnect();
|
||||
}
|
||||
});
|
||||
@@ -211,6 +211,7 @@ const install = async (page: Page, rejectGeneral = false) => {
|
||||
requests.push(body);
|
||||
return response({
|
||||
ok: true,
|
||||
revision: 1,
|
||||
turns: [{ index: 0, action: 'che_포상', args: { isGold: false, amount: 300, destGeneralId: 2 } }],
|
||||
});
|
||||
}
|
||||
@@ -281,7 +282,7 @@ test('keeps the entered command visible and reports a server validation error',
|
||||
});
|
||||
|
||||
test('keeps the shared main and chief shell geometry and interaction states', async ({ page }) => {
|
||||
await install(page);
|
||||
const requests = await install(page);
|
||||
await page.setViewportSize({ width: 1000, height: 900 });
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
||||
@@ -329,10 +330,23 @@ test('keeps the shared main and chief shell geometry and interaction states', as
|
||||
await page.locator('.main-nation-menu').first().locator('[data-navigation-id="chief-center"]').click();
|
||||
await expect(page).toHaveURL(/\/che\/chief-center$/);
|
||||
await expect(page.getByRole('heading', { name: '사령부', exact: true })).toBeVisible();
|
||||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||||
await expect(page.getByTestId('chief-command-picker')).toBeVisible();
|
||||
await page.getByTestId('chief-command-picker').getByRole('button', { name: /포상/ }).click();
|
||||
const chiefArgumentForm = page.getByTestId('chief-command-picker').getByTestId('command-argument-form');
|
||||
await chiefArgumentForm.getByRole('button', { name: '쌀' }).click();
|
||||
await chiefArgumentForm.locator('input[type=number]').fill('300');
|
||||
await chiefArgumentForm.locator('select').selectOption('2');
|
||||
await page.getByTestId('chief-command-picker').getByRole('button', { name: '입력', exact: true }).click();
|
||||
await expect(page.getByTestId('chief-command-editor').locator('.editor-turn-row strong').first()).toHaveText(
|
||||
'포상'
|
||||
);
|
||||
expect(JSON.stringify(requests)).toContain('"action":"che_포상"');
|
||||
expect(JSON.stringify(requests)).toContain('"destGeneralId":2');
|
||||
const chiefDesktop = await page.locator('.chief-page').evaluate((element) => ({
|
||||
width: element.getBoundingClientRect().width,
|
||||
padding: getComputedStyle(element).padding,
|
||||
headerWidth: element.querySelector<HTMLElement>('.game-shell__header')!.getBoundingClientRect().width,
|
||||
headerWidth: element.querySelector<HTMLElement>('.chief-top')!.getBoundingClientRect().width,
|
||||
}));
|
||||
expect(chiefDesktop).toEqual({ width: 1000, padding: '0px', headerWidth: 1000 });
|
||||
|
||||
@@ -340,7 +354,7 @@ test('keeps the shared main and chief shell geometry and interaction states', as
|
||||
const chiefMobile = await page.locator('.chief-page').evaluate((element) => ({
|
||||
width: element.getBoundingClientRect().width,
|
||||
padding: getComputedStyle(element).padding,
|
||||
headerWidth: element.querySelector<HTMLElement>('.game-shell__header')!.getBoundingClientRect().width,
|
||||
headerWidth: element.querySelector<HTMLElement>('.chief-top')!.getBoundingClientRect().width,
|
||||
}));
|
||||
expect(chiefMobile).toEqual({ width: 500, padding: '0px', headerWidth: 500 });
|
||||
});
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
"./npcPossessionLive.spec.ts",
|
||||
"./npcPossession.live.playwright.config.mjs",
|
||||
"./dieOnPrestartLive.spec.ts",
|
||||
"./dieOnPrestart.live.playwright.config.mjs"
|
||||
"./dieOnPrestart.live.playwright.config.mjs",
|
||||
"./chiefCenterLive.spec.ts",
|
||||
"./chiefCenter.live.playwright.config.mjs"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
<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 };
|
||||
|
||||
const props = defineProps<{
|
||||
officerLevelText: string;
|
||||
name: string | null;
|
||||
npcState: number | null;
|
||||
rows: TurnRow[];
|
||||
commandTable: CommandTable | null;
|
||||
loading: boolean;
|
||||
mobile?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'reserve', payload: { index: number; action: string; args: Record<string, unknown> }): 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>
|
||||
</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>
|
||||
@@ -18,6 +18,7 @@ const props = defineProps<{
|
||||
compact?: boolean;
|
||||
isMe?: boolean;
|
||||
clickable?: boolean;
|
||||
turnTimeLabel?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -40,13 +41,26 @@ const handleClick = () => {
|
||||
@click="handleClick"
|
||||
>
|
||||
<header class="chief-header">
|
||||
<div class="chief-title">
|
||||
<span class="chief-level">{{ props.officerLevelText }}</span>
|
||||
<span class="chief-name" :style="{ color: nameColor }">
|
||||
{{ props.name ?? '-' }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-if="props.isMe" class="chief-me">ME</span>
|
||||
<template v-if="props.compact">
|
||||
<span
|
||||
class="compact-name"
|
||||
:style="{ color: nameColor, textDecoration: props.isMe ? 'underline' : undefined }"
|
||||
>{{ props.name ?? '-' }}</span
|
||||
>
|
||||
<span class="compact-meta"
|
||||
><span>{{ props.officerLevelText }}</span
|
||||
><time>{{ props.turnTimeLabel ?? '--:--' }}</time></span
|
||||
>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="chief-title">
|
||||
<span class="chief-level">{{ props.officerLevelText }}</span>
|
||||
<span class="chief-name" :style="{ color: nameColor }">
|
||||
{{ props.name ?? '-' }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-if="props.isMe" class="chief-me">ME</span>
|
||||
</template>
|
||||
</header>
|
||||
<div class="chief-rows">
|
||||
<div v-for="row in props.rows" :key="row.index" class="chief-row" :class="{ rest: row.isRest }">
|
||||
@@ -72,7 +86,9 @@ const handleClick = () => {
|
||||
|
||||
.chief-card.clickable {
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.chief-card.clickable:hover {
|
||||
@@ -163,6 +179,28 @@ const handleClick = () => {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.compact-name,
|
||||
.compact-meta {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.compact-meta {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.chief-card.compact .chief-header {
|
||||
height: 72px;
|
||||
grid-template-rows: 36px 36px;
|
||||
display: grid;
|
||||
padding: 0;
|
||||
}
|
||||
.chief-card.compact .chief-row {
|
||||
height: 46px;
|
||||
line-height: 46px;
|
||||
}
|
||||
|
||||
.chief-card.compact .chief-level,
|
||||
.chief-card.compact .chief-name {
|
||||
font-size: 0.6rem;
|
||||
|
||||
@@ -25,6 +25,7 @@ const props = defineProps<{
|
||||
commandTable: CommandTable | null;
|
||||
loading: boolean;
|
||||
activeCategory?: string;
|
||||
scope?: 'all' | 'general' | 'nation';
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -48,6 +49,10 @@ const categories = computed(() => {
|
||||
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 }));
|
||||
}
|
||||
return [...general, ...nation];
|
||||
});
|
||||
|
||||
@@ -58,7 +63,10 @@ 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) ?? null;
|
||||
return (
|
||||
props.commandTable[scope === 'nation' ? 'nation' : 'general'].find((group) => group.category === category) ??
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
watch(
|
||||
@@ -109,9 +117,7 @@ const statusLabel = (command: CommandAvailability) => {
|
||||
<div v-if="props.loading">
|
||||
<SkeletonLines :lines="4" />
|
||||
</div>
|
||||
<div v-else-if="!props.commandTable" class="empty">
|
||||
명령 목록을 불러오지 못했습니다.
|
||||
</div>
|
||||
<div v-else-if="!props.commandTable" class="empty">명령 목록을 불러오지 못했습니다.</div>
|
||||
<div v-else>
|
||||
<div class="category-list">
|
||||
<button
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMediaQuery } from '@vueuse/core';
|
||||
import { addMinutes, format } from 'date-fns';
|
||||
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
||||
import ChiefTurnCard from '../components/chief/ChiefTurnCard.vue';
|
||||
import ChiefCommandEditor from '../components/chief/ChiefCommandEditor.vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { formatOfficerLevelText } from '../utils/nationFormat';
|
||||
|
||||
@@ -260,7 +261,7 @@ const selectedChiefRows = computed(() => {
|
||||
return buildTurnRows(selectedChief.value);
|
||||
});
|
||||
|
||||
const updateMyTurns = (turns: ChiefEntry['turns'], revision: number) => {
|
||||
const updateMyTurns = (turns: Array<{ index: number; action: string; args?: unknown }>, revision: number) => {
|
||||
if (!data.value) {
|
||||
return;
|
||||
}
|
||||
@@ -269,29 +270,10 @@ const updateMyTurns = (turns: ChiefEntry['turns'], revision: number) => {
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
entry.turns = turns;
|
||||
entry.turns = turns.map((turn) => ({ ...turn, args: turn.args ?? {} }));
|
||||
entry.revision = revision;
|
||||
};
|
||||
|
||||
const clearTurn = async (turnIndex: number) => {
|
||||
if (!data.value || !isEditingAllowed.value) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await trpc.turns.reserved.setNation.mutate({
|
||||
generalId: data.value.me.id,
|
||||
turnIndex,
|
||||
action: '휴식',
|
||||
args: {},
|
||||
expectedRevision: selectedChief.value?.revision ?? 0,
|
||||
});
|
||||
updateMyTurns(result.turns, result.revision);
|
||||
} catch (err) {
|
||||
await loadChiefCenter();
|
||||
error.value = resolveErrorMessage(err);
|
||||
}
|
||||
};
|
||||
|
||||
const shiftTurns = async (amount: number) => {
|
||||
if (!data.value || !isEditingAllowed.value) {
|
||||
return;
|
||||
@@ -308,6 +290,38 @@ const shiftTurns = async (amount: number) => {
|
||||
error.value = resolveErrorMessage(err);
|
||||
}
|
||||
};
|
||||
|
||||
const reserveTurn = async (payload: { index: number; action: string; args: Record<string, unknown> }) => {
|
||||
if (!data.value || !isEditingAllowed.value) return;
|
||||
try {
|
||||
const result = await trpc.turns.reserved.setNation.mutate({
|
||||
generalId: data.value.me.id,
|
||||
turnIndex: payload.index,
|
||||
action: payload.action,
|
||||
args: payload.args,
|
||||
expectedRevision: selectedChief.value?.revision ?? 0,
|
||||
});
|
||||
updateMyTurns(result.turns, result.revision);
|
||||
} catch (err) {
|
||||
await loadChiefCenter();
|
||||
error.value = resolveErrorMessage(err);
|
||||
}
|
||||
};
|
||||
|
||||
const repeatTurns = async (amount: number) => {
|
||||
if (!data.value || !isEditingAllowed.value) return;
|
||||
try {
|
||||
const result = await trpc.turns.reserved.repeatNation.mutate({
|
||||
generalId: data.value.me.id,
|
||||
amount,
|
||||
expectedRevision: selectedChief.value?.revision ?? 0,
|
||||
});
|
||||
updateMyTurns(result.turns, result.revision);
|
||||
} catch (err) {
|
||||
await loadChiefCenter();
|
||||
error.value = resolveErrorMessage(err);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -316,7 +330,8 @@ const shiftTurns = async (amount: number) => {
|
||||
<RouterLink class="chief-nav" to="/">돌아가기</RouterLink>
|
||||
<button class="chief-nav" @click="loadChiefCenter">갱신</button>
|
||||
<h1>사령부</h1>
|
||||
<div></div><div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
</header>
|
||||
|
||||
<div v-if="error" class="game-feedback game-feedback--error" role="alert">{{ error }}</div>
|
||||
@@ -324,47 +339,80 @@ const shiftTurns = async (amount: number) => {
|
||||
<section v-if="loading && !data" class="loading-panel"><SkeletonLines :lines="5" /></section>
|
||||
|
||||
<section v-else-if="data && isMobile" class="layout-mobile">
|
||||
<div class="mobile-editor">
|
||||
<aside class="mobile-controls legacy-bg1">
|
||||
<strong>{{ selectedChief?.name ?? '-' }}</strong>
|
||||
<span>{{ selectedChief ? formatOfficerLevelText(selectedChief.officerLevel, data.nation.level) : '-' }}</span>
|
||||
<time>{{ selectedChiefRows[0]?.time ?? '--:--' }}</time>
|
||||
<button>고급 모드</button><button>반복⌄</button>
|
||||
<button @click="shiftTurns(-1)">당기기⌄</button><button @click="shiftTurns(1)">미루기⌄</button>
|
||||
</aside>
|
||||
<div class="mobile-turns">
|
||||
<div v-for="row in selectedChiefRows" :key="row.index" class="mobile-turn-row">
|
||||
<time>{{ row.time }}</time><strong>{{ row.action }}</strong>
|
||||
<button :disabled="!isEditingAllowed" @click="clearTurn(row.index)">✎</button>
|
||||
</div>
|
||||
</div>
|
||||
<ChiefCommandEditor
|
||||
v-if="isEditingAllowed && selectedChief"
|
||||
:officer-level-text="formatOfficerLevelText(selectedChief.officerLevel, data.nation.level)"
|
||||
:name="selectedChief.name"
|
||||
:npc-state="selectedChief.npcState"
|
||||
:rows="selectedChiefRows"
|
||||
:command-table="commandTable"
|
||||
:loading="commandLoading"
|
||||
:mobile="true"
|
||||
@reserve="reserveTurn"
|
||||
@shift="shiftTurns"
|
||||
@repeat="repeatTurns"
|
||||
/>
|
||||
<div v-else-if="selectedChief" class="mobile-readonly">
|
||||
<ChiefTurnCard
|
||||
:officer-level-text="formatOfficerLevelText(selectedChief.officerLevel, data.nation.level)"
|
||||
:name="selectedChief.name"
|
||||
:npc-state="selectedChief.npcState"
|
||||
:rows="selectedChiefRows"
|
||||
/>
|
||||
</div>
|
||||
<div class="chief-overview">
|
||||
<ChiefTurnCard v-for="chief in chiefViews" :key="chief.officerLevel"
|
||||
:officer-level-text="chief.officerLevelText" :name="chief.name" :npc-state="chief.npcState"
|
||||
:rows="chief.rows" :compact="true" :selected="chief.officerLevel === selectedChief?.officerLevel"
|
||||
:is-me="chief.officerLevel === data.me.officerLevel" :clickable="true"
|
||||
@select="selectedChiefLevel = chief.officerLevel" />
|
||||
<div class="chief-overview-frame">
|
||||
<div class="chief-overview">
|
||||
<ChiefTurnCard
|
||||
v-for="chief in chiefViews"
|
||||
:key="chief.officerLevel"
|
||||
:officer-level-text="chief.officerLevelText"
|
||||
:name="chief.name"
|
||||
:npc-state="chief.npcState"
|
||||
:rows="chief.rows"
|
||||
:compact="true"
|
||||
:selected="chief.officerLevel === selectedChief?.officerLevel"
|
||||
:is-me="chief.officerLevel === data.me.officerLevel"
|
||||
:clickable="true"
|
||||
:turn-time-label="chief.rows[0]?.time"
|
||||
@select="selectedChiefLevel = chief.officerLevel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="data" class="layout-desktop">
|
||||
<div class="chief-grid">
|
||||
<ChiefTurnCard
|
||||
v-for="chief in chiefViews"
|
||||
:key="chief.officerLevel"
|
||||
:officer-level-text="chief.officerLevelText"
|
||||
:name="chief.name"
|
||||
:npc-state="chief.npcState"
|
||||
:rows="chief.rows"
|
||||
:selected="chief.officerLevel === selectedChief?.officerLevel"
|
||||
:is-me="chief.officerLevel === data.me.officerLevel"
|
||||
:clickable="true"
|
||||
@select="selectedChiefLevel = chief.officerLevel"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="isEditingAllowed" class="desktop-actions legacy-bg0">
|
||||
<button @click="shiftTurns(-1)">당기기</button><button @click="shiftTurns(1)">미루기</button>
|
||||
<div
|
||||
v-for="(rowChiefs, rowIndex) in [chiefViews.slice(0, 4), chiefViews.slice(4, 8)]"
|
||||
:key="rowIndex"
|
||||
class="chief-grid-row"
|
||||
>
|
||||
<div class="turn-index-gutter legacy-bg0">
|
||||
<span></span><span v-for="idx in data.maxTurns" :key="idx">{{ idx }}</span>
|
||||
</div>
|
||||
<template v-for="chief in rowChiefs" :key="chief.officerLevel">
|
||||
<ChiefCommandEditor
|
||||
v-if="chief.officerLevel === data.me.officerLevel && data.me.officerLevel >= 5"
|
||||
:officer-level-text="chief.officerLevelText"
|
||||
:name="chief.name"
|
||||
:npc-state="chief.npcState"
|
||||
:rows="chief.rows"
|
||||
:command-table="commandTable"
|
||||
:loading="commandLoading"
|
||||
@reserve="reserveTurn"
|
||||
@shift="shiftTurns"
|
||||
@repeat="repeatTurns"
|
||||
/>
|
||||
<ChiefTurnCard
|
||||
v-else
|
||||
:officer-level-text="chief.officerLevelText"
|
||||
:name="chief.name"
|
||||
:npc-state="chief.npcState"
|
||||
:rows="chief.rows"
|
||||
/>
|
||||
</template>
|
||||
<div class="turn-index-gutter legacy-bg0">
|
||||
<span></span><span v-for="idx in data.maxTurns" :key="idx">{{ idx }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<footer class="chief-footer legacy-bg0"><RouterLink class="chief-nav" to="/">돌아가기</RouterLink></footer>
|
||||
@@ -580,66 +628,165 @@ const shiftTurns = async (amount: number) => {
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.layout-desktop { display: block; }
|
||||
.chief-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
.layout-desktop {
|
||||
display: block;
|
||||
}
|
||||
.chief-grid :deep(.chief-header) { height: 24px; min-height: 24px; }
|
||||
.chief-grid :deep(.chief-row) { box-sizing: border-box; min-height: 30px; }
|
||||
.chief-grid :deep(.chief-card) { border-color: transparent; box-shadow: none; }
|
||||
.desktop-actions { padding: 2px 24px; }
|
||||
.desktop-actions button,
|
||||
.mobile-controls button {
|
||||
min-height: 35px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: #444;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
.chief-footer {
|
||||
min-height: 56px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
.chief-footer { min-height: 56px; padding-top: 20px; }
|
||||
.chief-footer .chief-nav { width: 70px; }
|
||||
.mobile-editor {
|
||||
height: 371px;
|
||||
display: grid;
|
||||
grid-template-columns: 109px 1fr;
|
||||
background: #000;
|
||||
.chief-footer .chief-nav {
|
||||
width: 70px;
|
||||
}
|
||||
.mobile-controls {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
align-content: start;
|
||||
text-align: center;
|
||||
}
|
||||
.mobile-controls strong,
|
||||
.mobile-controls span,
|
||||
.mobile-controls time { grid-column: 1 / -1; min-height: 30px; line-height: 30px; }
|
||||
.mobile-controls time { border-radius: 5px; background: #345c85; }
|
||||
.mobile-controls button { grid-column: 1 / -1; margin-top: 5px; }
|
||||
.mobile-turns { display: grid; grid-template-rows: repeat(12, 30px); padding-top: 10px; }
|
||||
.mobile-turn-row {
|
||||
display: grid;
|
||||
grid-template-columns: 74px 1fr 53px;
|
||||
align-items: center;
|
||||
background: #071638;
|
||||
text-align: center;
|
||||
}
|
||||
.mobile-turn-row:nth-child(even) { background: #0d214e; }
|
||||
.mobile-turn-row button { height: 30px; border: 0; background: #3d3d3d; color: #fff; }
|
||||
.chief-overview {
|
||||
width: 445px;
|
||||
margin-top: 56px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 111.25px);
|
||||
}
|
||||
.chief-overview :deep(.chief-card) { border-color: transparent; box-shadow: none; }
|
||||
.chief-overview :deep(.chief-row) { height: 12px; line-height: 10px; }
|
||||
.chief-overview :deep(.chief-header) { height: 28px; }
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.chief-page { width: 500px; min-width: 500px; }
|
||||
.chief-top { grid-template-columns: 89px 89px 1fr 0 0; }
|
||||
.chief-overview { grid-template-columns: repeat(4, 111.25px); }
|
||||
.chief-page {
|
||||
width: 500px;
|
||||
min-width: 500px;
|
||||
}
|
||||
.chief-top {
|
||||
grid-template-columns: 89px 89px 1fr 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ref PageChiefCenter의 24 + 4×238 + 24 행렬과 500px 축소 overview 계약입니다. */
|
||||
.layout-desktop {
|
||||
display: block;
|
||||
}
|
||||
.chief-grid-row {
|
||||
display: grid;
|
||||
grid-template-columns: 24px repeat(4, 238px) 24px;
|
||||
align-items: start;
|
||||
}
|
||||
.turn-index-gutter {
|
||||
display: grid;
|
||||
grid-template-rows: 24px repeat(12, 30px);
|
||||
text-align: center;
|
||||
}
|
||||
.turn-index-gutter span {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.chief-grid-row :deep(.chief-card) {
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
.chief-grid-row :deep(.chief-header) {
|
||||
box-sizing: border-box;
|
||||
height: 24px;
|
||||
min-height: 24px;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
.chief-grid-row :deep(.chief-title) {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.chief-grid-row :deep(.chief-level),
|
||||
.chief-grid-row :deep(.chief-name) {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: inherit;
|
||||
}
|
||||
.chief-grid-row :deep(.chief-level)::after {
|
||||
content: ':';
|
||||
}
|
||||
.chief-grid-row :deep(.chief-row) {
|
||||
box-sizing: border-box;
|
||||
min-height: 30px;
|
||||
height: 30px;
|
||||
grid-template-columns: 55px minmax(0, 1fr);
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
.chief-grid-row :deep(.row-index) {
|
||||
display: none;
|
||||
}
|
||||
.chief-grid-row :deep(.row-time),
|
||||
.chief-grid-row :deep(.row-action) {
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.chief-grid-row :deep(.row-time) {
|
||||
background: #000;
|
||||
}
|
||||
.chief-grid-row :deep(.chief-row:nth-child(odd) .row-action) {
|
||||
background-color: rgba(18, 41, 93, 0.88);
|
||||
}
|
||||
.chief-grid-row :deep(.chief-row:nth-child(even) .row-action) {
|
||||
background-color: rgba(7, 22, 56, 0.88);
|
||||
}
|
||||
|
||||
.layout-mobile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
.chief-overview-frame {
|
||||
width: 500px;
|
||||
height: 320px;
|
||||
margin-top: 56px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.chief-overview {
|
||||
width: 890px;
|
||||
height: 1248px;
|
||||
margin-top: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 222.5px);
|
||||
transform: scale(0.5);
|
||||
transform-origin: left top;
|
||||
}
|
||||
.chief-overview :deep(.chief-card) {
|
||||
width: 222.5px;
|
||||
height: 624px;
|
||||
border: 0;
|
||||
border-left: 1px solid #fff;
|
||||
box-shadow: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.chief-overview :deep(.row-index) {
|
||||
display: none;
|
||||
}
|
||||
.chief-overview :deep(.chief-row) {
|
||||
grid-template-columns: 74px minmax(0, 1fr);
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
}
|
||||
.chief-overview :deep(.row-time),
|
||||
.chief-overview :deep(.row-action) {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.mobile-readonly {
|
||||
width: 308px;
|
||||
min-height: 394px;
|
||||
margin: 10px auto 16px;
|
||||
}
|
||||
.mobile-readonly :deep(.chief-header) {
|
||||
height: 24px;
|
||||
min-height: 24px;
|
||||
}
|
||||
.mobile-readonly :deep(.chief-row) {
|
||||
height: 30px;
|
||||
grid-template-columns: 55px 1fr;
|
||||
padding: 0;
|
||||
}
|
||||
.mobile-readonly :deep(.row-index) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.chief-overview {
|
||||
grid-template-columns: repeat(4, 222.5px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user