fix: 인게임 작업 상태를 공용 toast로 통일한다

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