fix: 인게임 작업 상태를 공용 toast로 통일한다
This commit is contained in:
@@ -299,12 +299,16 @@ test('resource auction preserves the legacy desktop structure, geometry, and int
|
||||
const bidInput = page.getByRole('spinbutton', { name: '1번 경매 입찰가' });
|
||||
await bidInput.fill('800');
|
||||
await page.getByRole('button', { name: '입찰', exact: true }).click();
|
||||
await expect(page.getByRole('alert')).toContainText('금이 부족합니다.');
|
||||
await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="error"]')).toContainText(
|
||||
'금이 부족합니다.'
|
||||
);
|
||||
await page.screenshot({ path: 'test-results/auction/resource-desktop-error.png', fullPage: true });
|
||||
expect(state.resourceBidCount).toBe(0);
|
||||
|
||||
await page.getByRole('button', { name: '입찰', exact: true }).click();
|
||||
await expect(page.getByRole('status')).toContainText('입찰했습니다.');
|
||||
await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="success"]')).toContainText(
|
||||
'입찰했습니다.'
|
||||
);
|
||||
expect(state.resourceBidCount).toBe(1);
|
||||
|
||||
await firstRow.hover();
|
||||
@@ -370,7 +374,9 @@ test('unique auction separates ongoing and finished lists and auto-loads the leg
|
||||
await dialog.accept();
|
||||
});
|
||||
await page.getByRole('button', { name: '입찰', exact: true }).click();
|
||||
await expect(page.getByRole('status')).toContainText('입찰이 완료되었습니다.');
|
||||
await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="success"]')).toContainText(
|
||||
'입찰이 완료되었습니다.'
|
||||
);
|
||||
expect(state.uniqueBidCount).toBe(1);
|
||||
await page.screenshot({ path: 'test-results/auction/unique-desktop.png', fullPage: true });
|
||||
});
|
||||
|
||||
@@ -596,11 +596,15 @@ test('finance enforces edit permissions and preserves the old value across an AP
|
||||
const input = page.getByRole('spinbutton', { name: '세율' });
|
||||
await input.fill('25');
|
||||
await page.locator('.policy-cell').filter({ hasText: '세율' }).getByRole('button', { name: '변경' }).click();
|
||||
await expect(page.getByRole('alert')).toContainText('세율을 변경할 수 없습니다.');
|
||||
await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="error"]')).toContainText(
|
||||
'세율을 변경할 수 없습니다.'
|
||||
);
|
||||
await expect(input).toHaveValue('20');
|
||||
await input.fill('25');
|
||||
await page.locator('.policy-cell').filter({ hasText: '세율' }).getByRole('button', { name: '변경' }).click();
|
||||
await expect(page.getByRole('status')).toContainText('세율을 변경했습니다.');
|
||||
await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="success"]')).toContainText(
|
||||
'세율을 변경했습니다.'
|
||||
);
|
||||
expect(state.rate).toBe(25);
|
||||
|
||||
const readOnly = await page.context().newPage();
|
||||
|
||||
@@ -282,7 +282,9 @@ test('desktop geometry, typography, textures, drag, focus, tooltip, and successf
|
||||
await goldInput.fill('12345');
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
await page.locator('#container > .control_bar').getByRole('button', { name: '설정' }).click();
|
||||
await expect(page.getByRole('status')).toContainText('NPC 정책이 반영되었습니다.');
|
||||
await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="success"]')).toContainText(
|
||||
'NPC 정책이 반영되었습니다.'
|
||||
);
|
||||
expect(state.mutations).toContain('npc.setNationPolicy');
|
||||
await screenshot(page, 'core-npc-policy-desktop.png');
|
||||
});
|
||||
|
||||
@@ -372,7 +372,9 @@ test('join refresh shows the assigned preliminary group immediately with accessi
|
||||
await expect(page.getByLabel('토너먼트 대진표')).toHaveCount(0);
|
||||
await join.click();
|
||||
|
||||
await expect(page.getByRole('status')).toHaveText('참가 신청이 반영되었습니다. 六조에 배정되었습니다.');
|
||||
await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="success"]')).toContainText(
|
||||
'참가 신청이 반영되었습니다. 六조에 배정되었습니다.'
|
||||
);
|
||||
await expect(join).toBeDisabled();
|
||||
const preliminaryTabs = page.getByRole('tablist', { name: '예선 조 선택' });
|
||||
await expect(preliminaryTabs.getByRole('tab').nth(5)).toHaveAttribute('aria-selected', 'true');
|
||||
@@ -413,7 +415,9 @@ test('desktop join scrolls the assigned preliminary group into view without futu
|
||||
await expect(page.getByLabel('토너먼트 대진표')).toHaveCount(0);
|
||||
await page.getByRole('button', { name: '참가' }).click();
|
||||
|
||||
await expect(page.getByRole('status')).toHaveText('참가 신청이 반영되었습니다. 八조에 배정되었습니다.');
|
||||
await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="success"]')).toContainText(
|
||||
'참가 신청이 반영되었습니다. 八조에 배정되었습니다.'
|
||||
);
|
||||
const assignedGroup = page.locator('[data-preliminary-group="7"]');
|
||||
await expect(assignedGroup.locator('.general-identity', { hasText: names[0] })).toBeVisible();
|
||||
const bounds = await assignedGroup.boundingBox();
|
||||
@@ -612,7 +616,9 @@ test('mobile betting rankings use tabs and keep dedicated icons beside general n
|
||||
);
|
||||
await dialog.getByRole('button', { name: '베팅 등록' }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await expect(page.getByRole('status')).toHaveText('베팅이 등록되었습니다.');
|
||||
await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="success"]')).toContainText(
|
||||
'베팅이 등록되었습니다.'
|
||||
);
|
||||
expect(placedBets).toEqual([{ targetId: 1, amount: 50 }]);
|
||||
|
||||
await expect(page.getByRole('tablist', { name: '토너먼트 랭킹 종목 선택' })).toBeVisible();
|
||||
|
||||
@@ -387,12 +387,16 @@ test('shows API failure then creates a troop successfully', async ({ page }) =>
|
||||
const input = page.getByRole('textbox', { name: '부대명' });
|
||||
await input.fill('실패대');
|
||||
await page.getByRole('button', { name: '부대 창설', exact: true }).click();
|
||||
await expect(page.getByRole('alert')).toContainText('부대 이름이 없습니다.');
|
||||
await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="error"]')).toContainText(
|
||||
'부대 이름이 없습니다.'
|
||||
);
|
||||
expect(state.me.troopId).toBe(0);
|
||||
|
||||
await input.fill('신규대');
|
||||
await page.getByRole('button', { name: '부대 창설', exact: true }).click();
|
||||
await expect(page.getByRole('status')).toContainText('신규대 부대가 생성되었습니다.');
|
||||
await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="success"]')).toContainText(
|
||||
'신규대 부대가 생성되었습니다.'
|
||||
);
|
||||
await expect(page.locator('.troopInfo').filter({ hasText: '신규대' })).toBeVisible();
|
||||
await expect(input).toBeHidden();
|
||||
});
|
||||
@@ -410,13 +414,17 @@ test('renames and kicks through confirm dialogs, then refreshes state', async ({
|
||||
await page.getByRole('button', { name: '부대명 변경...' }).first().click();
|
||||
await page.getByRole('textbox', { name: '새 부대명' }).fill('백마의종');
|
||||
await page.getByRole('button', { name: '변경', exact: true }).click();
|
||||
await expect(page.getByRole('status')).toContainText('부대명을 변경했습니다.');
|
||||
await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="success"]')).toContainText(
|
||||
'부대명을 변경했습니다.'
|
||||
);
|
||||
await expect(page.locator('.troopInfo').filter({ hasText: '백마의종' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: '부대원 추방...' }).click();
|
||||
await page.getByRole('combobox', { name: '추방할 부대원' }).selectOption('3');
|
||||
await page.getByRole('button', { name: '추방', exact: true }).click();
|
||||
await expect(page.getByRole('status')).toContainText('조운을 추방했습니다.');
|
||||
await expect(
|
||||
page.locator('[data-testid="game-toast"][data-feedback-kind="success"]').filter({ hasText: '조운' })
|
||||
).toContainText('조운을 추방했습니다.');
|
||||
await expect(page.locator('.troopMembers').first()).not.toContainText('조운');
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRoute } from 'vue-router';
|
||||
|
||||
import { formatLog } from '../utils/formatLog';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||
|
||||
type AuctionOverview = Awaited<ReturnType<typeof trpc.auction.getOverview.query>>;
|
||||
type ResourceAuction = AuctionOverview['resourceAuctions'][number];
|
||||
@@ -16,12 +17,12 @@ const activeTab = ref<'resource' | 'unique'>(route.query.type === 'unique' ? 'un
|
||||
const loading = ref(false);
|
||||
const actionBusy = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const message = ref<string | null>(null);
|
||||
const overview = ref<AuctionOverview | null>(null);
|
||||
const selectedResource = ref<ResourceAuction | null>(null);
|
||||
const selectedUnique = ref<UniqueAuction | null>(null);
|
||||
const uniqueDetail = ref<UniqueDetail | null>(null);
|
||||
const bidAmount = ref(0);
|
||||
const { success: showSuccessToast, error: showErrorToast } = useGameFeedback();
|
||||
|
||||
const openForm = reactive({
|
||||
type: 'BUY_RICE' as 'BUY_RICE' | 'SELL_RICE',
|
||||
@@ -111,12 +112,11 @@ const runAction = async (action: () => Promise<void>): Promise<void> => {
|
||||
}
|
||||
actionBusy.value = true;
|
||||
error.value = null;
|
||||
message.value = null;
|
||||
try {
|
||||
await action();
|
||||
await loadOverview();
|
||||
} catch (err) {
|
||||
error.value = resolveErrorMessage(err);
|
||||
showErrorToast(resolveErrorMessage(err));
|
||||
} finally {
|
||||
actionBusy.value = false;
|
||||
}
|
||||
@@ -134,7 +134,7 @@ const openResourceAuction = (): Promise<void> =>
|
||||
openForm.type === 'BUY_RICE'
|
||||
? await trpc.auction.openBuyRice.mutate(input)
|
||||
: await trpc.auction.openSellRice.mutate(input);
|
||||
message.value = `${result.auctionId}번 경매로 등록되었습니다.`;
|
||||
showSuccessToast(`${result.auctionId}번 경매로 등록되었습니다.`);
|
||||
});
|
||||
|
||||
const bidResourceAuction = (): Promise<void> =>
|
||||
@@ -151,7 +151,7 @@ const bidResourceAuction = (): Promise<void> =>
|
||||
} else {
|
||||
await trpc.auction.bidSellRice.mutate({ auctionId: auction.id, amount: bidAmount.value });
|
||||
}
|
||||
message.value = '입찰했습니다.';
|
||||
showSuccessToast('입찰했습니다.');
|
||||
});
|
||||
|
||||
const bidUniqueAuction = (): Promise<void> =>
|
||||
@@ -172,14 +172,13 @@ const bidUniqueAuction = (): Promise<void> =>
|
||||
amount: bidAmount.value,
|
||||
tryExtendCloseDate: false,
|
||||
});
|
||||
message.value = '입찰이 완료되었습니다.';
|
||||
showSuccessToast('입찰이 완료되었습니다.');
|
||||
});
|
||||
|
||||
const closeWindow = (): void => window.close();
|
||||
|
||||
watch(activeTab, (tab) => {
|
||||
error.value = null;
|
||||
message.value = null;
|
||||
if (tab === 'unique' && !selectedUnique.value && ongoingUnique.value[0]) {
|
||||
void selectUnique(ongoingUnique.value[0]);
|
||||
}
|
||||
@@ -230,7 +229,6 @@ onMounted(() => {
|
||||
</header>
|
||||
|
||||
<p v-if="error" class="auction-notice error" role="alert">{{ error }}</p>
|
||||
<p v-if="message" class="auction-notice success" role="status">{{ message }}</p>
|
||||
<div v-if="loading && !overview" class="loading-state">불러오는 중...</div>
|
||||
|
||||
<section v-else-if="activeTab === 'resource'" class="resource-auction bg0">
|
||||
@@ -756,9 +754,6 @@ input:focus-visible {
|
||||
.auction-notice.error {
|
||||
background: #842029;
|
||||
}
|
||||
.auction-notice.success {
|
||||
background: #0f5132;
|
||||
}
|
||||
.loading-state {
|
||||
min-height: 120px;
|
||||
display: grid;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { computed, nextTick, onMounted, ref } from 'vue';
|
||||
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
|
||||
import TournamentPageHeader from '../components/tournament/TournamentPageHeader.vue';
|
||||
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
|
||||
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||
import type { TournamentBracketSlot } from '../utils/tournamentBracket';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
@@ -14,7 +15,6 @@ const summary = ref<Awaited<ReturnType<typeof trpc.tournament.getBettingSummary.
|
||||
const rankings = ref<Awaited<ReturnType<typeof trpc.tournament.getRankings.query>>>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const message = ref<string | null>(null);
|
||||
const amounts = ref<Record<number, number>>({});
|
||||
const selectedTarget = ref<TournamentBracketSlot | null>(null);
|
||||
const betDialog = ref<HTMLDialogElement | null>(null);
|
||||
@@ -22,6 +22,7 @@ const betAmountSelect = ref<HTMLSelectElement | null>(null);
|
||||
const placingBet = ref(false);
|
||||
const betError = ref<string | null>(null);
|
||||
const activeRankingPrefix = ref('tt');
|
||||
const { success: showSuccessToast } = useGameFeedback();
|
||||
const typeNames = ['전력전', '통솔전', '일기토', '설전'];
|
||||
const stageNames = [
|
||||
'경기 없음',
|
||||
@@ -109,17 +110,15 @@ const placeBet = async () => {
|
||||
const targetId = selectedTarget.value?.id;
|
||||
if (targetId === null || targetId === undefined || placingBet.value) return;
|
||||
const amount = selectedAmount.value;
|
||||
message.value = null;
|
||||
betError.value = null;
|
||||
placingBet.value = true;
|
||||
try {
|
||||
await trpc.tournament.placeBet.mutate({ targetId, amount });
|
||||
message.value = '베팅이 등록되었습니다.';
|
||||
showSuccessToast('베팅이 등록되었습니다.');
|
||||
await load();
|
||||
closeBetDialog();
|
||||
} catch (value) {
|
||||
betError.value = errorText(value);
|
||||
message.value = betError.value;
|
||||
} finally {
|
||||
placingBet.value = false;
|
||||
}
|
||||
@@ -138,7 +137,6 @@ const placeBet = async () => {
|
||||
갱신
|
||||
</button>
|
||||
<span v-if="loading">불러오는 중...</span>
|
||||
<span v-if="message" role="status">{{ message }}</span>
|
||||
</section>
|
||||
<section v-if="error" class="error bg0" role="alert">{{ error }}</section>
|
||||
<section class="state bg0">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
|
||||
import { JosaUtil } from '@sammo-ts/common/util/JosaUtil';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||
|
||||
type InheritStatus = Awaited<ReturnType<typeof trpc.inherit.getStatus.query>>;
|
||||
type InheritLog = Awaited<ReturnType<typeof trpc.inherit.getLogs.query>>[number];
|
||||
@@ -110,9 +111,8 @@ const logs = ref<InheritLog[]>([]);
|
||||
const logLoading = ref(false);
|
||||
const logEnd = ref(false);
|
||||
|
||||
const actionError = ref<string | null>(null);
|
||||
const actionMessage = ref<string | null>(null);
|
||||
const actionBusy = ref(false);
|
||||
const { success: showSuccessToast, error: showErrorToast } = useGameFeedback();
|
||||
|
||||
const joinConfig = ref<JoinConfig | null>(null);
|
||||
|
||||
@@ -262,7 +262,6 @@ const syncSelections = () => {
|
||||
const loadStatus = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
actionError.value = null;
|
||||
try {
|
||||
status.value = await trpc.inherit.getStatus.query();
|
||||
syncSelections();
|
||||
@@ -292,7 +291,7 @@ const loadLogs = async (reset = false) => {
|
||||
logs.value = reset ? result : [...logs.value, ...result];
|
||||
logEnd.value = result.length < 30;
|
||||
} catch (err) {
|
||||
actionError.value = resolveErrorMessage(err);
|
||||
showErrorToast(resolveErrorMessage(err));
|
||||
} finally {
|
||||
logLoading.value = false;
|
||||
}
|
||||
@@ -303,17 +302,15 @@ const runAction = async (action: () => Promise<void>, message?: string) => {
|
||||
return;
|
||||
}
|
||||
actionBusy.value = true;
|
||||
actionError.value = null;
|
||||
actionMessage.value = null;
|
||||
try {
|
||||
await action();
|
||||
if (message) {
|
||||
actionMessage.value = message;
|
||||
showSuccessToast(message);
|
||||
}
|
||||
await loadStatus();
|
||||
await loadLogs(true);
|
||||
} catch (err) {
|
||||
actionError.value = resolveErrorMessage(err);
|
||||
showErrorToast(resolveErrorMessage(err));
|
||||
} finally {
|
||||
actionBusy.value = false;
|
||||
}
|
||||
@@ -335,7 +332,7 @@ const buyHiddenBuff = async (key: BuffKey) => {
|
||||
|
||||
const reserveSpecialWar = async () => {
|
||||
if (!nextSpecialKey.value) {
|
||||
actionError.value = '다음 전투 특기를 선택해주세요.';
|
||||
showErrorToast('다음 전투 특기를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
const name = specialNameMap.value.get(nextSpecialKey.value) ?? nextSpecialKey.value;
|
||||
@@ -371,7 +368,7 @@ const resetTurnTime = async () => {
|
||||
|
||||
const resetStats = async () => {
|
||||
if (resetStatErrors.value.length > 0) {
|
||||
actionError.value = resetStatErrors.value[0] ?? '입력값을 확인해주세요.';
|
||||
showErrorToast(resetStatErrors.value[0] ?? '입력값을 확인해주세요.');
|
||||
return;
|
||||
}
|
||||
if (!window.confirm('능력치를 초기화하시겠습니까?')) {
|
||||
@@ -400,16 +397,16 @@ const buyRandomUnique = async () => {
|
||||
const openUniqueAuction = async () => {
|
||||
const selectedItem = status.value?.availableUnique.find((item) => item.key === uniqueForm.itemId.trim());
|
||||
if (!selectedItem) {
|
||||
actionError.value = '유니크를 선택해주세요.';
|
||||
showErrorToast('유니크를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
const amount = Math.max(0, Math.floor(uniqueForm.amount));
|
||||
if (amount <= 0) {
|
||||
actionError.value = '입찰 포인트를 입력해주세요.';
|
||||
showErrorToast('입찰 포인트를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
if (previousPoint.value < amount) {
|
||||
actionError.value = '유산 포인트가 부족합니다.';
|
||||
showErrorToast('유산 포인트가 부족합니다.');
|
||||
return;
|
||||
}
|
||||
const itemJosa = JosaUtil.pick(selectedItem.rawName, '을');
|
||||
@@ -427,7 +424,7 @@ const openUniqueAuction = async () => {
|
||||
const checkOwner = async () => {
|
||||
const targetId = Number(ownerTargetId.value);
|
||||
if (!targetId) {
|
||||
actionError.value = '확인할 장수를 선택해주세요.';
|
||||
showErrorToast('확인할 장수를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
const cost = status.value?.inheritConst.inheritCheckOwnerPoint ?? 0;
|
||||
@@ -465,8 +462,7 @@ onMounted(() => {
|
||||
|
||||
<main id="container" class="inherit-page legacy-bg0">
|
||||
<input type="hidden" name="inheritanceAction" value="inherit" />
|
||||
<div v-if="error || actionError" class="notice error" role="alert">{{ error ?? actionError }}</div>
|
||||
<div v-if="actionMessage" class="notice success">{{ actionMessage }}</div>
|
||||
<div v-if="error" class="notice error" role="alert">{{ error }}</div>
|
||||
<div v-if="loading" class="loading-state">불러오는 중...</div>
|
||||
|
||||
<template v-else-if="status">
|
||||
@@ -821,10 +817,6 @@ onMounted(() => {
|
||||
color: #ffb0b0;
|
||||
}
|
||||
|
||||
.notice.success {
|
||||
color: #b6efb6;
|
||||
}
|
||||
|
||||
.point-grid,
|
||||
.action-grid,
|
||||
.buff-grid {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
type BettingListItem = {
|
||||
@@ -56,7 +57,7 @@ const loadingList = ref(false);
|
||||
const loadingDetail = ref(false);
|
||||
const submitting = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const noticeMessage = ref('');
|
||||
const { success: showSuccessToast, error: showErrorToast } = useGameFeedback();
|
||||
|
||||
const currentYearMonth = computed(() => {
|
||||
if (!detail.value) {
|
||||
@@ -265,13 +266,10 @@ const loadList = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadDetail = async (bettingId: number, resetSelection = true, preserveNotice = false) => {
|
||||
const loadDetail = async (bettingId: number, resetSelection = true) => {
|
||||
selectedBettingId.value = bettingId;
|
||||
loadingDetail.value = true;
|
||||
errorMessage.value = '';
|
||||
if (!preserveNotice) {
|
||||
noticeMessage.value = '';
|
||||
}
|
||||
try {
|
||||
detail.value = await bettingApi.getDetail.query({ bettingId });
|
||||
if (resetSelection) {
|
||||
@@ -299,7 +297,7 @@ const toggleCandidate = (index: number) => {
|
||||
return;
|
||||
}
|
||||
if (current.length >= info.value.selectCnt) {
|
||||
errorMessage.value = `이미 ${info.value.selectCnt}개를 선택했습니다.`;
|
||||
showErrorToast(`이미 ${info.value.selectCnt}개를 선택했습니다.`);
|
||||
return;
|
||||
}
|
||||
selectedCandidates.value = [...current, index];
|
||||
@@ -311,19 +309,18 @@ const submitBet = async () => {
|
||||
}
|
||||
submitting.value = true;
|
||||
errorMessage.value = '';
|
||||
noticeMessage.value = '';
|
||||
try {
|
||||
await bettingApi.bet.mutate({
|
||||
bettingId: info.value.id,
|
||||
bettingType: [...selectedCandidates.value],
|
||||
amount: amount.value,
|
||||
});
|
||||
noticeMessage.value = '베팅했습니다';
|
||||
await loadDetail(info.value.id, true, true);
|
||||
showSuccessToast('베팅했습니다');
|
||||
await loadDetail(info.value.id, true);
|
||||
await loadList();
|
||||
} catch (error) {
|
||||
// Legacy form keeps the selected candidates and amount after a failed request.
|
||||
errorMessage.value = getErrorMessage(error);
|
||||
showErrorToast(getErrorMessage(error));
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
@@ -353,7 +350,6 @@ onMounted(() => {
|
||||
</header>
|
||||
|
||||
<div v-if="errorMessage" class="betting-notice error" role="alert">{{ errorMessage }}</div>
|
||||
<div v-if="noticeMessage" class="betting-notice success" role="status">{{ noticeMessage }}</div>
|
||||
|
||||
<section v-if="detail && info" class="betting-detail">
|
||||
<div class="section-title legacy-bg2">
|
||||
@@ -668,11 +664,6 @@ onMounted(() => {
|
||||
color: #ffd0d0;
|
||||
}
|
||||
|
||||
.betting-notice.success {
|
||||
border: 1px solid #477a47;
|
||||
color: #d8f5d8;
|
||||
}
|
||||
|
||||
@media (min-width: 1000px) {
|
||||
.nation-betting-page {
|
||||
width: 1000px;
|
||||
|
||||
@@ -5,13 +5,13 @@ import { trpc } from '../utils/trpc';
|
||||
import { resolveDiplomacyInfo } from '../utils/diplomacy';
|
||||
import { legacyNationTextColor } from '../utils/legacyNationColor';
|
||||
import LegacyHtmlEditor from '../components/ui/LegacyHtmlEditor.vue';
|
||||
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||
|
||||
type StratFinanResponse = Awaited<ReturnType<typeof trpc.nation.getStratFinan.query>>;
|
||||
type NationEntry = StratFinanResponse['nationsList'][number];
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const status = ref<string | null>(null);
|
||||
const data = ref<StratFinanResponse | null>(null);
|
||||
const nationMsg = ref('');
|
||||
const scoutMsg = ref('');
|
||||
@@ -21,6 +21,7 @@ const editingNationMsg = ref(false);
|
||||
const editingScoutMsg = ref(false);
|
||||
const policy = reactive({ rate: 0, bill: 0, secretLimit: 0, blockScout: false, blockWar: false });
|
||||
const oldPolicy = reactive({ rate: 0, bill: 0, secretLimit: 0 });
|
||||
const { success: showSuccessToast, error: showErrorToast } = useGameFeedback();
|
||||
|
||||
const resolveErrorMessage = (value: unknown): string =>
|
||||
value instanceof Error ? value.message : typeof value === 'string' ? value : 'unknown_error';
|
||||
@@ -69,17 +70,15 @@ const mutation = async <T,>(
|
||||
action: () => Promise<T>,
|
||||
message: string,
|
||||
rollback?: () => void
|
||||
): Promise<T | undefined> => {
|
||||
error.value = null;
|
||||
status.value = null;
|
||||
): Promise<{ ok: true; value: T } | { ok: false }> => {
|
||||
try {
|
||||
const result = await action();
|
||||
status.value = message;
|
||||
return result;
|
||||
const value = await action();
|
||||
showSuccessToast(message);
|
||||
return { ok: true, value };
|
||||
} catch (err) {
|
||||
rollback?.();
|
||||
error.value = resolveErrorMessage(err);
|
||||
return undefined;
|
||||
showErrorToast(resolveErrorMessage(err));
|
||||
return { ok: false };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -97,9 +96,9 @@ const saveNationMsg = async () => {
|
||||
() => trpc.nation.setNotice.mutate({ msg: nationMsgDraft.value }),
|
||||
'국가 방침을 변경했습니다.'
|
||||
);
|
||||
if (result) {
|
||||
nationMsg.value = result.msg;
|
||||
nationMsgDraft.value = result.msg;
|
||||
if (result.ok) {
|
||||
nationMsg.value = result.value.msg;
|
||||
nationMsgDraft.value = result.value.msg;
|
||||
editingNationMsg.value = false;
|
||||
}
|
||||
};
|
||||
@@ -117,36 +116,36 @@ const saveScoutMsg = async () => {
|
||||
() => trpc.nation.setScoutMsg.mutate({ msg: scoutMsgDraft.value }),
|
||||
'임관 권유문을 변경했습니다.'
|
||||
);
|
||||
if (result) {
|
||||
scoutMsg.value = result.msg;
|
||||
scoutMsgDraft.value = result.msg;
|
||||
if (result.ok) {
|
||||
scoutMsg.value = result.value.msg;
|
||||
scoutMsgDraft.value = result.value.msg;
|
||||
editingScoutMsg.value = false;
|
||||
}
|
||||
};
|
||||
const setRate = () =>
|
||||
mutation(
|
||||
const setRate = async () => {
|
||||
const result = await mutation(
|
||||
() => trpc.nation.setRate.mutate({ amount: policy.rate }),
|
||||
'세율을 변경했습니다.',
|
||||
() => (policy.rate = oldPolicy.rate)
|
||||
).then(() => {
|
||||
if (!error.value) oldPolicy.rate = policy.rate;
|
||||
});
|
||||
const setBill = () =>
|
||||
mutation(
|
||||
);
|
||||
if (result.ok) oldPolicy.rate = policy.rate;
|
||||
};
|
||||
const setBill = async () => {
|
||||
const result = await mutation(
|
||||
() => trpc.nation.setBill.mutate({ amount: policy.bill }),
|
||||
'지급률을 변경했습니다.',
|
||||
() => (policy.bill = oldPolicy.bill)
|
||||
).then(() => {
|
||||
if (!error.value) oldPolicy.bill = policy.bill;
|
||||
});
|
||||
const setSecretLimit = () =>
|
||||
mutation(
|
||||
);
|
||||
if (result.ok) oldPolicy.bill = policy.bill;
|
||||
};
|
||||
const setSecretLimit = async () => {
|
||||
const result = await mutation(
|
||||
() => trpc.nation.setSecretLimit.mutate({ amount: policy.secretLimit }),
|
||||
'기밀 권한을 변경했습니다.',
|
||||
() => (policy.secretLimit = oldPolicy.secretLimit)
|
||||
).then(() => {
|
||||
if (!error.value) oldPolicy.secretLimit = policy.secretLimit;
|
||||
});
|
||||
);
|
||||
if (result.ok) oldPolicy.secretLimit = policy.secretLimit;
|
||||
};
|
||||
const setBlockWar = async () => {
|
||||
const next = policy.blockWar;
|
||||
await mutation(
|
||||
@@ -182,7 +181,6 @@ onMounted(() => void loadStratFinan());
|
||||
</nav>
|
||||
|
||||
<div v-if="error" class="feedback error" role="alert">{{ error }}</div>
|
||||
<div v-if="status" class="feedback status" role="status">{{ status }}</div>
|
||||
<div v-if="loading" class="loading">불러오는 중...</div>
|
||||
|
||||
<template v-if="data && !loading">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import SortableStringList from '../components/ui/SortableStringList';
|
||||
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||
import { npcPriorityHelp } from '../utils/npcPriorityHelp';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
@@ -37,7 +38,6 @@ interface PriorityPanel {
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const notice = ref<string | null>(null);
|
||||
const data = ref<NpcPolicyResponse | null>(null);
|
||||
const policyDraft = ref<NationPolicy | null>(null);
|
||||
const lastSavedPolicy = ref<NationPolicy | null>(null);
|
||||
@@ -45,6 +45,7 @@ const nationPriority = ref<PriorityListState | null>(null);
|
||||
const generalPriority = ref<PriorityListState | null>(null);
|
||||
const lastSavedNationPriority = ref<string[]>([]);
|
||||
const lastSavedGeneralPriority = ref<string[]>([]);
|
||||
const { success: showSuccessToast, error: showErrorToast, info: showInfoToast } = useGameFeedback();
|
||||
|
||||
const resolveErrorMessage = (value: unknown): string => {
|
||||
if (value instanceof Error) return value.message;
|
||||
@@ -283,25 +284,23 @@ const priorityPanels = computed<PriorityPanel[]>(() => {
|
||||
const resetPolicy = () => {
|
||||
if (!data.value || !window.confirm('초기 설정으로 되돌릴까요?')) return;
|
||||
policyDraft.value = clonePolicy(data.value.defaultNationPolicy);
|
||||
notice.value = '서버 초깃값을 적용했습니다. 설정 버튼을 누르면 반영됩니다.';
|
||||
showInfoToast('서버 초깃값을 적용했습니다. 설정 버튼을 누르면 반영됩니다.');
|
||||
};
|
||||
|
||||
const rollbackPolicy = () => {
|
||||
if (!lastSavedPolicy.value || !window.confirm('이전 설정으로 되돌릴까요?')) return;
|
||||
policyDraft.value = clonePolicy(lastSavedPolicy.value);
|
||||
notice.value = '이전 설정으로 되돌렸습니다.';
|
||||
showInfoToast('이전 설정으로 되돌렸습니다.');
|
||||
};
|
||||
|
||||
const submitPolicy = async () => {
|
||||
if (!policyDraft.value || !window.confirm('저장할까요?')) return;
|
||||
error.value = null;
|
||||
notice.value = null;
|
||||
try {
|
||||
await trpc.npc.setNationPolicy.mutate(policyDraft.value);
|
||||
lastSavedPolicy.value = clonePolicy(policyDraft.value);
|
||||
notice.value = 'NPC 정책이 반영되었습니다.';
|
||||
showSuccessToast('NPC 정책이 반영되었습니다.');
|
||||
} catch (caught) {
|
||||
error.value = `설정하지 못했습니다: ${resolveErrorMessage(caught)}`;
|
||||
showErrorToast(`설정하지 못했습니다: ${resolveErrorMessage(caught)}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -318,7 +317,7 @@ const resetPriority = (section: PrioritySectionKey) => {
|
||||
data.value.availableGeneralActionPriorityItems
|
||||
);
|
||||
}
|
||||
notice.value = '서버 초깃값을 적용했습니다. 설정 버튼을 누르면 반영됩니다.';
|
||||
showInfoToast('서버 초깃값을 적용했습니다. 설정 버튼을 누르면 반영됩니다.');
|
||||
};
|
||||
|
||||
const rollbackPriority = (section: PrioritySectionKey) => {
|
||||
@@ -334,14 +333,12 @@ const rollbackPriority = (section: PrioritySectionKey) => {
|
||||
data.value.availableGeneralActionPriorityItems
|
||||
);
|
||||
}
|
||||
notice.value = '이전 설정으로 되돌렸습니다.';
|
||||
showInfoToast('이전 설정으로 되돌렸습니다.');
|
||||
};
|
||||
|
||||
const submitPriority = async (section: PrioritySectionKey) => {
|
||||
const state = section === 'nation' ? nationPriority.value : generalPriority.value;
|
||||
if (!state || !window.confirm('저장할까요?')) return;
|
||||
error.value = null;
|
||||
notice.value = null;
|
||||
try {
|
||||
if (section === 'nation') {
|
||||
await trpc.npc.setNationPriority.mutate(state.active);
|
||||
@@ -350,9 +347,9 @@ const submitPriority = async (section: PrioritySectionKey) => {
|
||||
await trpc.npc.setGeneralPriority.mutate(state.active);
|
||||
lastSavedGeneralPriority.value = [...state.active];
|
||||
}
|
||||
notice.value = 'NPC 정책이 반영되었습니다.';
|
||||
showSuccessToast('NPC 정책이 반영되었습니다.');
|
||||
} catch (caught) {
|
||||
error.value = `설정하지 못했습니다: ${resolveErrorMessage(caught)}`;
|
||||
showErrorToast(`설정하지 못했습니다: ${resolveErrorMessage(caught)}`);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -380,9 +377,6 @@ const submitPriority = async (section: PrioritySectionKey) => {
|
||||
}})
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="feedback error-feedback" role="alert">{{ error }}</div>
|
||||
<div v-if="notice" class="feedback notice-feedback" role="status">{{ notice }}</div>
|
||||
|
||||
<div class="form_list">
|
||||
<div v-for="field in policyFields" :key="field.key" class="policy-field">
|
||||
<div class="field-row">
|
||||
@@ -628,26 +622,12 @@ const submitPriority = async (section: PrioritySectionKey) => {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.feedback {
|
||||
margin: 4px 12px;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.error-feedback,
|
||||
.error-state {
|
||||
color: #ffd4d4;
|
||||
border-color: #a94442;
|
||||
background-color: rgba(120, 20, 20, 0.75);
|
||||
}
|
||||
|
||||
.notice-feedback {
|
||||
color: #d9ffd9;
|
||||
border-color: #3c763d;
|
||||
background-color: rgba(20, 90, 20, 0.7);
|
||||
}
|
||||
|
||||
.form_list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
||||
@@ -3,6 +3,7 @@ import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
type VoteListResponse = Awaited<ReturnType<typeof trpc.vote.getVoteList.query>>;
|
||||
@@ -17,8 +18,7 @@ const loading = ref(false);
|
||||
const detailLoading = ref(false);
|
||||
const isVoteAdmin = ref(false);
|
||||
const showNewVote = ref(false);
|
||||
const message = ref('');
|
||||
const messageKind = ref<'success' | 'error'>('success');
|
||||
const loadError = ref('');
|
||||
const mySinglePick = ref(0);
|
||||
const myMultiPick = ref<number[]>([]);
|
||||
const myComment = ref('');
|
||||
@@ -26,6 +26,7 @@ const newVoteTitle = ref('');
|
||||
const newVoteOptionsText = ref('');
|
||||
const newVoteMultipleOptions = ref(1);
|
||||
const router = useRouter();
|
||||
const { success: showSuccessToast, error: showErrorToast } = useGameFeedback();
|
||||
|
||||
const getErrorMessage = (error: unknown): string => {
|
||||
if (error instanceof Error) {
|
||||
@@ -34,11 +35,6 @@ const getErrorMessage = (error: unknown): string => {
|
||||
return typeof error === 'string' ? error : '요청을 처리하지 못했습니다.';
|
||||
};
|
||||
|
||||
const showMessage = (text: string, kind: 'success' | 'error') => {
|
||||
message.value = text;
|
||||
messageKind.value = kind;
|
||||
};
|
||||
|
||||
const isEnded = (poll: { endAt: string | null; closedAt: string | null }): boolean => {
|
||||
if (poll.closedAt) {
|
||||
return true;
|
||||
@@ -79,6 +75,7 @@ const voteColorText = (index: number): string => ([1, 2].includes(index % 7) ? '
|
||||
|
||||
const loadVoteDetail = async (voteId: number) => {
|
||||
detailLoading.value = true;
|
||||
loadError.value = '';
|
||||
try {
|
||||
const detail = await trpc.vote.getVoteDetail.query({ voteId });
|
||||
currentVote.value = detail;
|
||||
@@ -87,7 +84,7 @@ const loadVoteDetail = async (voteId: number) => {
|
||||
myMultiPick.value = detail.myVote ? [...detail.myVote] : [];
|
||||
myComment.value = '';
|
||||
} catch (error) {
|
||||
showMessage(getErrorMessage(error), 'error');
|
||||
loadError.value = getErrorMessage(error);
|
||||
} finally {
|
||||
detailLoading.value = false;
|
||||
}
|
||||
@@ -98,7 +95,7 @@ const reloadVote = async () => {
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
message.value = '';
|
||||
loadError.value = '';
|
||||
try {
|
||||
const result = await trpc.vote.getVoteList.query();
|
||||
polls.value = result.polls;
|
||||
@@ -114,7 +111,7 @@ const reloadVote = async () => {
|
||||
currentVote.value = null;
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage(getErrorMessage(error), 'error');
|
||||
loadError.value = getErrorMessage(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -130,7 +127,7 @@ const changeMultiPick = (index: number, checked: boolean) => {
|
||||
const limit = currentVote.value?.voteInfo.multipleOptions ?? 0;
|
||||
if (checked && limit > 0 && myMultiPick.value.length > limit) {
|
||||
myMultiPick.value = myMultiPick.value.filter((value) => value !== index);
|
||||
showMessage(`${limit}개까지만 선택할 수 있습니다.`, 'error');
|
||||
showErrorToast(`${limit}개까지만 선택할 수 있습니다.`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -140,7 +137,7 @@ const submitVote = async () => {
|
||||
}
|
||||
const selection = currentVote.value.voteInfo.multipleOptions === 1 ? [mySinglePick.value] : [...myMultiPick.value];
|
||||
if (selection.length === 0) {
|
||||
showMessage('선택한 항목이 없습니다.', 'error');
|
||||
showErrorToast('선택한 항목이 없습니다.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -148,10 +145,10 @@ const submitVote = async () => {
|
||||
voteId: currentVote.value.voteInfo.id,
|
||||
selection,
|
||||
});
|
||||
showMessage(result.wonLottery ? '특별한 설문 보상이 제공되었습니다!' : '설문을 마쳤습니다.', 'success');
|
||||
showSuccessToast(result.wonLottery ? '특별한 설문 보상이 제공되었습니다!' : '설문을 마쳤습니다.');
|
||||
await loadVoteDetail(currentVote.value.voteInfo.id);
|
||||
} catch (error) {
|
||||
showMessage(getErrorMessage(error), 'error');
|
||||
showErrorToast(getErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -165,10 +162,10 @@ const submitComment = async () => {
|
||||
text: myComment.value,
|
||||
});
|
||||
myComment.value = '';
|
||||
showMessage('댓글을 달았습니다.', 'success');
|
||||
showSuccessToast('댓글을 달았습니다.');
|
||||
await loadVoteDetail(currentVote.value.voteInfo.id);
|
||||
} catch (error) {
|
||||
showMessage(getErrorMessage(error), 'error');
|
||||
showErrorToast(getErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -182,14 +179,14 @@ const submitNewVote = async () => {
|
||||
revealMode: 'after_vote',
|
||||
closePrevious: true,
|
||||
});
|
||||
showMessage('설문 조사가 생성되었습니다.', 'success');
|
||||
showSuccessToast('설문 조사가 생성되었습니다.');
|
||||
newVoteTitle.value = '';
|
||||
newVoteOptionsText.value = '';
|
||||
newVoteMultipleOptions.value = 1;
|
||||
showNewVote.value = false;
|
||||
await reloadVote();
|
||||
} catch (error) {
|
||||
showMessage(getErrorMessage(error), 'error');
|
||||
showErrorToast(getErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -229,14 +226,7 @@ onMounted(() => {
|
||||
<div></div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
v-if="message"
|
||||
class="vote-notice"
|
||||
:class="messageKind"
|
||||
:role="messageKind === 'error' ? 'alert' : 'status'"
|
||||
>
|
||||
{{ message }}
|
||||
</div>
|
||||
<div v-if="loadError" class="vote-notice error" role="alert">{{ loadError }}</div>
|
||||
<div id="vote-title" class="bg2">설문 조사({{ voteReward }}금과 추첨으로 유니크템 증정!)</div>
|
||||
|
||||
<div v-if="detailLoading && !currentVote" class="loading">불러오는 중...</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { computed, nextTick, onMounted, ref } from 'vue';
|
||||
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
|
||||
import TournamentPageHeader from '../components/tournament/TournamentPageHeader.vue';
|
||||
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
|
||||
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { resolveTournamentSectionVisibility, resolveTournamentStageName } from '../utils/tournamentStatus';
|
||||
|
||||
@@ -14,11 +15,11 @@ const betting = ref<Awaited<ReturnType<typeof trpc.tournament.getBettingSummary.
|
||||
const myGeneralId = ref(0);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const actionMessage = ref<string | null>(null);
|
||||
const adminEnabled = ref(false);
|
||||
const activeFinalGroup = ref(0);
|
||||
const activePreliminaryGroup = ref(0);
|
||||
const tournamentContainer = ref<HTMLElement | null>(null);
|
||||
const { success: showSuccessToast, error: showErrorToast } = useGameFeedback();
|
||||
|
||||
const typeNames = ['전력전', '통솔전', '일기토', '설전'];
|
||||
const typeStatNames = ['종합', '통솔', '무력', '지력'];
|
||||
@@ -133,21 +134,21 @@ const revealMyPreliminaryGroup = async (): Promise<number | undefined> => {
|
||||
};
|
||||
|
||||
const join = async () => {
|
||||
actionMessage.value = null;
|
||||
let joined = false;
|
||||
try {
|
||||
await trpc.tournament.join.mutate();
|
||||
joined = true;
|
||||
} catch (value) {
|
||||
actionMessage.value = errorText(value);
|
||||
showErrorToast(errorText(value));
|
||||
} finally {
|
||||
await load();
|
||||
if (joined) {
|
||||
const groupId = await revealMyPreliminaryGroup();
|
||||
actionMessage.value =
|
||||
showSuccessToast(
|
||||
groupId === undefined
|
||||
? '참가 신청이 반영되었습니다.'
|
||||
: `참가 신청이 반영되었습니다. ${groupNames[groupId]}조에 배정되었습니다.`;
|
||||
: `참가 신청이 반영되었습니다. ${groupNames[groupId]}조에 배정되었습니다.`
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -155,10 +156,10 @@ const join = async () => {
|
||||
const cancel = async () => {
|
||||
try {
|
||||
await trpc.tournament.cancel.mutate();
|
||||
actionMessage.value = '토너먼트가 중단되었습니다.';
|
||||
showSuccessToast('토너먼트가 중단되었습니다.');
|
||||
await load();
|
||||
} catch (value) {
|
||||
actionMessage.value = errorText(value);
|
||||
showErrorToast(errorText(value));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -177,10 +178,10 @@ const start = async () => {
|
||||
bettingSettled: false,
|
||||
rewardSettled: false,
|
||||
});
|
||||
actionMessage.value = '토너먼트를 개최했습니다.';
|
||||
showSuccessToast('토너먼트를 개최했습니다.');
|
||||
await load();
|
||||
} catch (value) {
|
||||
actionMessage.value = errorText(value);
|
||||
showErrorToast(errorText(value));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -206,7 +207,6 @@ const start = async () => {
|
||||
참가
|
||||
</button>
|
||||
<span v-if="loading">불러오는 중...</span>
|
||||
<span v-if="actionMessage" role="status">{{ actionMessage }}</span>
|
||||
</section>
|
||||
|
||||
<section v-if="error" class="error-row bg0" role="alert">{{ error }}</section>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useRouter } from 'vue-router';
|
||||
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
|
||||
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||
|
||||
type TroopList = Awaited<ReturnType<typeof trpc.troop.getList.query>>;
|
||||
type Troop = TroopList['troops'][number];
|
||||
@@ -15,8 +16,6 @@ type DialogKind = 'rename' | 'kick' | null;
|
||||
const loading = ref(false);
|
||||
const data = ref<TroopList | null>(null);
|
||||
const errorMessage = ref('');
|
||||
const noticeMessage = ref('');
|
||||
const noticeKind = ref<'success' | 'error'>('success');
|
||||
const createName = ref('');
|
||||
const editName = ref('');
|
||||
const kickTargetId = ref(0);
|
||||
@@ -25,6 +24,7 @@ const dialogTroopId = ref(0);
|
||||
const popupMember = ref<Member | null>(null);
|
||||
const popupTop = ref(0);
|
||||
const router = useRouter();
|
||||
const { success: showSuccessToast, error: showErrorToast } = useGameFeedback();
|
||||
|
||||
const me = computed(() => data.value?.me ?? null);
|
||||
|
||||
@@ -39,8 +39,11 @@ const getErrorMessage = (error: unknown): string => {
|
||||
};
|
||||
|
||||
const showNotice = (message: string, kind: 'success' | 'error') => {
|
||||
noticeMessage.value = message;
|
||||
noticeKind.value = kind;
|
||||
if (kind === 'success') {
|
||||
showSuccessToast(message);
|
||||
} else {
|
||||
showErrorToast(message);
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
@@ -65,7 +68,6 @@ const runAction = async (action: () => Promise<void>) => {
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
errorMessage.value = message;
|
||||
showNotice(message, 'error');
|
||||
}
|
||||
};
|
||||
@@ -198,15 +200,7 @@ onMounted(() => {
|
||||
<div></div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
v-if="noticeMessage"
|
||||
class="notice"
|
||||
:class="noticeKind"
|
||||
:role="noticeKind === 'error' ? 'alert' : 'status'"
|
||||
>
|
||||
{{ noticeMessage }}
|
||||
</div>
|
||||
<div v-if="errorMessage && !noticeMessage" class="notice error" role="alert">{{ errorMessage }}</div>
|
||||
<div v-if="errorMessage" class="notice error" role="alert">{{ errorMessage }}</div>
|
||||
<div v-if="loading && !data" class="loading">불러오는 중...</div>
|
||||
|
||||
<div v-if="data" id="troopList" class="bg0">
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
const migratedViewNames = [
|
||||
'AuctionView.vue',
|
||||
'BettingView.vue',
|
||||
'InheritView.vue',
|
||||
'NationBettingView.vue',
|
||||
'NationStratFinanView.vue',
|
||||
'NpcControlView.vue',
|
||||
'SurveyView.vue',
|
||||
'TournamentView.vue',
|
||||
'TroopView.vue',
|
||||
] as const;
|
||||
|
||||
void describe('transient action feedback contract', () => {
|
||||
for (const viewName of migratedViewNames) {
|
||||
void it(`${viewName} sends action feedback through the shared toast layer`, async () => {
|
||||
const source = await readFile(path.resolve(import.meta.dirname, `../src/views/${viewName}`), 'utf8');
|
||||
const template = source.slice(source.indexOf('<template>'));
|
||||
|
||||
assert.match(source, /useGameFeedback/);
|
||||
assert.doesNotMatch(template, /role="status"/);
|
||||
assert.doesNotMatch(template, /class="[^"]*(?:success|status)[^"]*"[^>]*>\s*\{\{/);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -415,7 +415,9 @@ test.describe('inheritance management legacy parity', () => {
|
||||
await expect.poll(() => fixture.uniqueAuctionRequests.length).toBe(1);
|
||||
expect(JSON.stringify(fixture.uniqueAuctionRequests[0])).toContain('che_서적_07_논어');
|
||||
expect(JSON.stringify(fixture.uniqueAuctionRequests[0])).toContain('6000');
|
||||
await expect(page.locator('.notice.success')).toHaveText('성공했습니다. 경매장을 확인해주세요.');
|
||||
await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="success"]')).toContainText(
|
||||
'성공했습니다. 경매장을 확인해주세요.'
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps controls usable and renders an API mutation error', async ({ page }) => {
|
||||
|
||||
@@ -305,7 +305,9 @@ test('nation betting matches the legacy desktop geometry and preserves a failed
|
||||
await expect(page.locator('.betting-candidate.picked')).toHaveCount(2);
|
||||
|
||||
await page.getByRole('button', { name: '베팅', exact: true }).click();
|
||||
await expect(page.getByRole('status')).toContainText('베팅했습니다');
|
||||
await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="success"]')).toContainText(
|
||||
'베팅했습니다'
|
||||
);
|
||||
await expect(amountInput).toHaveValue('0');
|
||||
|
||||
if (artifactRoot) {
|
||||
@@ -313,6 +315,29 @@ test('nation betting matches the legacy desktop geometry and preserves a failed
|
||||
}
|
||||
});
|
||||
|
||||
test('nation betting reports failed and successful submissions through shared toasts', async ({ page }) => {
|
||||
await installBettingFixture(page);
|
||||
await page.goto(gameUrl('/nation-betting'));
|
||||
await page.getByRole('button', { name: /천통국 예상/ }).click();
|
||||
await page.locator('.betting-candidate').nth(0).click();
|
||||
await page.locator('.betting-candidate').nth(1).click();
|
||||
|
||||
const amountInput = page.getByLabel('베팅 금액');
|
||||
await amountInput.fill('300');
|
||||
await page.getByRole('button', { name: '베팅', exact: true }).click();
|
||||
await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="error"]')).toContainText(
|
||||
'유산포인트가 충분하지 않습니다.'
|
||||
);
|
||||
await expect(amountInput).toHaveValue('300');
|
||||
|
||||
await page.getByRole('button', { name: '베팅', exact: true }).click();
|
||||
await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="success"]')).toContainText(
|
||||
'베팅했습니다'
|
||||
);
|
||||
await expect(amountInput).toHaveValue('0');
|
||||
await expect(page.locator('.betting-notice.success')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('nation betting keeps the legacy 500px three-column mobile contract', async ({ page }) => {
|
||||
await installBettingFixture(page);
|
||||
await page.setViewportSize({ width: 500, height: 900 });
|
||||
|
||||
@@ -366,11 +366,15 @@ test('tournament reloads its snapshot after failed and successful joins without
|
||||
await expect.poll(() => state.snapshotQueries).toBe(1);
|
||||
|
||||
await join.click();
|
||||
await expect(page.getByRole('status')).toHaveText('금이 부족합니다.');
|
||||
await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="error"]')).toContainText(
|
||||
'금이 부족합니다.'
|
||||
);
|
||||
await expect.poll(() => state.snapshotQueries).toBe(2);
|
||||
|
||||
await join.click();
|
||||
await expect(page.getByRole('status')).toHaveText('참가 신청이 반영되었습니다.');
|
||||
await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="success"]')).toContainText(
|
||||
'참가 신청이 반영되었습니다.'
|
||||
);
|
||||
await expect.poll(() => state.snapshotQueries).toBe(3);
|
||||
expect(state.summaryQueries).toBe(3);
|
||||
expect(state.accessCalls).toBe(0);
|
||||
@@ -423,12 +427,14 @@ test('betting keeps the 1120px and 16 by 70px layout and retains a failed select
|
||||
await bet.focus();
|
||||
await expect(bet).toBeFocused();
|
||||
await bet.click();
|
||||
await expect(page.getByRole('status')).toHaveText('500금까지만 베팅 가능합니다.');
|
||||
await expect(page.getByRole('alert')).toHaveText('500금까지만 베팅 가능합니다.');
|
||||
await expect(select).toHaveValue('500');
|
||||
await expect.poll(() => state.snapshotQueries).toBe(2);
|
||||
|
||||
await bet.click();
|
||||
await expect(page.getByRole('status')).toHaveText('베팅이 등록되었습니다.');
|
||||
await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="success"]')).toContainText(
|
||||
'베팅이 등록되었습니다.'
|
||||
);
|
||||
await expect.poll(() => state.snapshotQueries).toBe(3);
|
||||
expect(state.summaryQueries).toBe(3);
|
||||
expect(state.rankingQueries).toBe(3);
|
||||
|
||||
@@ -1244,7 +1244,9 @@ test.describe('survey legacy parity', () => {
|
||||
test('submits a vote and comment through the real screen controls', async ({ page }) => {
|
||||
await page.goto(gameUrl('/survey'));
|
||||
await page.getByRole('button', { name: '투표', exact: true }).click();
|
||||
await expect(page.getByRole('status')).toHaveText('설문을 마쳤습니다.');
|
||||
await expect(page.locator('[data-testid="game-toast"][data-feedback-kind="success"]')).toContainText(
|
||||
'설문을 마쳤습니다.'
|
||||
);
|
||||
await expect(page.getByText('결산', { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByLabel('댓글').fill('새 댓글');
|
||||
|
||||
Reference in New Issue
Block a user