feat: 환경 설정으로 메뉴를 확장하고 전용 아이콘 변경을 이동
This commit is contained in:
@@ -75,6 +75,8 @@ type FixtureState = {
|
|||||||
dieOnPrestartAttempts?: number;
|
dieOnPrestartAttempts?: number;
|
||||||
dieOnPrestartInputs?: Array<Record<string, unknown>>;
|
dieOnPrestartInputs?: Array<Record<string, unknown>>;
|
||||||
generalMeQueries?: number;
|
generalMeQueries?: number;
|
||||||
|
generalMeFailure?: boolean;
|
||||||
|
canChangeIcon?: boolean;
|
||||||
generalLogQueries?: number;
|
generalLogQueries?: number;
|
||||||
ensurePrestartQueries?: number;
|
ensurePrestartQueries?: number;
|
||||||
ensurePrestartFailure?: 'TIMEOUT' | 'INTERNAL_SERVER_ERROR';
|
ensurePrestartFailure?: 'TIMEOUT' | 'INTERNAL_SERVER_ERROR';
|
||||||
@@ -285,7 +287,7 @@ const myGeneral = (state: FixtureState) => ({
|
|||||||
},
|
},
|
||||||
penalties: {},
|
penalties: {},
|
||||||
iconChoices: state.iconChoices ?? [],
|
iconChoices: state.iconChoices ?? [],
|
||||||
canChangeIcon: true,
|
canChangeIcon: state.canChangeIcon ?? true,
|
||||||
iconChangeAvailableAt: null,
|
iconChangeAvailableAt: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -467,6 +469,15 @@ const install = async (page: Page, state: FixtureState) => {
|
|||||||
}
|
}
|
||||||
if (operation === 'general.me') {
|
if (operation === 'general.me') {
|
||||||
state.generalMeQueries = (state.generalMeQueries ?? 0) + 1;
|
state.generalMeQueries = (state.generalMeQueries ?? 0) + 1;
|
||||||
|
if (state.generalMeFailure) {
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
message: '아이콘 조회 실패',
|
||||||
|
code: -32603,
|
||||||
|
data: { code: 'INTERNAL_SERVER_ERROR', httpStatus: 500, path: operation },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
return response(myGeneral(state));
|
return response(myGeneral(state));
|
||||||
}
|
}
|
||||||
if (operation === 'general.ensureDieOnPrestartStatus') {
|
if (operation === 'general.ensureDieOnPrestartStatus') {
|
||||||
@@ -518,9 +529,7 @@ const install = async (page: Page, state: FixtureState) => {
|
|||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
turntime: '2026-01-01T00:00:00.000Z',
|
turntime: '2026-01-01T00:00:00.000Z',
|
||||||
lastTurnTime: state.gameStarted
|
lastTurnTime: state.gameStarted ? '2026-03-01T00:00:00.000Z' : '2026-01-01T00:00:00.000Z',
|
||||||
? '2026-03-01T00:00:00.000Z'
|
|
||||||
: '2026-01-01T00:00:00.000Z',
|
|
||||||
opentime: state.buildNationCandidateEnabled
|
opentime: state.buildNationCandidateEnabled
|
||||||
? '2026-02-01T00:00:00.000Z'
|
? '2026-02-01T00:00:00.000Z'
|
||||||
: '2025-12-01T00:00:00.000Z',
|
: '2025-12-01T00:00:00.000Z',
|
||||||
@@ -1126,9 +1135,7 @@ test('커맨드 실행 결과의 성공·실패·일반 색상을 메인 기록
|
|||||||
]) {
|
]) {
|
||||||
await page.setViewportSize(viewport);
|
await page.setViewportSize(viewport);
|
||||||
await page.goto('');
|
await page.goto('');
|
||||||
const colors = await inspectResultColors(
|
const colors = await inspectResultColors(`${viewport.selector} [data-record-bucket="general"] .record-line`);
|
||||||
`${viewport.selector} [data-record-bucket="general"] .record-line`
|
|
||||||
);
|
|
||||||
await persistParityArtifact(page, `core-main-command-result-colors-${viewport.name}`, colors);
|
await persistParityArtifact(page, `core-main-command-result-colors-${viewport.name}`, colors);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1408,12 +1415,7 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
|
|||||||
const promotionAutomation = page.getByRole('checkbox', { name: '자동 수뇌 임명' });
|
const promotionAutomation = page.getByRole('checkbox', { name: '자동 수뇌 임명' });
|
||||||
const financeAutomation = page.getByRole('checkbox', { name: '자동 세율·지급률 조정' });
|
const financeAutomation = page.getByRole('checkbox', { name: '자동 세율·지급률 조정' });
|
||||||
const capitalAutomation = page.getByRole('checkbox', { name: '자동 천도' });
|
const capitalAutomation = page.getByRole('checkbox', { name: '자동 천도' });
|
||||||
for (const checkbox of [
|
for (const checkbox of [warAutomation, promotionAutomation, financeAutomation, capitalAutomation]) {
|
||||||
warAutomation,
|
|
||||||
promotionAutomation,
|
|
||||||
financeAutomation,
|
|
||||||
capitalAutomation,
|
|
||||||
]) {
|
|
||||||
await expect(checkbox).not.toBeChecked();
|
await expect(checkbox).not.toBeChecked();
|
||||||
await checkbox.check();
|
await checkbox.check();
|
||||||
}
|
}
|
||||||
@@ -1719,14 +1721,14 @@ test('내 정보&설정의 지난 플레이는 기본 탐색과 분리되어 오
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('화면 설정에서 화면 폭과 개인 CSS를 저장하고 게임 설정 API는 호출하지 않는다', async ({ page }, testInfo) => {
|
test('환경 설정에서 화면 폭과 개인 CSS를 저장하고 게임 설정 API는 호출하지 않는다', async ({ page }, testInfo) => {
|
||||||
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
|
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
|
||||||
await install(page, state);
|
await install(page, state);
|
||||||
await page.setViewportSize({ width: 1000, height: 900 });
|
await page.setViewportSize({ width: 1000, height: 900 });
|
||||||
await page.goto('my-settings');
|
await page.goto('my-settings');
|
||||||
await waitForVisualAssets(page);
|
await waitForVisualAssets(page);
|
||||||
|
|
||||||
await expect(page.locator('.title-row')).toContainText('화 면 설 정');
|
await expect(page.locator('.title-row')).toContainText('환 경 설 정');
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText('이 설정은 이 기기의 화면 표시만 바꾸며 게임 상태에는 영향을 주지 않습니다.')
|
page.getByText('이 설정은 이 기기의 화면 표시만 바꾸며 게임 상태에는 영향을 주지 않습니다.')
|
||||||
).toHaveCount(0);
|
).toHaveCount(0);
|
||||||
@@ -1773,7 +1775,7 @@ test('화면 설정에서 화면 폭과 개인 CSS를 저장하고 게임 설정
|
|||||||
await persistParityArtifact(page, 'core-interface-settings-desktop', desktop);
|
await persistParityArtifact(page, 'core-interface-settings-desktop', desktop);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('화면 설정에서 모바일 메인 패널을 드래그하거나 버튼으로 재정렬하고 기본 순서로 복원한다', async ({
|
test('환경 설정에서 모바일 메인 패널을 드래그하거나 버튼으로 재정렬하고 기본 순서로 복원한다', async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
|
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
|
||||||
@@ -1848,7 +1850,7 @@ test('화면 설정에서 모바일 메인 패널을 드래그하거나 버튼
|
|||||||
.toEqual(defaultOrder);
|
.toEqual(defaultOrder);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('화면 설정에서 실제 모바일 터치로 메인 패널 순서를 재정렬한다', async ({ browser }, testInfo) => {
|
test('환경 설정에서 실제 모바일 터치로 메인 패널 순서를 재정렬한다', async ({ browser }, testInfo) => {
|
||||||
const configuredBaseUrl = testInfo.project.use.baseURL;
|
const configuredBaseUrl = testInfo.project.use.baseURL;
|
||||||
if (typeof configuredBaseUrl !== 'string') {
|
if (typeof configuredBaseUrl !== 'string') {
|
||||||
throw new Error('Playwright baseURL is required for the mobile touch contract');
|
throw new Error('Playwright baseURL is required for the mobile touch contract');
|
||||||
@@ -1944,36 +1946,121 @@ for (const [label, failure] of [
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
test('내 정보에서 사람 장수의 등록 전콘을 골라 변경한다', async ({ page }) => {
|
for (const viewport of [
|
||||||
const iconId = '3f804277-584f-4f44-b39c-9ecf40d1ed31';
|
{ name: 'desktop', width: 1000, height: 900 },
|
||||||
|
{ name: 'mobile', width: 390, height: 844 },
|
||||||
|
]) {
|
||||||
|
test(`환경 설정에서 사람 장수의 등록 전콘을 골라 변경한다 ${viewport.name}`, async ({ page }) => {
|
||||||
|
await page.setViewportSize(viewport);
|
||||||
|
const iconId = '3f804277-584f-4f44-b39c-9ecf40d1ed31';
|
||||||
|
const state: FixtureState = {
|
||||||
|
permission: 'member',
|
||||||
|
myset: 1,
|
||||||
|
settingMutations: [],
|
||||||
|
accessPages: [],
|
||||||
|
iconChoices: [
|
||||||
|
{
|
||||||
|
id: iconId,
|
||||||
|
picture: 'mine.png',
|
||||||
|
imageServer: 1,
|
||||||
|
createdAt: '2026-08-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
adjustIconInputs: [],
|
||||||
|
};
|
||||||
|
await install(page, state);
|
||||||
|
await page.goto('my-page');
|
||||||
|
await expect(page.locator('#set_my_setting')).toBeVisible();
|
||||||
|
await expect(page.locator('.general-icon-action')).toHaveCount(0);
|
||||||
|
const prestartQueries = state.ensurePrestartQueries;
|
||||||
|
await page.route('**/mine.png', async (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: 'image/svg+xml',
|
||||||
|
body: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#306090"/></svg>',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.goto('my-settings');
|
||||||
|
await expect(page.getByText('전용 아이콘 변경 (24시간에 1회)')).toBeVisible();
|
||||||
|
await expect(page.locator('.selected-general-icon img')).toHaveCSS('width', '64px');
|
||||||
|
await expect(page.locator('.selected-general-icon img')).toHaveCSS('height', '64px');
|
||||||
|
await page.locator('.general-icon-choice input').check();
|
||||||
|
await expect(page.locator('.general-icon-choice input')).toBeChecked();
|
||||||
|
await page.getByRole('button', { name: '아이콘 변경' }).focus();
|
||||||
|
await expect(page.getByRole('button', { name: '아이콘 변경' })).toBeFocused();
|
||||||
|
await page.getByRole('button', { name: '아이콘 변경' }).hover();
|
||||||
|
await waitForVisualAssets(page);
|
||||||
|
const geometry = await page.locator('#interface-settings').evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const icon = element.querySelector<HTMLImageElement>('.selected-general-icon img')!;
|
||||||
|
return {
|
||||||
|
url: location.href,
|
||||||
|
width: rect.width,
|
||||||
|
scrollWidth: document.documentElement.scrollWidth,
|
||||||
|
icon: {
|
||||||
|
width: icon.width,
|
||||||
|
height: icon.height,
|
||||||
|
naturalWidth: icon.naturalWidth,
|
||||||
|
naturalHeight: icon.naturalHeight,
|
||||||
|
objectFit: getComputedStyle(icon).objectFit,
|
||||||
|
},
|
||||||
|
columns: getComputedStyle(element.querySelector('.settings-grid')!).gridTemplateColumns,
|
||||||
|
html: element.outerHTML,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(geometry.width).toBe(viewport.width);
|
||||||
|
expect(geometry.scrollWidth).toBe(viewport.width);
|
||||||
|
expect(geometry.icon).toMatchObject({ width: 64, height: 64, objectFit: 'cover' });
|
||||||
|
expect(geometry.icon.naturalWidth).toBeGreaterThan(0);
|
||||||
|
await persistParityArtifact(page, `core-preferences-icon-${viewport.name}`, geometry);
|
||||||
|
page.once('dialog', async (dialog) => dialog.dismiss());
|
||||||
|
await page.getByRole('button', { name: '아이콘 변경' }).click();
|
||||||
|
expect(state.adjustIconInputs).toHaveLength(0);
|
||||||
|
page.once('dialog', async (dialog) => dialog.accept());
|
||||||
|
await page.getByRole('button', { name: '아이콘 변경' }).click();
|
||||||
|
await expect.poll(() => state.adjustIconInputs?.length ?? 0).toBe(1);
|
||||||
|
expect(state.adjustIconInputs?.[0]).toMatchObject({ iconId });
|
||||||
|
await expect.poll(() => state.generalMeQueries).toBe(3);
|
||||||
|
expect(state.ensurePrestartQueries).toBe(prestartQueries);
|
||||||
|
expect(state.settingMutations).toHaveLength(0);
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator('.general-icon-action')).toBeVisible();
|
||||||
|
expect(state.adjustIconInputs?.[0]?.clientRequestId).toMatch(
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('환경 설정의 아이콘 조회 오류는 화면 설정을 막지 않고 재시도할 수 있다', async ({ page }) => {
|
||||||
const state: FixtureState = {
|
const state: FixtureState = {
|
||||||
permission: 'member',
|
permission: 'member',
|
||||||
myset: 1,
|
myset: 1,
|
||||||
settingMutations: [],
|
settingMutations: [],
|
||||||
accessPages: [],
|
accessPages: [],
|
||||||
|
generalMeFailure: true,
|
||||||
iconChoices: [
|
iconChoices: [
|
||||||
{
|
{
|
||||||
id: iconId,
|
id: '3f804277-584f-4f44-b39c-9ecf40d1ed31',
|
||||||
picture: 'mine.png',
|
picture: 'mine.png',
|
||||||
imageServer: 1,
|
imageServer: 1,
|
||||||
createdAt: '2026-08-01T00:00:00.000Z',
|
createdAt: '2026-08-01T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
adjustIconInputs: [],
|
|
||||||
};
|
};
|
||||||
await install(page, state);
|
await install(page, state);
|
||||||
await page.goto('my-page');
|
await page.goto('my-settings');
|
||||||
await expect(page.getByText('전용 아이콘 변경 (24시간에 1회)')).toBeVisible();
|
await expect(page.getByRole('alert')).toContainText('아이콘 조회 실패');
|
||||||
await expect(page.locator('.selected-general-icon img')).toHaveCSS('width', '64px');
|
await page.getByRole('radio', { name: '500px' }).check();
|
||||||
await expect(page.locator('.selected-general-icon img')).toHaveCSS('height', '64px');
|
await expect.poll(() => page.evaluate(() => localStorage.getItem('sam.screenMode'))).toBe('500px');
|
||||||
await page.locator('.general-icon-choice input').check();
|
state.generalMeFailure = false;
|
||||||
page.once('dialog', async (dialog) => dialog.accept());
|
await page.getByRole('button', { name: '다시 불러오기' }).click();
|
||||||
await page.getByRole('button', { name: '아이콘 변경' }).click();
|
await expect(page.locator('.general-icon-action')).toBeVisible();
|
||||||
await expect.poll(() => state.adjustIconInputs?.length ?? 0).toBe(1);
|
state.canChangeIcon = false;
|
||||||
expect(state.adjustIconInputs?.[0]).toMatchObject({ iconId });
|
await page.reload();
|
||||||
expect(state.adjustIconInputs?.[0]?.clientRequestId).toMatch(
|
await expect.poll(() => state.generalMeQueries).toBe(3);
|
||||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
await expect(page.locator('.general-icon-action')).toHaveCount(0);
|
||||||
);
|
await expect(page.locator('#custom_css')).toBeVisible();
|
||||||
|
expect(state.ensurePrestartQueries ?? 0).toBe(0);
|
||||||
|
expect(state.settingMutations).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('내 정보 아이템 파기 확인은 이름의 받침에 맞는 조사를 쓴다', async ({ page }) => {
|
test('내 정보 아이템 파기 확인은 이름의 받침에 맞는 조사를 쓴다', async ({ page }) => {
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ export const nationNavigation: MainNavigationEntry[] = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ kind: 'link', id: 'my-settings', label: '화면 설정', to: '/my-settings', access: 'always' },
|
{ kind: 'link', id: 'my-settings', label: '환경 설정', to: '/my-settings', access: 'always' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const buildNationNavigation = (
|
export const buildNationNavigation = (
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { formatLog } from '../utils/formatLog';
|
|||||||
import { formatSeoulDateTime } from '../utils/legacyDateTime';
|
import { formatSeoulDateTime } from '../utils/legacyDateTime';
|
||||||
import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic/scenario/scenarioEffect.js';
|
import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic/scenario/scenarioEffect.js';
|
||||||
import { useSessionStore } from '../stores/session';
|
import { useSessionStore } from '../stores/session';
|
||||||
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
|
||||||
import GeneralInformationPanel from '../components/main/GeneralInformationPanel.vue';
|
import GeneralInformationPanel from '../components/main/GeneralInformationPanel.vue';
|
||||||
import { useGameFeedback } from '../composables/useGameFeedback';
|
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||||
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
|
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
|
||||||
@@ -46,7 +45,6 @@ const dieOnPrestartStatusLoading = ref(false);
|
|||||||
const dieOnPrestartStatusError = ref<string | null>(null);
|
const dieOnPrestartStatusError = ref<string | null>(null);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const selectedIconId = ref('');
|
|
||||||
const session = useSessionStore();
|
const session = useSessionStore();
|
||||||
const readPendingDieOnPrestartId = (): string => {
|
const readPendingDieOnPrestartId = (): string => {
|
||||||
const stored = window.sessionStorage.getItem(PENDING_DIE_ON_PRESTART_KEY);
|
const stored = window.sessionStorage.getItem(PENDING_DIE_ON_PRESTART_KEY);
|
||||||
@@ -169,8 +167,6 @@ const items = computed<Array<{ key: ItemSlotKey; slotName: string; displayName:
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
const iconChoices = computed(() => data.value?.iconChoices ?? []);
|
|
||||||
const selectedIcon = computed(() => iconChoices.value.find((icon) => icon.id === selectedIconId.value) ?? null);
|
|
||||||
const autorunUser = computed(() => asRecord(world.value?.meta.autorun_user));
|
const autorunUser = computed(() => asRecord(world.value?.meta.autorun_user));
|
||||||
const showAutoNationTurn = computed(() => asRecord(autorunUser.value.options).chief !== false);
|
const showAutoNationTurn = computed(() => asRecord(autorunUser.value.options).chief !== false);
|
||||||
const showUserRulerAutomation = computed(() => showAutoNationTurn.value && (data.value?.general.npcState ?? 2) < 2);
|
const showUserRulerAutomation = computed(() => showAutoNationTurn.value && (data.value?.general.npcState ?? 2) < 2);
|
||||||
@@ -259,10 +255,6 @@ const loadPage = async (resetImmediateActionIds = true) => {
|
|||||||
use_auto_nation_finance: general.settings.use_auto_nation_finance,
|
use_auto_nation_finance: general.settings.use_auto_nation_finance,
|
||||||
use_auto_nation_capital: general.settings.use_auto_nation_capital,
|
use_auto_nation_capital: general.settings.use_auto_nation_capital,
|
||||||
});
|
});
|
||||||
selectedIconId.value =
|
|
||||||
iconChoices.value.find((icon) => icon.picture === general.general.picture)?.id ??
|
|
||||||
iconChoices.value[0]?.id ??
|
|
||||||
'';
|
|
||||||
}
|
}
|
||||||
await Promise.all(logTypes.map((type) => loadLog(type)));
|
await Promise.all(logTypes.map((type) => loadLog(type)));
|
||||||
if (resetImmediateActionIds) {
|
if (resetImmediateActionIds) {
|
||||||
@@ -275,20 +267,6 @@ const loadPage = async (resetImmediateActionIds = true) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const changeGeneralIcon = async () => {
|
|
||||||
if (!selectedIconId.value) return;
|
|
||||||
if (!confirm('선택한 전용 아이콘으로 바꿀까요? 변경 후 24시간 동안 다시 바꿀 수 없습니다.')) return;
|
|
||||||
try {
|
|
||||||
await trpc.general.adjustIcon.mutate({
|
|
||||||
iconId: selectedIconId.value,
|
|
||||||
clientRequestId: crypto.randomUUID(),
|
|
||||||
});
|
|
||||||
await loadPage();
|
|
||||||
} catch (cause) {
|
|
||||||
showErrorToast(`전용 아이콘 변경에 실패했습니다: ${errorText(cause)}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveSettings = async () => {
|
const saveSettings = async () => {
|
||||||
if (!canSave.value) return;
|
if (!canSave.value) return;
|
||||||
try {
|
try {
|
||||||
@@ -447,12 +425,7 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
<legend>사용자 군주 자동 업무</legend>
|
<legend>사용자 군주 자동 업무</legend>
|
||||||
<label>
|
<label>
|
||||||
<input
|
<input v-model="form.use_auto_nation_war" type="checkbox" :true-value="1" :false-value="0" />
|
||||||
v-model="form.use_auto_nation_war"
|
|
||||||
type="checkbox"
|
|
||||||
:true-value="1"
|
|
||||||
:false-value="0"
|
|
||||||
/>
|
|
||||||
자동 선전포고
|
자동 선전포고
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
@@ -526,41 +499,6 @@ onMounted(() => {
|
|||||||
휴가 신청
|
휴가 신청
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="data?.canChangeIcon && iconChoices.length" class="action-line general-icon-action">
|
|
||||||
전용 아이콘 변경 (24시간에 1회)<br />
|
|
||||||
<span v-if="data.iconChangeAvailableAt" class="hint">
|
|
||||||
다음 변경 가능: {{ formatSeoulDateTime(data.iconChangeAvailableAt) }}
|
|
||||||
</span>
|
|
||||||
<div v-if="selectedIcon" class="selected-general-icon" aria-live="polite">
|
|
||||||
<img
|
|
||||||
:src="resolveGeneralIconUrl(selectedIcon)"
|
|
||||||
width="64"
|
|
||||||
height="64"
|
|
||||||
alt=""
|
|
||||||
@error="useDefaultGeneralIcon"
|
|
||||||
/>
|
|
||||||
<strong>{{ data.general.name }}</strong>
|
|
||||||
</div>
|
|
||||||
<div class="general-icon-list" role="radiogroup" aria-label="장수 전용 아이콘 선택">
|
|
||||||
<label v-for="icon in iconChoices" :key="icon.id" class="general-icon-choice">
|
|
||||||
<input v-model="selectedIconId" type="radio" :value="icon.id" />
|
|
||||||
<img
|
|
||||||
:src="resolveGeneralIconUrl(icon)"
|
|
||||||
width="64"
|
|
||||||
height="64"
|
|
||||||
alt=""
|
|
||||||
@error="useDefaultGeneralIcon"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="legacy-button legacy-button--lumen legacy-button--fixed-height action-button"
|
|
||||||
type="button"
|
|
||||||
@click="changeGeneralIcon"
|
|
||||||
>
|
|
||||||
아이콘 변경
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div v-if="actionAvailability.dieOnPrestart" class="action-line">
|
<div v-if="actionAvailability.dieOnPrestart" class="action-line">
|
||||||
가오픈 기간 내 장수 삭제 ({{ formatDieOnPrestartAvailableAt }} 부터)<br />
|
가오픈 기간 내 장수 삭제 ({{ formatDieOnPrestartAvailableAt }} 부터)<br />
|
||||||
<button
|
<button
|
||||||
@@ -877,35 +815,6 @@ button:disabled {
|
|||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
.general-icon-list {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 6px;
|
|
||||||
margin: 6px 0;
|
|
||||||
}
|
|
||||||
.selected-general-icon {
|
|
||||||
display: flex;
|
|
||||||
max-width: 260px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 10px;
|
|
||||||
margin: 8px auto;
|
|
||||||
padding: 6px 10px;
|
|
||||||
border: 1px solid #666;
|
|
||||||
background: rgb(23 42 82 / 70%);
|
|
||||||
}
|
|
||||||
.selected-general-icon img {
|
|
||||||
flex: 0 0 var(--sammo-general-icon-size);
|
|
||||||
width: var(--sammo-general-icon-size);
|
|
||||||
height: var(--sammo-general-icon-size);
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
.general-icon-choice {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
@media (max-width: 991px) {
|
@media (max-width: 991px) {
|
||||||
.legacy-page {
|
.legacy-page {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
import { trpc } from '../utils/trpc';
|
||||||
|
import { formatSeoulDateTime } from '../utils/legacyDateTime';
|
||||||
|
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
||||||
import SortableStringList from '../components/ui/SortableStringList';
|
import SortableStringList from '../components/ui/SortableStringList';
|
||||||
import { useGameFeedback } from '../composables/useGameFeedback';
|
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||||
import { applyCustomCss, CUSTOM_CSS_KEY } from '../utils/customCss';
|
import { applyCustomCss, CUSTOM_CSS_KEY } from '../utils/customCss';
|
||||||
@@ -18,7 +21,52 @@ import {
|
|||||||
type ScreenMode,
|
type ScreenMode,
|
||||||
} from '../utils/screenModeViewport';
|
} from '../utils/screenModeViewport';
|
||||||
|
|
||||||
const { success: showSuccessToast } = useGameFeedback();
|
const { success: showSuccessToast, error: showErrorToast } = useGameFeedback();
|
||||||
|
type MyGeneralResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
|
||||||
|
const data = ref<MyGeneralResponse | null>(null);
|
||||||
|
const selectedIconId = ref('');
|
||||||
|
const iconLoading = ref(false);
|
||||||
|
const iconSaving = ref(false);
|
||||||
|
const iconError = ref<string | null>(null);
|
||||||
|
const iconChoices = computed(() => data.value?.iconChoices ?? []);
|
||||||
|
const selectedIcon = computed(() => iconChoices.value.find((icon) => icon.id === selectedIconId.value) ?? null);
|
||||||
|
const errorText = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause));
|
||||||
|
|
||||||
|
const loadIcons = async () => {
|
||||||
|
iconLoading.value = true;
|
||||||
|
iconError.value = null;
|
||||||
|
try {
|
||||||
|
const general = await trpc.general.me.query();
|
||||||
|
data.value = general;
|
||||||
|
selectedIconId.value =
|
||||||
|
iconChoices.value.find((icon) => icon.picture === general?.general.picture)?.id ??
|
||||||
|
iconChoices.value[0]?.id ??
|
||||||
|
'';
|
||||||
|
} catch (cause) {
|
||||||
|
iconError.value = errorText(cause);
|
||||||
|
} finally {
|
||||||
|
iconLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeGeneralIcon = async () => {
|
||||||
|
if (!selectedIconId.value || iconSaving.value) return;
|
||||||
|
if (!confirm('선택한 전용 아이콘으로 바꿀까요? 변경 후 24시간 동안 다시 바꿀 수 없습니다.')) return;
|
||||||
|
iconSaving.value = true;
|
||||||
|
try {
|
||||||
|
await trpc.general.adjustIcon.mutate({
|
||||||
|
iconId: selectedIconId.value,
|
||||||
|
clientRequestId: crypto.randomUUID(),
|
||||||
|
});
|
||||||
|
showSuccessToast('전용 아이콘을 변경했습니다.');
|
||||||
|
await loadIcons();
|
||||||
|
} catch (cause) {
|
||||||
|
showErrorToast(`전용 아이콘 변경에 실패했습니다: ${errorText(cause)}`);
|
||||||
|
} finally {
|
||||||
|
iconSaving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const screenMode = ref<ScreenMode>('auto');
|
const screenMode = ref<ScreenMode>('auto');
|
||||||
const customCss = ref('');
|
const customCss = ref('');
|
||||||
const cssSaving = ref(false);
|
const cssSaving = ref(false);
|
||||||
@@ -67,6 +115,7 @@ watch(customCss, (text) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
void loadIcons();
|
||||||
screenMode.value = normalizeScreenMode(window.localStorage.getItem(SCREEN_MODE_KEY));
|
screenMode.value = normalizeScreenMode(window.localStorage.getItem(SCREEN_MODE_KEY));
|
||||||
customCss.value = window.localStorage.getItem(CUSTOM_CSS_KEY) ?? '';
|
customCss.value = window.localStorage.getItem(CUSTOM_CSS_KEY) ?? '';
|
||||||
});
|
});
|
||||||
@@ -82,7 +131,7 @@ onBeforeUnmount(() => {
|
|||||||
<template>
|
<template>
|
||||||
<main id="interface-settings" class="legacy-page bg0 interface-settings-page">
|
<main id="interface-settings" class="legacy-page bg0 interface-settings-page">
|
||||||
<div class="title-row">
|
<div class="title-row">
|
||||||
<span>화 면 설 정</span>
|
<span>환 경 설 정</span>
|
||||||
<RouterLink class="legacy-button legacy-button--navigation" to="/">돌아가기</RouterLink>
|
<RouterLink class="legacy-button legacy-button--navigation" to="/">돌아가기</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -125,6 +174,55 @@ onBeforeUnmount(() => {
|
|||||||
<p class="css-hint">변경 사항은 이 기기에 자동 저장됩니다.</p>
|
<p class="css-hint">변경 사항은 이 기기에 자동 저장됩니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
<article
|
||||||
|
v-if="iconLoading || iconError || (data?.canChangeIcon && iconChoices.length)"
|
||||||
|
class="settings-column"
|
||||||
|
>
|
||||||
|
<h2 class="section-title">전용 아이콘</h2>
|
||||||
|
<p v-if="iconLoading" class="settings-content" role="status">아이콘 목록을 불러오는 중입니다.</p>
|
||||||
|
<div v-else-if="iconError" class="settings-content" role="alert">
|
||||||
|
<p>{{ iconError }}</p>
|
||||||
|
<button class="legacy-button legacy-button--secondary" type="button" @click="loadIcons">
|
||||||
|
다시 불러오기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="data?.canChangeIcon && iconChoices.length" class="settings-content general-icon-action">
|
||||||
|
전용 아이콘 변경 (24시간에 1회)<br />
|
||||||
|
<span v-if="data.iconChangeAvailableAt" class="css-hint">
|
||||||
|
다음 변경 가능: {{ formatSeoulDateTime(data.iconChangeAvailableAt) }}
|
||||||
|
</span>
|
||||||
|
<div v-if="selectedIcon" class="selected-general-icon" aria-live="polite">
|
||||||
|
<img
|
||||||
|
:src="resolveGeneralIconUrl(selectedIcon)"
|
||||||
|
width="64"
|
||||||
|
height="64"
|
||||||
|
alt=""
|
||||||
|
@error="useDefaultGeneralIcon"
|
||||||
|
/>
|
||||||
|
<strong>{{ data.general.name }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="general-icon-list" role="radiogroup" aria-label="장수 전용 아이콘 선택">
|
||||||
|
<label v-for="icon in iconChoices" :key="icon.id" class="general-icon-choice">
|
||||||
|
<input v-model="selectedIconId" type="radio" :value="icon.id" />
|
||||||
|
<img
|
||||||
|
:src="resolveGeneralIconUrl(icon)"
|
||||||
|
width="64"
|
||||||
|
height="64"
|
||||||
|
alt=""
|
||||||
|
@error="useDefaultGeneralIcon"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="legacy-button legacy-button--primary"
|
||||||
|
type="button"
|
||||||
|
:disabled="iconSaving || iconLoading"
|
||||||
|
@click="changeGeneralIcon"
|
||||||
|
>
|
||||||
|
아이콘 변경
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer class="legacy-credit">
|
<footer class="legacy-credit">
|
||||||
@@ -413,6 +511,38 @@ onBeforeUnmount(() => {
|
|||||||
.mobile-layout-dialog__actions .mobile-layout-apply {
|
.mobile-layout-dialog__actions .mobile-layout-apply {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
.general-icon-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 6px 0;
|
||||||
|
}
|
||||||
|
.selected-general-icon {
|
||||||
|
display: flex;
|
||||||
|
max-width: 260px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 8px auto;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid #666;
|
||||||
|
background: rgb(23 42 82 / 70%);
|
||||||
|
}
|
||||||
|
.selected-general-icon img {
|
||||||
|
flex: 0 0 var(--sammo-general-icon-size);
|
||||||
|
width: var(--sammo-general-icon-size);
|
||||||
|
height: var(--sammo-general-icon-size);
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.general-icon-choice {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.general-icon-action {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
@media (max-width: 939.98px) {
|
@media (max-width: 939.98px) {
|
||||||
.settings-grid {
|
.settings-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
Reference in New Issue
Block a user