feat: 정식 사용자의 오픈 요청과 동의 기반 예약을 영속화한다
This commit is contained in:
@@ -8,14 +8,22 @@ const operationNames = (route: Route): string[] => {
|
||||
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
||||
};
|
||||
|
||||
const installFixture = async (page: Page): Promise<string[]> => {
|
||||
const installFixture = async (page: Page, opener = false): Promise<string[]> => {
|
||||
const operations: string[] = [];
|
||||
let savedRequest: Record<string, unknown> | null = null;
|
||||
let policy = { enabled: true, lifetimeDays: 7, buildLeadMinutes: 10, endorsementThreshold: 2 as number | null };
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem('sammo-session-token', 'regular-user-session');
|
||||
});
|
||||
await page.route('**/gateway/api/trpc/**', async (route) => {
|
||||
expect(route.request().headers()['x-session-token']).toBe('regular-user-session');
|
||||
const results = operationNames(route).map((operation) => {
|
||||
const url = new URL(route.request().url());
|
||||
const raw =
|
||||
route.request().method() === 'POST'
|
||||
? route.request().postDataJSON()
|
||||
: JSON.parse(url.searchParams.get('input') ?? '{}');
|
||||
const results = operationNames(route).map((operation, index) => {
|
||||
const input = url.searchParams.get('batch') === '1' ? raw[String(index)] : raw;
|
||||
operations.push(operation);
|
||||
if (operation === 'me') {
|
||||
return response({
|
||||
@@ -52,6 +60,7 @@ const installFixture = async (page: Page): Promise<string[]> => {
|
||||
]);
|
||||
}
|
||||
if (operation === 'lobby.scenarios') {
|
||||
expect(input).toEqual({ profileName: 'pya:default' });
|
||||
return response([
|
||||
{
|
||||
id: 2701,
|
||||
@@ -77,6 +86,48 @@ const installFixture = async (page: Page): Promise<string[]> => {
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (operation === 'openRequests.list') {
|
||||
expect(input).toEqual({ profileName: 'pya:default' });
|
||||
return response({ policy, canRequest: true, requests: savedRequest ? [savedRequest] : [] });
|
||||
}
|
||||
if (operation === 'admin.openRequests.permissions') {
|
||||
return response({ canAccept: opener, canManagePolicy: opener });
|
||||
}
|
||||
if (operation === 'openRequests.create') {
|
||||
expect(Object.keys(input).sort()).toEqual(['install', 'profileName']);
|
||||
expect(input.profileName).toBe('pya:default');
|
||||
expect(input.install.preopenAt).toBe('2099-08-18T02:30:00.000Z');
|
||||
expect(input.install.openAt).toBe('2099-08-18T03:00:00.000Z');
|
||||
expect(input.install.scenarioId).toBe(2701);
|
||||
expect(input.install).not.toHaveProperty('gitRef');
|
||||
savedRequest = {
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
status: 'PENDING',
|
||||
requesterName: '일반유저',
|
||||
isMine: true,
|
||||
announcement: { scenarioTitle: '아시아 명장전', defaultStatTotal: 310 },
|
||||
install: input.install,
|
||||
expiresAt: '2099-08-18T02:20:00.000Z',
|
||||
createdAt: '2099-08-17T00:00:00.000Z',
|
||||
endorsementCount: 0,
|
||||
endorsed: false,
|
||||
canEndorse: false,
|
||||
operationStatus: null,
|
||||
};
|
||||
return response({ id: savedRequest.id, status: savedRequest.status });
|
||||
}
|
||||
if (operation === 'admin.openRequests.decide') {
|
||||
expect(opener).toBe(true);
|
||||
expect(input).toEqual({ id: savedRequest?.id, action: 'ACCEPT' });
|
||||
savedRequest = { ...savedRequest, status: 'RESERVED', operationStatus: 'QUEUED' };
|
||||
return response({ id: savedRequest.id, status: 'RESERVED' });
|
||||
}
|
||||
if (operation === 'admin.openRequests.updatePolicy') {
|
||||
expect(opener).toBe(true);
|
||||
expect(input.reason).toBe('테스트 서버 정책 변경');
|
||||
policy = input.policy;
|
||||
return response(policy);
|
||||
}
|
||||
throw new Error(`Unhandled tRPC operation: ${operation}`);
|
||||
});
|
||||
const batched = new URL(route.request().url()).searchParams.get('batch') === '1';
|
||||
@@ -95,7 +146,7 @@ test('lets a regular user inspect active-build scenarios and copy an open sugges
|
||||
const operations = await installFixture(page);
|
||||
|
||||
await page.goto('lobby');
|
||||
const suggestionLink = page.getByRole('link', { name: '오픈 건의 양식 작성' });
|
||||
const suggestionLink = page.getByRole('link', { name: '오픈 요청 · 목록' });
|
||||
await expect(suggestionLink).toBeVisible();
|
||||
await suggestionLink.hover();
|
||||
await suggestionLink.focus();
|
||||
@@ -103,8 +154,10 @@ test('lets a regular user inspect active-build scenarios and copy an open sugges
|
||||
await suggestionLink.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/gateway\/open-suggestion$/);
|
||||
await expect(page.getByRole('heading', { name: '오픈 건의 양식' })).toBeVisible();
|
||||
await expect(page.getByText('서버 설정, 시나리오, 오픈 시각은 변경되지 않습니다.')).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: '오픈 요청', exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('복사는 문구만 복사하며, 오픈 요청 등록 버튼을 눌러야 요청이 저장됩니다.')
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId('scenario-summary')).toContainText('310');
|
||||
|
||||
await page.getByTestId('proposal-open').fill('2026-08-18T12:00');
|
||||
@@ -166,7 +219,77 @@ test('lets a regular user inspect active-build scenarios and copy an open sugges
|
||||
await page.screenshot({ path: testInfo.outputPath('open-suggestion-mobile.png'), fullPage: true });
|
||||
|
||||
expect(operations).toContain('lobby.scenarios');
|
||||
expect(operations.every((operation) => ['me', 'lobby.notice', 'lobby.profiles', 'lobby.scenarios'].includes(operation))).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
operations.every((operation) =>
|
||||
[
|
||||
'me',
|
||||
'lobby.notice',
|
||||
'lobby.profiles',
|
||||
'lobby.scenarios',
|
||||
'openRequests.list',
|
||||
'admin.openRequests.permissions',
|
||||
].includes(operation)
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('registers a durable proposal and lets the opener accept and configure it across desktop/mobile', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const operations = await installFixture(page, true);
|
||||
await page.goto('open-suggestion');
|
||||
await expect(page.getByRole('heading', { name: '오픈 요청 목록' })).toBeVisible();
|
||||
await page.getByTestId('proposal-preopen').fill('2099-08-18T11:30');
|
||||
await page.getByTestId('proposal-open').fill('2099-08-18T12:00');
|
||||
const submit = page.getByTestId('submit-open-request');
|
||||
await submit.hover();
|
||||
await submit.focus();
|
||||
await expect(submit).toBeEnabled();
|
||||
await submit.click();
|
||||
const card = page.locator('.request-card');
|
||||
await expect(card).toContainText('일반유저');
|
||||
await expect(card).toContainText('수락 대기');
|
||||
await page.reload();
|
||||
await expect(card).toContainText('아시아 명장전');
|
||||
await card.getByText('요청 설정 전체').click();
|
||||
await expect(card).toContainText('시간 동기화');
|
||||
await card.getByRole('button', { name: '수락하여 오픈 예약' }).hover();
|
||||
await card.getByRole('button', { name: '수락하여 오픈 예약' }).click();
|
||||
await expect(card).toContainText('오픈 예약');
|
||||
await expect(card).toContainText('준비 예약');
|
||||
await expect(card.getByRole('button', { name: '요청 철회' })).toHaveCount(0);
|
||||
await page.getByText('서버 오픈 요청 정책', { exact: true }).click();
|
||||
const policy = page.locator('.request-policy');
|
||||
await policy.getByLabel('가오픈 전 준비 시간(분)').fill('25');
|
||||
await policy.getByLabel('동의로 자동 예약').uncheck();
|
||||
await expect(policy.getByLabel('필요 동의 수(요청자 제외)')).toHaveCount(0);
|
||||
await policy.getByLabel('변경 사유').fill('테스트 서버 정책 변경');
|
||||
await policy.getByRole('button', { name: '정책 저장' }).click();
|
||||
await expect(page.getByText('이 서버는 오프너가 수락하면 예약됩니다.')).toBeVisible();
|
||||
for (const viewport of [
|
||||
{ width: 1200, height: 900 },
|
||||
{ width: 390, height: 844 },
|
||||
]) {
|
||||
await page.setViewportSize(viewport);
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const measurements = await page.locator('.request-list').evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
rect: rect.toJSON(),
|
||||
color: style.color,
|
||||
font: style.font,
|
||||
width: innerWidth,
|
||||
documentWidth: document.documentElement.scrollWidth,
|
||||
html: element.outerHTML,
|
||||
};
|
||||
});
|
||||
expect(measurements.documentWidth).toBe(viewport.width);
|
||||
expect(measurements.rect.right).toBeLessThanOrEqual(viewport.width);
|
||||
await writeFile(testInfo.outputPath(`requests-${viewport.width}.json`), JSON.stringify(measurements, null, 2));
|
||||
await page.screenshot({ path: testInfo.outputPath(`requests-${viewport.width}.png`), fullPage: true });
|
||||
}
|
||||
expect(operations).toContain('openRequests.create');
|
||||
expect(operations).toContain('admin.openRequests.decide');
|
||||
expect(operations).toContain('admin.openRequests.updatePolicy');
|
||||
});
|
||||
|
||||
@@ -802,13 +802,13 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
</div>
|
||||
<div class="p-6 text-center">
|
||||
<p class="m-0 text-sm text-zinc-400">
|
||||
시나리오와 빌드 옵션을 확인하고 운영자에게 전달할 오픈 건의 문구를 만들 수 있습니다.
|
||||
다음 오픈을 요청하고, 다른 사용자의 요청에 동의하거나 진행 상황을 확인할 수 있습니다.
|
||||
</p>
|
||||
<RouterLink
|
||||
to="/open-suggestion"
|
||||
class="open-suggestion-link mt-4 inline-flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-white px-6 py-2 rounded border border-zinc-700 transition-colors"
|
||||
>
|
||||
오픈 건의 양식 작성
|
||||
오픈 요청 · 목록
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { formatServerDateTime, serverDateTimeInputToIso } from '@sammo-ts/common/time/ServerDateTime';
|
||||
import type { AppRouter } from '@sammo-ts/gateway-api';
|
||||
import type { inferRouterOutputs } from '@trpc/server';
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
@@ -24,6 +25,112 @@ const scenarios = ref<Scenario[]>([]);
|
||||
const catalogLoading = ref(false);
|
||||
const catalogError = ref('');
|
||||
const copiedMessage = ref('');
|
||||
const requestState = ref<GatewayOutput['openRequests']['list'] | null>(null);
|
||||
const requestMessage = ref('');
|
||||
const requestError = ref('');
|
||||
const requestBusy = ref(false);
|
||||
const requestLoading = ref(false);
|
||||
const canAccept = ref(false);
|
||||
const canManagePolicy = ref(false);
|
||||
const policyForm = reactive({
|
||||
enabled: true,
|
||||
lifetimeDays: 7,
|
||||
buildLeadMinutes: 10,
|
||||
endorsementThreshold: null as number | null,
|
||||
});
|
||||
const policyReason = ref('');
|
||||
let requestListId = 0;
|
||||
|
||||
const loadRequests = async (): Promise<void> => {
|
||||
const profileName = form.profileName;
|
||||
const requestId = ++requestListId;
|
||||
requestState.value = null;
|
||||
canAccept.value = false;
|
||||
canManagePolicy.value = false;
|
||||
if (!profileName) return;
|
||||
requestLoading.value = true;
|
||||
try {
|
||||
const result = await directTrpc.openRequests.list.query({ profileName });
|
||||
const permissions = await directTrpc.admin.openRequests.permissions.query({ profileName }).catch(() => null);
|
||||
if (requestId !== requestListId) return;
|
||||
requestState.value = result;
|
||||
Object.assign(policyForm, result.policy);
|
||||
canAccept.value = permissions?.canAccept ?? false;
|
||||
canManagePolicy.value = permissions?.canManagePolicy ?? false;
|
||||
} catch (error) {
|
||||
if (requestId === requestListId)
|
||||
requestError.value = error instanceof Error ? error.message : '요청 목록을 불러오지 못했습니다.';
|
||||
} finally {
|
||||
if (requestId === requestListId) requestLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const performRequest = async (work: () => Promise<unknown>, message: string): Promise<void> => {
|
||||
if (requestBusy.value) return;
|
||||
requestBusy.value = true;
|
||||
requestError.value = '';
|
||||
requestMessage.value = '';
|
||||
try {
|
||||
await work();
|
||||
requestMessage.value = message;
|
||||
await loadRequests();
|
||||
} catch (error) {
|
||||
requestError.value = error instanceof Error ? error.message : '요청을 처리하지 못했습니다.';
|
||||
} finally {
|
||||
requestBusy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitRequest = (): Promise<void> =>
|
||||
performRequest(async () => {
|
||||
const preopenAt = serverDateTimeInputToIso(form.preopenAt);
|
||||
const openAt = serverDateTimeInputToIso(form.openAt);
|
||||
if (!preopenAt || !openAt || form.scenarioId === null)
|
||||
throw new Error('가오픈·오픈 시각과 시나리오를 확인해 주세요.');
|
||||
return directTrpc.openRequests.create.mutate({
|
||||
profileName: form.profileName,
|
||||
install: {
|
||||
scenarioId: form.scenarioId,
|
||||
preopenAt,
|
||||
openAt,
|
||||
turnTermMinutes: form.turnTermMinutes,
|
||||
fiction: form.fiction,
|
||||
npcMode: form.npcMode,
|
||||
sync: form.sync,
|
||||
extend: form.extend,
|
||||
blockGeneralCreate: form.blockGeneralCreate,
|
||||
showImgLevel: form.showImgLevel,
|
||||
tournamentTrig: form.tournamentTrig,
|
||||
joinMode: form.joinMode,
|
||||
autorunUser: form.autorunEnabled
|
||||
? { limitMinutes: form.autorunLimitMinutes, options: [...form.autorunOptions] }
|
||||
: null,
|
||||
},
|
||||
});
|
||||
}, '오픈 요청을 등록했습니다.');
|
||||
|
||||
const actOnRequest = (id: string, action: 'ENDORSE' | 'WITHDRAW' | 'ACCEPT' | 'REJECT'): Promise<void> =>
|
||||
performRequest(
|
||||
() =>
|
||||
action === 'ACCEPT' || action === 'REJECT'
|
||||
? directTrpc.admin.openRequests.decide.mutate({ id, action })
|
||||
: directTrpc.openRequests.act.mutate({ id, action }),
|
||||
action === 'ACCEPT'
|
||||
? '오픈 예약으로 전환했습니다.'
|
||||
: action === 'ENDORSE'
|
||||
? '동의했습니다. 동의 수가 충족되면 자동 예약됩니다.'
|
||||
: '요청을 처리했습니다.'
|
||||
);
|
||||
const savePolicy = (): Promise<void> =>
|
||||
performRequest(
|
||||
() =>
|
||||
directTrpc.admin.openRequests.updatePolicy.mutate({
|
||||
profileName: form.profileName,
|
||||
policy: { ...policyForm },
|
||||
reason: policyReason.value,
|
||||
}),
|
||||
'서버 오픈 요청 정책을 저장했습니다.'
|
||||
);
|
||||
const outputElement = ref<HTMLTextAreaElement | null>(null);
|
||||
let catalogRequestId = 0;
|
||||
|
||||
@@ -48,9 +155,7 @@ const form = reactive({
|
||||
|
||||
const selectedProfile = computed(() => profiles.value.find((profile) => profile.profileName === form.profileName));
|
||||
const selectedScenario = computed(() => scenarios.value.find((scenario) => scenario.id === form.scenarioId));
|
||||
const dateOrderValid = computed(
|
||||
() => !form.preopenAt || !form.openAt || new Date(form.preopenAt).getTime() <= new Date(form.openAt).getTime()
|
||||
);
|
||||
const dateOrderValid = computed(() => !form.preopenAt || !form.openAt || form.preopenAt < form.openAt);
|
||||
const canCopy = computed(
|
||||
() =>
|
||||
Boolean(selectedProfile.value && selectedScenario.value && form.preopenAt && form.openAt) &&
|
||||
@@ -64,6 +169,14 @@ const formatProposalDate = (value: string): string => {
|
||||
return normalized.length === 16 ? `${normalized}:00` : normalized;
|
||||
};
|
||||
|
||||
const operationStatusText = (status: string): string =>
|
||||
({
|
||||
QUEUED: '준비 예약',
|
||||
RUNNING: '준비 중',
|
||||
SUCCEEDED: '준비 완료',
|
||||
FAILED: '준비 실패 · 오프너 확인 필요',
|
||||
CANCELLED: '예약 취소',
|
||||
})[status] ?? status;
|
||||
const npcModeText = (value: number): string => ['불가', '가능', '선택 생성 가능'][value] ?? '불가';
|
||||
const fictionText = (value: number): string => (value === 1 ? '가상' : '사실');
|
||||
const autorunText = computed(() => {
|
||||
@@ -138,7 +251,12 @@ const loadScenarios = async (): Promise<void> => {
|
||||
|
||||
watch(
|
||||
() => form.profileName,
|
||||
() => void loadScenarios()
|
||||
() => {
|
||||
requestError.value = '';
|
||||
requestMessage.value = '';
|
||||
void loadScenarios();
|
||||
void loadRequests();
|
||||
}
|
||||
);
|
||||
watch(selectedScenario, (scenario) => {
|
||||
if (scenario?.fiction === 0 || scenario?.fiction === 1) form.fiction = scenario.fiction;
|
||||
@@ -174,16 +292,185 @@ onMounted(async () => {
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<p class="eyebrow">GATEWAY COMMUNITY TOOL</p>
|
||||
<h1>오픈 건의 양식</h1>
|
||||
<p>현재 서버 빌드의 시나리오와 빌드 옵션을 살펴보고, 운영자에게 전달할 문구를 만듭니다.</p>
|
||||
<h1>오픈 요청</h1>
|
||||
<p>통일되었거나 닫힌 서버의 다음 오픈을 요청하고, 다른 사용자의 요청에 동의할 수 있습니다.</p>
|
||||
</div>
|
||||
<RouterLink class="back-link" to="/lobby">서버 목록으로</RouterLink>
|
||||
</header>
|
||||
|
||||
<p class="read-only-notice" role="note">
|
||||
이 화면은 조회와 문구 작성만 합니다. 서버 설정, 시나리오, 오픈 시각은 변경되지 않습니다.
|
||||
이 화면은 조회와 문구 작성만 합니다. 복사는 문구만 복사하며, 오픈 요청 등록 버튼을 눌러야 요청이
|
||||
저장됩니다.
|
||||
</p>
|
||||
|
||||
<section class="panel request-list" aria-labelledby="requests-heading">
|
||||
<div class="output-heading">
|
||||
<h2 id="requests-heading">오픈 요청 목록</h2>
|
||||
<button type="button" :disabled="requestLoading || requestBusy" @click="loadRequests">
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="requestMessage" role="status">{{ requestMessage }}</p>
|
||||
<p v-if="requestError" class="field-error" role="alert">{{ requestError }}</p>
|
||||
<template v-if="requestState">
|
||||
<p class="section-help">
|
||||
요청은 {{ requestState.policy.lifetimeDays }}일 동안, 늦어도 빌드 시작 전까지 모집합니다. 가오픈
|
||||
{{ requestState.policy.buildLeadMinutes }}분 전 서버 패치 정책에 따라 준비합니다.
|
||||
<span v-if="requestState.policy.endorsementThreshold !== null"
|
||||
>요청자 외 정식 사용자 {{ requestState.policy.endorsementThreshold }}명의 동의로 자동
|
||||
예약됩니다.</span
|
||||
>
|
||||
<span v-else>이 서버는 오프너가 수락하면 예약됩니다.</span>
|
||||
</p>
|
||||
<p v-if="!requestState.canRequest" class="section-help">
|
||||
현재 오픈 요청을 등록할 수 없습니다. 서버 상태와 정식 참여 자격을 확인해 주세요.
|
||||
</p>
|
||||
<p v-if="requestState.requests.length === 0">대기 중인 오픈 요청이 없습니다.</p>
|
||||
<article
|
||||
v-for="request in requestState.requests"
|
||||
:key="request.id"
|
||||
class="request-card"
|
||||
:data-request-id="request.id"
|
||||
>
|
||||
<h3>{{ request.requesterName }} · {{ request.announcement.scenarioTitle }}</h3>
|
||||
<p>
|
||||
{{ request.status === 'RESERVED' ? '오픈 예약' : '수락 대기'
|
||||
}}<span v-if="request.operationStatus">
|
||||
· {{ operationStatusText(request.operationStatus) }}</span
|
||||
>
|
||||
</p>
|
||||
<p>
|
||||
가오픈 {{ formatServerDateTime(request.install.preopenAt) }} / 오픈
|
||||
{{ formatServerDateTime(request.install.openAt) }} (서버 시간 UTC+9)
|
||||
</p>
|
||||
<p>
|
||||
{{ request.install.turnTermMinutes }}분 턴 · 상성
|
||||
{{ request.install.fiction === 1 ? '가상' : '사실' }} · 빙의
|
||||
{{ npcModeText(request.install.npcMode) }} · 최대 스탯
|
||||
{{ request.announcement.defaultStatTotal }}
|
||||
</p>
|
||||
<details>
|
||||
<summary>요청 설정 전체</summary>
|
||||
<dl class="request-settings">
|
||||
<dt>시간 동기화</dt>
|
||||
<dd>{{ request.install.sync ? '사용' : '사용 안 함' }}</dd>
|
||||
<dt>확장 NPC</dt>
|
||||
<dd>{{ request.install.extend ? '포함' : '미포함' }}</dd>
|
||||
<dt>장수 생성</dt>
|
||||
<dd>{{ ['가능', '불가', '장수명 무작위'][request.install.blockGeneralCreate] }}</dd>
|
||||
<dt>임관</dt>
|
||||
<dd>{{ request.install.joinMode === 'full' ? '자유 임관' : '랜덤 임관' }}</dd>
|
||||
<dt>이미지 표시</dt>
|
||||
<dd>{{ ['표시 안 함', '전콘', '전콘·병종', '전체'][request.install.showImgLevel] }}</dd>
|
||||
<dt>토너먼트</dt>
|
||||
<dd>{{ request.install.tournamentTrig ? '자동 시작' : '수동 시작' }}</dd>
|
||||
<dt>자율행동</dt>
|
||||
<dd>
|
||||
{{
|
||||
request.install.autorunUser
|
||||
? `${request.install.autorunUser.limitMinutes}분`
|
||||
: '사용 안 함'
|
||||
}}
|
||||
<template v-if="request.install.autorunUser">
|
||||
·
|
||||
{{
|
||||
RESET_AUTORUN_LABELS.filter((entry) =>
|
||||
request.install.autorunUser?.options.includes(entry.value)
|
||||
)
|
||||
.map((entry) => entry.label)
|
||||
.join(', ')
|
||||
}}</template
|
||||
>
|
||||
</dd>
|
||||
</dl>
|
||||
</details>
|
||||
<p v-if="request.status === 'PENDING'">
|
||||
모집 종료 {{ formatServerDateTime(request.expiresAt) }}
|
||||
</p>
|
||||
<p v-if="requestState.policy.endorsementThreshold !== null">
|
||||
동의 {{ request.endorsementCount }}명 /
|
||||
{{ requestState.policy.endorsementThreshold }}명<span v-if="request.endorsed">
|
||||
· 동의 완료</span
|
||||
>
|
||||
</p>
|
||||
<div class="request-actions" v-if="request.status === 'PENDING'">
|
||||
<button
|
||||
v-if="request.canEndorse"
|
||||
type="button"
|
||||
:disabled="requestBusy || request.endorsed"
|
||||
@click="actOnRequest(request.id, 'ENDORSE')"
|
||||
>
|
||||
{{ request.endorsed ? '동의 완료' : '동의' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="request.isMine"
|
||||
type="button"
|
||||
:disabled="requestBusy"
|
||||
@click="actOnRequest(request.id, 'WITHDRAW')"
|
||||
>
|
||||
요청 철회
|
||||
</button>
|
||||
<button
|
||||
v-if="canAccept"
|
||||
type="button"
|
||||
:disabled="requestBusy"
|
||||
@click="actOnRequest(request.id, 'ACCEPT')"
|
||||
>
|
||||
수락하여 오픈 예약
|
||||
</button>
|
||||
<button
|
||||
v-if="canAccept"
|
||||
type="button"
|
||||
:disabled="requestBusy"
|
||||
@click="actOnRequest(request.id, 'REJECT')"
|
||||
>
|
||||
요청 거절
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
<details v-if="canManagePolicy" class="request-policy">
|
||||
<summary>서버 오픈 요청 정책</summary>
|
||||
<div class="field-grid">
|
||||
<label><span>요청 접수</span><input v-model="policyForm.enabled" type="checkbox" /></label>
|
||||
<label
|
||||
><span>모집 기간(일)</span
|
||||
><input v-model.number="policyForm.lifetimeDays" type="number" min="1" max="365"
|
||||
/></label>
|
||||
<label
|
||||
><span>가오픈 전 준비 시간(분)</span
|
||||
><input v-model.number="policyForm.buildLeadMinutes" type="number" min="1" max="10080"
|
||||
/></label>
|
||||
<label
|
||||
><span>동의로 자동 예약</span
|
||||
><input
|
||||
:checked="policyForm.endorsementThreshold !== null"
|
||||
type="checkbox"
|
||||
@change="
|
||||
policyForm.endorsementThreshold =
|
||||
policyForm.endorsementThreshold === null ? 5 : null
|
||||
"
|
||||
/></label>
|
||||
<label v-if="policyForm.endorsementThreshold !== null"
|
||||
><span>필요 동의 수(요청자 제외)</span
|
||||
><input
|
||||
v-model.number="policyForm.endorsementThreshold"
|
||||
type="number"
|
||||
min="1"
|
||||
max="10000"
|
||||
/></label>
|
||||
<label><span>변경 사유</span><input v-model="policyReason" maxlength="200" /></label>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="requestBusy || policyReason.trim().length < 3"
|
||||
@click="savePolicy"
|
||||
>
|
||||
정책 저장
|
||||
</button>
|
||||
</details>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="panel" aria-labelledby="proposal-basic-heading">
|
||||
<h2 id="proposal-basic-heading">기본 정보</h2>
|
||||
<div class="field-grid">
|
||||
@@ -208,34 +495,58 @@ onMounted(async () => {
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>가오픈 일시</span>
|
||||
<span>가오픈 일시 (서버 시간 UTC+9)</span>
|
||||
<input v-model="form.preopenAt" type="datetime-local" step="1" data-testid="proposal-preopen" />
|
||||
</label>
|
||||
<label>
|
||||
<span>오픈 일시</span>
|
||||
<span>오픈 일시 (서버 시간 UTC+9)</span>
|
||||
<input v-model="form.openAt" type="datetime-local" step="1" data-testid="proposal-open" />
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="catalogLoading" class="field-status" role="status">활성 빌드의 시나리오를 확인하고 있습니다.</p>
|
||||
<p v-if="catalogLoading" class="field-status" role="status">
|
||||
활성 빌드의 시나리오를 확인하고 있습니다.
|
||||
</p>
|
||||
<p v-else-if="catalogError" class="field-error" role="alert">
|
||||
{{ catalogError }}
|
||||
<button type="button" @click="loadScenarios">다시 확인</button>
|
||||
</p>
|
||||
<p v-if="!dateOrderValid" class="field-error" role="alert">가오픈 일시는 오픈 일시보다 늦을 수 없습니다.</p>
|
||||
<p v-if="!dateOrderValid" class="field-error" role="alert">
|
||||
가오픈 일시는 오픈 일시보다 늦을 수 없습니다.
|
||||
</p>
|
||||
|
||||
<dl v-if="selectedScenario" class="scenario-summary" data-testid="scenario-summary">
|
||||
<div><dt>시작 연도</dt><dd>{{ selectedScenario.year ?? '-' }}년</dd></div>
|
||||
<div><dt>최대 스탯</dt><dd>{{ selectedScenario.defaultStatTotal }}</dd></div>
|
||||
<div><dt>기본 NPC</dt><dd>{{ selectedScenario.npcCount }}명</dd></div>
|
||||
<div><dt>확장 NPC</dt><dd>{{ selectedScenario.npcExCount }}명</dd></div>
|
||||
<div><dt>중립 NPC</dt><dd>{{ selectedScenario.npcNeutralCount }}명</dd></div>
|
||||
<div><dt>국가</dt><dd>{{ selectedScenario.nations.length }}개</dd></div>
|
||||
<div>
|
||||
<dt>시작 연도</dt>
|
||||
<dd>{{ selectedScenario.year ?? '-' }}년</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>최대 스탯</dt>
|
||||
<dd>{{ selectedScenario.defaultStatTotal }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>기본 NPC</dt>
|
||||
<dd>{{ selectedScenario.npcCount }}명</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>확장 NPC</dt>
|
||||
<dd>{{ selectedScenario.npcExCount }}명</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>중립 NPC</dt>
|
||||
<dd>{{ selectedScenario.npcNeutralCount }}명</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>국가</dt>
|
||||
<dd>{{ selectedScenario.nations.length }}개</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="panel" aria-labelledby="proposal-options-heading">
|
||||
<h2 id="proposal-options-heading">빌드 옵션</h2>
|
||||
<p class="section-help">선택은 아래 미리보기에만 반영됩니다. 각 설명은 실제 초기화 옵션의 의미입니다.</p>
|
||||
<p class="section-help">
|
||||
선택한 설정은 오픈 요청에 저장됩니다. 각 설명은 실제 초기화 옵션의 의미입니다.
|
||||
</p>
|
||||
<div class="field-grid option-grid">
|
||||
<label>
|
||||
<span>{{ RESET_OPTION_COPY.turnTerm.label }}</span>
|
||||
@@ -345,11 +656,35 @@ onMounted(async () => {
|
||||
<p class="copy-status" role="status">{{ copiedMessage }}</p>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<p>
|
||||
요청자는 빌드 시각이나 배포 버전을 지정하지 않습니다. 서버 정책에 따라 가오픈 전에 준비하며, 수락된
|
||||
뒤에는 요청을 철회할 수 없습니다.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="submit-open-request"
|
||||
:disabled="!canCopy || !requestState?.canRequest || requestBusy || requestLoading"
|
||||
@click="submitRequest"
|
||||
>
|
||||
오픈 요청 등록
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<details class="panel catalog-panel">
|
||||
<summary>시나리오 목록 {{ scenarios.length }}개 보기</summary>
|
||||
<div class="catalog-table-frame">
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>시나리오</th><th>시작</th><th>최대 스탯</th><th>NPC</th><th>국가</th></tr></thead>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>시나리오</th>
|
||||
<th>시작</th>
|
||||
<th>최대 스탯</th>
|
||||
<th>NPC</th>
|
||||
<th>국가</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="scenario in scenarios" :key="scenario.id">
|
||||
<td>{{ scenario.id }}</td>
|
||||
@@ -368,6 +703,50 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.request-card {
|
||||
margin: 16px 0;
|
||||
padding: 16px;
|
||||
border: 1px solid #52525b;
|
||||
border-radius: 6px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.request-card h3 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.request-settings {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 4px 12px;
|
||||
}
|
||||
.request-card pre {
|
||||
overflow: auto;
|
||||
max-width: 100%;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.request-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.request-policy {
|
||||
margin-top: 16px;
|
||||
}
|
||||
button {
|
||||
min-height: 36px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #71717a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
button:focus-visible {
|
||||
outline: 2px solid #a5b4fc;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.suggestion-page {
|
||||
box-sizing: border-box;
|
||||
width: min(100% - 32px, 960px);
|
||||
@@ -390,10 +769,28 @@ onMounted(async () => {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.page-header h1 { font-size: 28px; line-height: 1.25; }
|
||||
.page-header p:not(.eyebrow), .section-help, .output-heading p { margin: 8px 0 0; color: #a1a1aa; }
|
||||
.eyebrow { margin: 0 0 6px; color: #fb923c; font-size: 12px; font-weight: 700; letter-spacing: .12em; }
|
||||
.back-link { flex: 0 0 auto; color: #fdba74; text-underline-offset: 3px; }
|
||||
.page-header h1 {
|
||||
font-size: 28px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.page-header p:not(.eyebrow),
|
||||
.section-help,
|
||||
.output-heading p {
|
||||
margin: 8px 0 0;
|
||||
color: #a1a1aa;
|
||||
}
|
||||
.eyebrow {
|
||||
margin: 0 0 6px;
|
||||
color: #fb923c;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
.back-link {
|
||||
flex: 0 0 auto;
|
||||
color: #fdba74;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.read-only-notice {
|
||||
margin: 22px 0;
|
||||
@@ -413,13 +810,37 @@ onMounted(async () => {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.panel h2 { font-size: 20px; }
|
||||
.field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin-top: 18px; }
|
||||
.field-grid label, .autorun-limit { display: flex; min-width: 0; flex-direction: column; gap: 6px; }
|
||||
.field-grid label > span, .autorun-limit > span { color: #fafafa; font-size: 14px; font-weight: 700; }
|
||||
.field-grid small { color: #a1a1aa; font-size: 12px; line-height: 1.45; }
|
||||
.panel h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
.field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.field-grid label,
|
||||
.autorun-limit {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.field-grid label > span,
|
||||
.autorun-limit > span {
|
||||
color: #fafafa;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.field-grid small {
|
||||
color: #a1a1aa;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
select, input[type='datetime-local'], textarea {
|
||||
select,
|
||||
input[type='datetime-local'],
|
||||
textarea {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
border: 1px solid #52525b;
|
||||
@@ -430,30 +851,94 @@ select, input[type='datetime-local'], textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
select:focus-visible, input:focus-visible, textarea:focus-visible, button:focus-visible, summary:focus-visible, .back-link:focus-visible {
|
||||
select:focus-visible,
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
button:focus-visible,
|
||||
summary:focus-visible,
|
||||
.back-link:focus-visible {
|
||||
outline: 2px solid #fb923c;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.field-status { color: #a1a1aa; }
|
||||
.field-error { color: #fca5a5; }
|
||||
.field-error button { border: 0; background: transparent; color: #fdba74; text-decoration: underline; cursor: pointer; }
|
||||
.field-status {
|
||||
color: #a1a1aa;
|
||||
}
|
||||
.field-error {
|
||||
color: #fca5a5;
|
||||
}
|
||||
.field-error button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #fdba74;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.scenario-summary { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); margin: 18px 0 0; border: 1px solid #3f3f46; }
|
||||
.scenario-summary div { min-width: 0; border-right: 1px solid #3f3f46; padding: 10px; text-align: center; }
|
||||
.scenario-summary div:last-child { border-right: 0; }
|
||||
.scenario-summary dt { color: #a1a1aa; font-size: 12px; }
|
||||
.scenario-summary dd { margin: 4px 0 0; color: #fff; font-weight: 700; }
|
||||
.scenario-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
margin: 18px 0 0;
|
||||
border: 1px solid #3f3f46;
|
||||
}
|
||||
.scenario-summary div {
|
||||
min-width: 0;
|
||||
border-right: 1px solid #3f3f46;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.scenario-summary div:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
.scenario-summary dt {
|
||||
color: #a1a1aa;
|
||||
font-size: 12px;
|
||||
}
|
||||
.scenario-summary dd {
|
||||
margin: 4px 0 0;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.toggle-grid, .checkbox-list { display: flex; flex-wrap: wrap; gap: 10px 18px; }
|
||||
.toggle-grid { margin-top: 20px; border-top: 1px solid #3f3f46; padding-top: 16px; }
|
||||
.toggle-grid label, .checkbox-list label { display: inline-flex; align-items: center; gap: 7px; }
|
||||
.autorun-options { margin-top: 18px; border: 1px solid #3f3f46; border-radius: 4px; padding: 16px; }
|
||||
.autorun-options legend { padding: 0 8px; color: #fff; font-weight: 700; }
|
||||
.autorun-limit { width: min(100%, 260px); margin-top: 16px; }
|
||||
.toggle-grid,
|
||||
.checkbox-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 18px;
|
||||
}
|
||||
.toggle-grid {
|
||||
margin-top: 20px;
|
||||
border-top: 1px solid #3f3f46;
|
||||
padding-top: 16px;
|
||||
}
|
||||
.toggle-grid label,
|
||||
.checkbox-list label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
.autorun-options {
|
||||
margin-top: 18px;
|
||||
border: 1px solid #3f3f46;
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
}
|
||||
.autorun-options legend {
|
||||
padding: 0 8px;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
.autorun-limit {
|
||||
width: min(100%, 260px);
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.output-heading { align-items: center; }
|
||||
.output-heading h2 { margin: 0; }
|
||||
.output-heading {
|
||||
align-items: center;
|
||||
}
|
||||
.output-heading h2 {
|
||||
margin: 0;
|
||||
}
|
||||
.output-heading button {
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid #f97316;
|
||||
@@ -464,25 +949,84 @@ select:focus-visible, input:focus-visible, textarea:focus-visible, button:focus-
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
.output-heading button:disabled { border-color: #3f3f46; background: #27272a; color: #71717a; cursor: default; }
|
||||
.suggestion-output { margin-top: 16px; resize: vertical; line-height: 1.6; white-space: pre-wrap; }
|
||||
.copy-status { min-height: 20px; margin: 8px 0 0; color: #86efac; }
|
||||
.output-heading button:disabled {
|
||||
border-color: #3f3f46;
|
||||
background: #27272a;
|
||||
color: #71717a;
|
||||
cursor: default;
|
||||
}
|
||||
.suggestion-output {
|
||||
margin-top: 16px;
|
||||
resize: vertical;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.copy-status {
|
||||
min-height: 20px;
|
||||
margin: 8px 0 0;
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.catalog-panel summary { cursor: pointer; color: #fdba74; font-weight: 700; }
|
||||
.catalog-table-frame { margin-top: 14px; overflow-x: auto; }
|
||||
.catalog-table-frame table { width: 100%; min-width: 680px; border-collapse: collapse; }
|
||||
.catalog-table-frame th, .catalog-table-frame td { border-bottom: 1px solid #3f3f46; padding: 9px; text-align: left; }
|
||||
.catalog-table-frame th { color: #a1a1aa; font-size: 12px; }
|
||||
.catalog-panel summary {
|
||||
cursor: pointer;
|
||||
color: #fdba74;
|
||||
font-weight: 700;
|
||||
}
|
||||
.catalog-table-frame {
|
||||
margin-top: 14px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.catalog-table-frame table {
|
||||
width: 100%;
|
||||
min-width: 680px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.catalog-table-frame th,
|
||||
.catalog-table-frame td {
|
||||
border-bottom: 1px solid #3f3f46;
|
||||
padding: 9px;
|
||||
text-align: left;
|
||||
}
|
||||
.catalog-table-frame th {
|
||||
color: #a1a1aa;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.suggestion-page { width: min(100% - 24px, 960px); padding-top: 96px; }
|
||||
.page-header { flex-direction: column; gap: 10px; }
|
||||
.field-grid, .scenario-summary { grid-template-columns: minmax(0, 1fr); }
|
||||
.scenario-summary div { display: flex; justify-content: space-between; border-right: 0; border-bottom: 1px solid #3f3f46; text-align: left; }
|
||||
.scenario-summary div:last-child { border-bottom: 0; }
|
||||
.scenario-summary dd { margin: 0; }
|
||||
.panel { padding: 16px; }
|
||||
.output-heading { align-items: stretch; flex-direction: column; }
|
||||
.output-heading button { width: 100%; }
|
||||
.suggestion-page {
|
||||
width: min(100% - 24px, 960px);
|
||||
padding-top: 96px;
|
||||
}
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.field-grid,
|
||||
.scenario-summary {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.scenario-summary div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid #3f3f46;
|
||||
text-align: left;
|
||||
}
|
||||
.scenario-summary div:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.scenario-summary dd {
|
||||
margin: 0;
|
||||
}
|
||||
.panel {
|
||||
padding: 16px;
|
||||
}
|
||||
.output-heading {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
.output-heading button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user