feat: 프로필 감사 화면에 외교 이력과 안전한 문서 상세 연결
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import { purifyDiplomacyHtml } from '../../security/diplomacyHtml.js';
|
||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { hashAuditDiplomacyDocument, type GamePrisma } from '@sammo-ts/infra';
|
import { hashAuditDiplomacyDocument, type GamePrisma } from '@sammo-ts/infra';
|
||||||
@@ -204,6 +205,8 @@ export const diplomacyEvent = auditProcedure.input(z.object({ id: zId }).strict(
|
|||||||
id: document.id,
|
id: document.id,
|
||||||
brief: document.textBrief,
|
brief: document.textBrief,
|
||||||
detail: document.textDetail,
|
detail: document.textDetail,
|
||||||
|
briefHtml: purifyDiplomacyHtml(document.textBrief),
|
||||||
|
detailHtml: purifyDiplomacyHtml(document.textDetail),
|
||||||
writtenAt: document.date,
|
writtenAt: document.date,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@@ -2255,7 +2255,7 @@ integration('game API security over HTTP transport', () => {
|
|||||||
srcSignerId: generalId,
|
srcSignerId: generalId,
|
||||||
state: 'CANCELLED',
|
state: 'CANCELLED',
|
||||||
textBrief: '당시 외교 문서',
|
textBrief: '당시 외교 문서',
|
||||||
textDetail: '<p>역사 본문</p>',
|
textDetail: '<p>역사 본문</p><script>window.auditInjected=true</script>',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await db.playAuditDiplomacyEvent.createMany({
|
await db.playAuditDiplomacyEvent.createMany({
|
||||||
@@ -2338,7 +2338,10 @@ integration('game API security over HTTP transport', () => {
|
|||||||
after: { state: 'ACTIVATED' },
|
after: { state: 'ACTIVATED' },
|
||||||
inputSequence: '9007199254740993',
|
inputSequence: '9007199254740993',
|
||||||
documentStatus: 'AVAILABLE',
|
documentStatus: 'AVAILABLE',
|
||||||
document: { detail: '<p>역사 본문</p>' },
|
document: {
|
||||||
|
detail: '<p>역사 본문</p><script>window.auditInjected=true</script>',
|
||||||
|
detailHtml: '<p>역사 본문</p>',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -89,6 +89,79 @@ const install = async (page: Page, denied = false) => {
|
|||||||
],
|
],
|
||||||
nextCursor: null,
|
nextCursor: null,
|
||||||
});
|
});
|
||||||
|
case 'playAudit.diplomacyHistory':
|
||||||
|
return result({
|
||||||
|
...world,
|
||||||
|
coverage: 'RECORDED_EVENTS_ONLY',
|
||||||
|
nextCursor: input.cursor ? null : '9007199254741001',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: (input.cursor ? 'e' : 'd').repeat(64),
|
||||||
|
sequence: input.cursor ? '9007199254741000' : '9007199254741001',
|
||||||
|
srcNationId: 2,
|
||||||
|
destNationId: 3,
|
||||||
|
category: 'DOCUMENT',
|
||||||
|
source: 'API',
|
||||||
|
eventType: input.cursor ? 'LETTER_DESTROYED' : 'LETTER_ACCEPTED',
|
||||||
|
documentId: 8,
|
||||||
|
previousDocumentId: 7,
|
||||||
|
year: 190,
|
||||||
|
month: 6,
|
||||||
|
actor: {
|
||||||
|
generalId: 1,
|
||||||
|
name: '당시 외교권자',
|
||||||
|
nationId: 2,
|
||||||
|
officerLevel: 12,
|
||||||
|
npcState: 0,
|
||||||
|
},
|
||||||
|
createdAt: world.asOf,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
case 'playAudit.diplomacyEvent':
|
||||||
|
return result({
|
||||||
|
...world,
|
||||||
|
event: {
|
||||||
|
id: input.id,
|
||||||
|
sequence: '9007199254741001',
|
||||||
|
srcNationId: 2,
|
||||||
|
destNationId: 3,
|
||||||
|
category: 'DOCUMENT',
|
||||||
|
source: 'API',
|
||||||
|
eventType: 'LETTER_ACCEPTED',
|
||||||
|
documentId: 8,
|
||||||
|
previousDocumentId: 7,
|
||||||
|
year: 190,
|
||||||
|
month: 6,
|
||||||
|
actor: {
|
||||||
|
generalId: 1,
|
||||||
|
name: '당시 외교권자',
|
||||||
|
nationId: 2,
|
||||||
|
officerLevel: 12,
|
||||||
|
npcState: 0,
|
||||||
|
},
|
||||||
|
createdAt: world.asOf,
|
||||||
|
before: { state: 'PROPOSED' },
|
||||||
|
after: { state: 'ACTIVATED' },
|
||||||
|
tick: '100',
|
||||||
|
clockRevision: '1',
|
||||||
|
ordinal: 1,
|
||||||
|
executionId: 'fixture',
|
||||||
|
requestId: 'fixture-request',
|
||||||
|
inputSequence: '9007199254740993',
|
||||||
|
documentStatus: String(input.id).startsWith('e') ? 'HASH_MISMATCH' : 'AVAILABLE',
|
||||||
|
document: String(input.id).startsWith('e')
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
id: 8,
|
||||||
|
writtenAt: world.asOf,
|
||||||
|
brief: '협정',
|
||||||
|
briefHtml: '<p>협정</p>',
|
||||||
|
detail: '<script>window.auditInjected=true</script><p>서명된 내용</p>',
|
||||||
|
detailHtml: '<p><b>서명된 내용</b></p>',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
case 'playAudit.policyHistory':
|
case 'playAudit.policyHistory':
|
||||||
return result({
|
return result({
|
||||||
...world,
|
...world,
|
||||||
@@ -500,6 +573,7 @@ test('general logs load explicitly and cache each category without reloading ent
|
|||||||
await page.goto(gamePath('/play-audit?tab=generals&general=1'));
|
await page.goto(gamePath('/play-audit?tab=generals&general=1'));
|
||||||
await expect(page.getByRole('button', { name: '장수 기록 조회', exact: true })).toBeVisible();
|
await expect(page.getByRole('button', { name: '장수 기록 조회', exact: true })).toBeVisible();
|
||||||
expect(requests.filter((r) => r.operation === 'playAudit.generalLogs')).toHaveLength(0);
|
expect(requests.filter((r) => r.operation === 'playAudit.generalLogs')).toHaveLength(0);
|
||||||
|
await expect(page.getByRole('rowheader', { name: /감사장수/ })).toBeVisible();
|
||||||
const listCount = requests.filter((r) => r.operation === 'playAudit.generals').length;
|
const listCount = requests.filter((r) => r.operation === 'playAudit.generals').length;
|
||||||
await page.getByRole('button', { name: '장수 기록 조회', exact: true }).click();
|
await page.getByRole('button', { name: '장수 기록 조회', exact: true }).click();
|
||||||
await expect(page.getByText('generalHistory 감사 로그', { exact: false })).toBeVisible();
|
await expect(page.getByText('generalHistory 감사 로그', { exact: false })).toBeVisible();
|
||||||
@@ -711,3 +785,81 @@ test('initial observation is separate from month-end and final snapshots', async
|
|||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
await capture(page, 'mobile-initial-observation');
|
await capture(page, 'mobile-initial-observation');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('diplomacy history loads selected safe document details without reloading the list', async ({ page }) => {
|
||||||
|
const requests = await install(page);
|
||||||
|
await page.goto(
|
||||||
|
gamePath('/play-audit?tab=diplomacy&nation=2&otherNation=3&fromYear=190&fromMonth=1&year=190&month=6')
|
||||||
|
);
|
||||||
|
await expect(page.getByRole('button', { name: '문서 승인', exact: true })).toBeVisible();
|
||||||
|
expect(requests.some(({ operation }) => operation === 'playAudit.diplomacyEvent')).toBe(false);
|
||||||
|
const count = requests.filter(({ operation }) => operation === 'playAudit.diplomacyHistory').length;
|
||||||
|
const nations = requests.filter(({ operation }) => operation === 'playAudit.nations').length;
|
||||||
|
await page.getByRole('button', { name: '문서 승인', exact: true }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('region', { name: '당시 외교 문서' }).getByText('서명된 내용', { exact: true })
|
||||||
|
).toBeVisible();
|
||||||
|
expect(requests.filter(({ operation }) => operation === 'playAudit.diplomacyHistory')).toHaveLength(count);
|
||||||
|
expect(requests.filter(({ operation }) => operation === 'playAudit.nations')).toHaveLength(nations);
|
||||||
|
await page.getByText('원문 HTML 확인', { exact: true }).click();
|
||||||
|
await expect(page.locator('pre').filter({ hasText: '<script>window.auditInjected=true</script>' })).toBeVisible();
|
||||||
|
expect(await page.evaluate(() => Reflect.get(window, 'auditInjected'))).toBeUndefined();
|
||||||
|
await capture(page, 'desktop-diplomacy-detail');
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByRole('heading', { name: '문서 #8' })).toBeVisible();
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await capture(page, 'mobile-diplomacy-detail');
|
||||||
|
const comparison = page.getByLabel('외교 전후 값', { exact: true });
|
||||||
|
await comparison.hover();
|
||||||
|
await page.mouse.wheel(650, 0);
|
||||||
|
await expect.poll(() => comparison.evaluate((node) => node.scrollLeft)).toBeGreaterThan(0);
|
||||||
|
await page.getByRole('button', { name: '외교 상세 닫기' }).click();
|
||||||
|
await page.getByRole('button', { name: '다음 사건 50개' }).click();
|
||||||
|
await page.getByRole('button', { name: '문서 파기', exact: true }).click();
|
||||||
|
await expect(page.getByRole('alert')).toContainText('해시와 달라');
|
||||||
|
await expect(page.getByRole('region', { name: '당시 외교 문서' })).toHaveCount(0);
|
||||||
|
const before = requests.filter(({ operation }) => operation === 'playAudit.diplomacyHistory').length;
|
||||||
|
await page.getByLabel('시작 월', { exact: true }).fill('2');
|
||||||
|
expect(requests.filter(({ operation }) => operation === 'playAudit.diplomacyHistory')).toHaveLength(before);
|
||||||
|
await page.getByRole('button', { name: '조회', exact: true }).click();
|
||||||
|
await expect
|
||||||
|
.poll(() => requests.filter(({ operation }) => operation === 'playAudit.diplomacyHistory').length)
|
||||||
|
.toBeGreaterThan(before);
|
||||||
|
expect(requests.filter(({ operation }) => operation === 'playAudit.diplomacyHistory').at(-1)?.input.from).toEqual({
|
||||||
|
year: 190,
|
||||||
|
month: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('diplomacy detail failure retries independently', async ({ page }) => {
|
||||||
|
const requests = await install(page);
|
||||||
|
let fail = true;
|
||||||
|
await page.route(gameTrpcRoute, async (route) => {
|
||||||
|
if (fail && route.request().url().includes('playAudit.diplomacyEvent')) {
|
||||||
|
fail = false;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 500,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify([
|
||||||
|
{
|
||||||
|
error: {
|
||||||
|
message: '외교 상세 재시도',
|
||||||
|
code: -32603,
|
||||||
|
data: { code: 'INTERNAL_SERVER_ERROR', httpStatus: 500 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
} else await route.fallback();
|
||||||
|
});
|
||||||
|
await page.goto(
|
||||||
|
gamePath('/play-audit?tab=diplomacy&nation=2&otherNation=3&fromYear=190&fromMonth=1&year=190&month=6')
|
||||||
|
);
|
||||||
|
await expect(page.getByRole('button', { name: '문서 승인', exact: true })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: '문서 승인', exact: true }).click();
|
||||||
|
await expect(page.getByRole('alert')).toContainText('외교 상세 재시도');
|
||||||
|
const count = requests.filter(({ operation }) => operation === 'playAudit.diplomacyHistory').length;
|
||||||
|
await page.getByRole('button', { name: '상세 다시 조회' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: '문서 #8' })).toBeVisible();
|
||||||
|
expect(requests.filter(({ operation }) => operation === 'playAudit.diplomacyHistory')).toHaveLength(count);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { trpc } from '../../utils/trpc';
|
||||||
|
const props = defineProps<{
|
||||||
|
nationId: number;
|
||||||
|
otherNationId: number;
|
||||||
|
from: { year: number; month: number };
|
||||||
|
to: { year: number; month: number };
|
||||||
|
}>();
|
||||||
|
type History = Awaited<ReturnType<typeof trpc.playAudit.diplomacyHistory.query>>;
|
||||||
|
type Detail = Awaited<ReturnType<typeof trpc.playAudit.diplomacyEvent.query>>;
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const data = ref<History | null>(null);
|
||||||
|
const detail = ref<Detail | null>(null);
|
||||||
|
const error = ref('');
|
||||||
|
const detailError = ref('');
|
||||||
|
const loading = ref(false);
|
||||||
|
const detailLoading = ref(false);
|
||||||
|
let generation = 0;
|
||||||
|
let detailGeneration = 0;
|
||||||
|
const selected = computed(() => (typeof route.query.event === 'string' ? route.query.event : null));
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
LETTER_PROPOSED: '문서 제안',
|
||||||
|
LETTER_REPLACED: '문서 교체',
|
||||||
|
LETTER_ACCEPTED: '문서 승인',
|
||||||
|
LETTER_REJECTED: '문서 거절',
|
||||||
|
LETTER_WITHDRAWN: '문서 회수',
|
||||||
|
LETTER_DESTROY_REQUESTED: '문서 파기 요청',
|
||||||
|
LETTER_DESTROYED: '문서 파기',
|
||||||
|
MONTHLY_RELATION_CHANGED: '월간 관계 변경',
|
||||||
|
TURN_RELATION_CHANGED: '명령 관계 변경',
|
||||||
|
MESSAGE_ACCEPTED_noAggression: '불가침 체결',
|
||||||
|
MESSAGE_ACCEPTED_cancelNA: '불가침 파기',
|
||||||
|
MESSAGE_ACCEPTED_stopWar: '종전 합의',
|
||||||
|
};
|
||||||
|
const fieldLabels: Record<string, string> = {
|
||||||
|
state: '상태',
|
||||||
|
term: '남은 기간',
|
||||||
|
dead: '사상자',
|
||||||
|
isDead: '소멸 표시',
|
||||||
|
isShowing: '표시 여부',
|
||||||
|
srcSignerId: '발신 서명 장수',
|
||||||
|
destSignerId: '수신 서명 장수',
|
||||||
|
srcNationName: '발신 국명',
|
||||||
|
destNationName: '수신 국명',
|
||||||
|
srcSignerName: '발신 장수명',
|
||||||
|
destSignerName: '수신 장수명',
|
||||||
|
stateOption: '파기 요청 상태',
|
||||||
|
reason: '사유',
|
||||||
|
reasonAction: '사유 종류',
|
||||||
|
reasonActorId: '사유 작성 장수',
|
||||||
|
};
|
||||||
|
const fields = computed(() => {
|
||||||
|
const event = detail.value?.event;
|
||||||
|
return [...new Set([...Object.keys(event?.before ?? {}), ...Object.keys(event?.after ?? {})])].map((key) => ({
|
||||||
|
key,
|
||||||
|
before: event?.before ? Reflect.get(event.before, key) : null,
|
||||||
|
after: event?.after ? Reflect.get(event.after, key) : null,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
const valueText = (key: string, value: unknown) => {
|
||||||
|
if (value === null || value === undefined) return '미관측 / 없음';
|
||||||
|
if (key === 'state')
|
||||||
|
return (
|
||||||
|
(
|
||||||
|
{
|
||||||
|
PROPOSED: '제안',
|
||||||
|
ACTIVATED: '승인',
|
||||||
|
REPLACED: '교체',
|
||||||
|
CANCELLED: '종료',
|
||||||
|
'0': '전쟁',
|
||||||
|
'1': '선포',
|
||||||
|
'2': '교역',
|
||||||
|
'7': '불가침',
|
||||||
|
} as Record<string, string>
|
||||||
|
)[String(value)] ?? String(value)
|
||||||
|
);
|
||||||
|
if (typeof value === 'boolean') return value ? '예' : '아니오';
|
||||||
|
if (key === 'stateOption')
|
||||||
|
return (
|
||||||
|
({ try_destroy_src: '발신국 파기 요청', try_destroy_dest: '수신국 파기 요청' } as Record<string, string>)[
|
||||||
|
String(value)
|
||||||
|
] ?? String(value)
|
||||||
|
);
|
||||||
|
return String(value);
|
||||||
|
};
|
||||||
|
const message = (cause: unknown) => (cause instanceof Error ? cause.message : '외교 기록을 조회하지 못했습니다.');
|
||||||
|
const load = async (append = false) => {
|
||||||
|
const request = ++generation;
|
||||||
|
loading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
if (!append) data.value = null;
|
||||||
|
try {
|
||||||
|
const response = await trpc.playAudit.diplomacyHistory.query({
|
||||||
|
...props,
|
||||||
|
limit: 50,
|
||||||
|
cursor: append ? (data.value?.nextCursor ?? undefined) : undefined,
|
||||||
|
});
|
||||||
|
if (request === generation)
|
||||||
|
data.value = {
|
||||||
|
...response,
|
||||||
|
items: append ? [...(data.value?.items ?? []), ...response.items] : response.items,
|
||||||
|
};
|
||||||
|
} catch (cause) {
|
||||||
|
if (request === generation) error.value = message(cause);
|
||||||
|
} finally {
|
||||||
|
if (request === generation) loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const loadDetail = async () => {
|
||||||
|
const request = ++detailGeneration;
|
||||||
|
detail.value = null;
|
||||||
|
detailError.value = '';
|
||||||
|
detailLoading.value = false;
|
||||||
|
if (!selected.value) return;
|
||||||
|
detailLoading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await trpc.playAudit.diplomacyEvent.query({ id: selected.value });
|
||||||
|
if (request === detailGeneration) detail.value = response;
|
||||||
|
} catch (cause) {
|
||||||
|
if (request === detailGeneration) detailError.value = message(cause);
|
||||||
|
} finally {
|
||||||
|
if (request === detailGeneration) detailLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const select = (id: string | null) => router.push({ query: { ...route.query, event: id ?? undefined } });
|
||||||
|
watch(
|
||||||
|
[
|
||||||
|
() => props.nationId,
|
||||||
|
() => props.otherNationId,
|
||||||
|
() => props.from.year,
|
||||||
|
() => props.from.month,
|
||||||
|
() => props.to.year,
|
||||||
|
() => props.to.month,
|
||||||
|
],
|
||||||
|
() => {
|
||||||
|
void load();
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
watch(
|
||||||
|
selected,
|
||||||
|
() => {
|
||||||
|
void loadDetail();
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<section aria-label="외교 감사 이력">
|
||||||
|
<p>기록된 문서와 관계 변경입니다. 수집 이전의 상태와 기록 사이의 미관측 변경은 복원하지 않습니다.</p>
|
||||||
|
<p v-if="loading" role="status">외교 이력 조회 중…</p>
|
||||||
|
<p v-if="error" role="alert">{{ error }} <button class="legacy-button" @click="load()">다시 조회</button></p>
|
||||||
|
<template v-if="data">
|
||||||
|
<p v-if="data.coverage === 'IDENTITY_MISSING'">기수 식별자가 없어 외교 이력을 조회할 수 없습니다.</p>
|
||||||
|
<p v-else-if="!data.items.length">선택한 기간에 기록된 외교 사건이 없습니다.</p>
|
||||||
|
<div v-else class="table-scroll" tabindex="0" aria-label="외교 사건 목록">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>사건</th>
|
||||||
|
<th>게임 시각</th>
|
||||||
|
<th>방향</th>
|
||||||
|
<th>당시 주체</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in data.items" :key="item.id">
|
||||||
|
<td>
|
||||||
|
<button class="legacy-button" @click="select(item.id)">
|
||||||
|
{{ labels[item.eventType] ?? item.eventType }}</button
|
||||||
|
><br />순번 {{ item.sequence }}
|
||||||
|
</td>
|
||||||
|
<td>{{ item.year }}년 {{ item.month }}월</td>
|
||||||
|
<td>국가 #{{ item.srcNationId }} → #{{ item.destNationId }}</td>
|
||||||
|
<td>
|
||||||
|
{{
|
||||||
|
item.actor
|
||||||
|
? `${item.actor.name} (#${item.actor.generalId}) · 직책 ${item.actor.officerLevel}`
|
||||||
|
: '월간 처리 / 관측 기준'
|
||||||
|
}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<button v-if="data.nextCursor" class="legacy-button" :disabled="loading" @click="load(true)">
|
||||||
|
다음 사건 50개
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<section v-if="selected" aria-label="선택 외교 사건">
|
||||||
|
<h3>선택 외교 사건 <button class="legacy-button" @click="select(null)">외교 상세 닫기</button></h3>
|
||||||
|
<p v-if="detailLoading" role="status">외교 상세 조회 중…</p>
|
||||||
|
<p v-if="detailError" role="alert">
|
||||||
|
{{ detailError }} <button class="legacy-button" @click="loadDetail">상세 다시 조회</button>
|
||||||
|
</p>
|
||||||
|
<template v-if="detail">
|
||||||
|
<p>
|
||||||
|
{{ labels[detail.event.eventType] ?? detail.event.eventType }} · {{ detail.event.year }}년
|
||||||
|
{{ detail.event.month }}월 · 순번 {{ detail.event.sequence }}
|
||||||
|
</p>
|
||||||
|
<p>기록 시각 {{ detail.event.createdAt }}</p>
|
||||||
|
<p v-if="detail.event.previousDocumentId">이전 문서 #{{ detail.event.previousDocumentId }}</p>
|
||||||
|
<p v-if="detail.event.actor">
|
||||||
|
{{ detail.event.actor.name }} (#{{ detail.event.actor.generalId }}) · 당시 국가 #{{
|
||||||
|
detail.event.actor.nationId
|
||||||
|
}}
|
||||||
|
· 직책 {{ detail.event.actor.officerLevel }}
|
||||||
|
<span v-if="detail.event.actor.actionKey">· {{ detail.event.actor.actionKey }}</span>
|
||||||
|
</p>
|
||||||
|
<div class="table-scroll" tabindex="0" aria-label="외교 전후 값">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>항목</th>
|
||||||
|
<th>변경 전</th>
|
||||||
|
<th>변경 후</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="field in fields" :key="field.key">
|
||||||
|
<th scope="row">{{ fieldLabels[field.key] ?? field.key }}</th>
|
||||||
|
<td>{{ valueText(field.key, field.before) }}</td>
|
||||||
|
<td>{{ valueText(field.key, field.after) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p v-if="detail.event.documentStatus === 'MISSING_REFERENCE'" role="status">
|
||||||
|
참조 문서가 없어 원문을 표시할 수 없습니다.
|
||||||
|
</p>
|
||||||
|
<p v-if="detail.event.documentStatus === 'HASH_MISMATCH'" role="alert">
|
||||||
|
원문이 기록 당시 해시와 달라 표시를 중단했습니다.
|
||||||
|
</p>
|
||||||
|
<section v-if="detail.event.document" aria-label="당시 외교 문서">
|
||||||
|
<h4>문서 #{{ detail.event.document.id }}</h4>
|
||||||
|
<p>작성 시각 {{ detail.event.document.writtenAt }}</p>
|
||||||
|
<!-- 서버가 기존 외교 HTML allowlist로 정제한 표시용 본문만 렌더링한다. -->
|
||||||
|
<div class="document-body" v-html="detail.event.document.briefHtml" />
|
||||||
|
<div class="document-body" v-html="detail.event.document.detailHtml" />
|
||||||
|
<details>
|
||||||
|
<summary>원문 HTML 확인</summary>
|
||||||
|
<pre>{{ detail.event.document.brief }}</pre>
|
||||||
|
<pre>{{ detail.event.document.detail }}</pre>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
<details>
|
||||||
|
<summary>실행 연결</summary>
|
||||||
|
<p>
|
||||||
|
tick {{ detail.event.tick ?? '미상' }} · 시계 버전 {{ detail.event.clockRevision ?? '미상' }} ·
|
||||||
|
실행 {{ detail.event.executionId }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
요청 {{ detail.event.requestId ?? '해당 없음' }} · 입력 순번
|
||||||
|
{{ detail.event.inputSequence ?? '해당 없음' }}
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.table-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 600px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
border: 1px solid var(--legacy-border, #777);
|
||||||
|
padding: 6px;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
p,
|
||||||
|
td,
|
||||||
|
pre {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.document-body {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.document-body :deep(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,6 +5,7 @@ import PanelCard from '../components/ui/PanelCard.vue';
|
|||||||
import AuditNationSeries from '../components/playAudit/AuditNationSeries.vue';
|
import AuditNationSeries from '../components/playAudit/AuditNationSeries.vue';
|
||||||
import AuditNationSnapshot from '../components/playAudit/AuditNationSnapshot.vue';
|
import AuditNationSnapshot from '../components/playAudit/AuditNationSnapshot.vue';
|
||||||
import AuditGeneralDetail from '../components/playAudit/AuditGeneralDetail.vue';
|
import AuditGeneralDetail from '../components/playAudit/AuditGeneralDetail.vue';
|
||||||
|
import AuditDiplomacyHistory from '../components/playAudit/AuditDiplomacyHistory.vue';
|
||||||
import AuditPolicyHistory from '../components/playAudit/AuditPolicyHistory.vue';
|
import AuditPolicyHistory from '../components/playAudit/AuditPolicyHistory.vue';
|
||||||
import AuditCityDetail from '../components/playAudit/AuditCityDetail.vue';
|
import AuditCityDetail from '../components/playAudit/AuditCityDetail.vue';
|
||||||
import { usePageExit } from '../composables/usePageExit';
|
import { usePageExit } from '../composables/usePageExit';
|
||||||
@@ -52,6 +53,13 @@ const appliedPolicy = computed(() => {
|
|||||||
to,
|
to,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
const otherNationId = ref('');
|
||||||
|
const appliedDiplomacy = computed(() => ({
|
||||||
|
nationId: appliedPolicy.value.nationId,
|
||||||
|
otherNationId: numeric(route.query.otherNation, 0),
|
||||||
|
from: appliedPolicy.value.from,
|
||||||
|
to: appliedPolicy.value.to,
|
||||||
|
}));
|
||||||
const nationId = ref('');
|
const nationId = ref('');
|
||||||
const cityId = ref('');
|
const cityId = ref('');
|
||||||
const population = ref('');
|
const population = ref('');
|
||||||
@@ -124,10 +132,11 @@ const result = computed(() =>
|
|||||||
: series.value
|
: series.value
|
||||||
);
|
);
|
||||||
const readQuery = () => {
|
const readQuery = () => {
|
||||||
tab.value = ['nations', 'generals', 'cities', 'policies'].includes(String(route.query.tab))
|
tab.value = ['nations', 'generals', 'cities', 'policies', 'diplomacy'].includes(String(route.query.tab))
|
||||||
? String(route.query.tab)
|
? String(route.query.tab)
|
||||||
: 'nations';
|
: 'nations';
|
||||||
policyArea.value = appliedPolicy.value.area;
|
policyArea.value = appliedPolicy.value.area;
|
||||||
|
otherNationId.value = route.query.otherNation ? String(numeric(route.query.otherNation, 0)) : '';
|
||||||
nationId.value = route.query.nation ? String(numeric(route.query.nation, 0)) : '';
|
nationId.value = route.query.nation ? String(numeric(route.query.nation, 0)) : '';
|
||||||
cityId.value = route.query.city ? String(numeric(route.query.city, 0)) : '';
|
cityId.value = route.query.city ? String(numeric(route.query.city, 0)) : '';
|
||||||
population.value = ['human', 'npc', 'troopNpc'].includes(String(route.query.population))
|
population.value = ['human', 'npc', 'troopNpc'].includes(String(route.query.population))
|
||||||
@@ -183,7 +192,7 @@ const load = async (append = false) => {
|
|||||||
...response,
|
...response,
|
||||||
items: append ? [...(cities.value?.items ?? []), ...response.items] : response.items,
|
items: append ? [...(cities.value?.items ?? []), ...response.items] : response.items,
|
||||||
};
|
};
|
||||||
} else if (tab.value === 'policies') {
|
} else if (tab.value === 'policies' || tab.value === 'diplomacy') {
|
||||||
// 정책 목록/상세는 해당 component가 필요한 요청만 실행한다.
|
// 정책 목록/상세는 해당 component가 필요한 요청만 실행한다.
|
||||||
} else if (nationId.value !== '' && (moment.value === 'final' || moment.value === 'initial')) {
|
} else if (nationId.value !== '' && (moment.value === 'final' || moment.value === 'initial')) {
|
||||||
const response = await trpc.playAudit.nationSnapshot.query({
|
const response = await trpc.playAudit.nationSnapshot.query({
|
||||||
@@ -229,6 +238,7 @@ const apply = async () => {
|
|||||||
const query = {
|
const query = {
|
||||||
tab: tab.value,
|
tab: tab.value,
|
||||||
nation: nationId.value || undefined,
|
nation: nationId.value || undefined,
|
||||||
|
otherNation: tab.value === 'diplomacy' ? otherNationId.value || undefined : undefined,
|
||||||
city: cityId.value || undefined,
|
city: cityId.value || undefined,
|
||||||
population: population.value || undefined,
|
population: population.value || undefined,
|
||||||
at: moment.value,
|
at: moment.value,
|
||||||
@@ -275,7 +285,9 @@ const moreNations = async () => {
|
|||||||
watch(
|
watch(
|
||||||
() =>
|
() =>
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
Object.entries(route.query).filter(([key]) => key !== 'general' && key !== 'cityRecord' && key !== 'policy')
|
Object.entries(route.query).filter(
|
||||||
|
([key]) => key !== 'general' && key !== 'cityRecord' && key !== 'policy' && key !== 'event'
|
||||||
|
)
|
||||||
),
|
),
|
||||||
() => {
|
() => {
|
||||||
if (authorized.value) {
|
if (authorized.value) {
|
||||||
@@ -331,14 +343,19 @@ onMounted(async () => {
|
|||||||
<option value="generals">전체 장수</option>
|
<option value="generals">전체 장수</option>
|
||||||
<option value="cities">도시 상태</option>
|
<option value="cities">도시 상태</option>
|
||||||
<option value="policies">정책 변경 이력</option>
|
<option value="policies">정책 변경 이력</option>
|
||||||
|
<option value="diplomacy">외교 이력</option>
|
||||||
</select></label
|
</select></label
|
||||||
>
|
>
|
||||||
<label
|
<label
|
||||||
>국가<select class="legacy-sort-select" v-model="nationId">
|
>국가<select class="legacy-sort-select" v-model="nationId">
|
||||||
<option value="">
|
<option value="">
|
||||||
{{ tab === 'nations' || tab === 'policies' ? '국가 선택' : '모든 국가' }}
|
{{
|
||||||
|
tab === 'nations' || tab === 'policies' || tab === 'diplomacy'
|
||||||
|
? '국가 선택'
|
||||||
|
: '모든 국가'
|
||||||
|
}}
|
||||||
</option>
|
</option>
|
||||||
<option value="0">무소속</option>
|
<option v-if="tab !== 'diplomacy'" value="0">무소속</option>
|
||||||
<option
|
<option
|
||||||
v-for="nation in nations?.items.filter((item) => item.id !== 0)"
|
v-for="nation in nations?.items.filter((item) => item.id !== 0)"
|
||||||
:key="nation.id"
|
:key="nation.id"
|
||||||
@@ -376,7 +393,7 @@ onMounted(async () => {
|
|||||||
</select></label
|
</select></label
|
||||||
>
|
>
|
||||||
<label
|
<label
|
||||||
>{{ tab === 'nations' || tab === 'policies' ? '종료 연도' : '표본 연도'
|
>{{ tab === 'nations' || tab === 'policies' || tab === 'diplomacy' ? '종료 연도' : '표본 연도'
|
||||||
}}<input
|
}}<input
|
||||||
v-model.number="year"
|
v-model.number="year"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -393,7 +410,11 @@ onMounted(async () => {
|
|||||||
required
|
required
|
||||||
/></label>
|
/></label>
|
||||||
<template
|
<template
|
||||||
v-if="(tab === 'nations' && moment !== 'final' && moment !== 'initial') || tab === 'policies'"
|
v-if="
|
||||||
|
(tab === 'nations' && moment !== 'final' && moment !== 'initial') ||
|
||||||
|
tab === 'policies' ||
|
||||||
|
tab === 'diplomacy'
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<label
|
<label
|
||||||
>시작 연도<input
|
>시작 연도<input
|
||||||
@@ -418,6 +439,26 @@ onMounted(async () => {
|
|||||||
</select></label
|
</select></label
|
||||||
>
|
>
|
||||||
</template>
|
</template>
|
||||||
|
<label v-if="tab === 'diplomacy'"
|
||||||
|
>상대 국가<select class="legacy-sort-select" v-model="otherNationId">
|
||||||
|
<option value="">국가 선택</option>
|
||||||
|
<option
|
||||||
|
v-for="nation in nations?.items.filter((item) => item.id > 0)"
|
||||||
|
:key="nation.id"
|
||||||
|
:value="String(nation.id)"
|
||||||
|
>
|
||||||
|
{{ nation.name }} (#{{ nation.id }})
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
v-if="
|
||||||
|
otherNationId && !nations?.items.some((item) => String(item.id) === otherNationId)
|
||||||
|
"
|
||||||
|
:value="otherNationId"
|
||||||
|
>
|
||||||
|
국가 #{{ otherNationId }}
|
||||||
|
</option>
|
||||||
|
</select></label
|
||||||
|
>
|
||||||
<label v-if="tab === 'policies'"
|
<label v-if="tab === 'policies'"
|
||||||
>정책 영역<select class="legacy-sort-select" v-model="policyArea">
|
>정책 영역<select class="legacy-sort-select" v-model="policyArea">
|
||||||
<option value="NPC_VALUES">NPC 국가 정책</option>
|
<option value="NPC_VALUES">NPC 국가 정책</option>
|
||||||
@@ -450,13 +491,25 @@ onMounted(async () => {
|
|||||||
? '전체 장수'
|
? '전체 장수'
|
||||||
: tab === 'policies'
|
: tab === 'policies'
|
||||||
? '정책 변경 이력'
|
? '정책 변경 이력'
|
||||||
|
: tab === 'diplomacy'
|
||||||
|
? '외교 이력'
|
||||||
: '도시 상태'
|
: '도시 상태'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<p v-if="result">조회 시각 {{ result.asOf }} · tick {{ result.tick ?? '없음' }}</p>
|
<p v-if="result">조회 시각 {{ result.asOf }} · tick {{ result.tick ?? '없음' }}</p>
|
||||||
<p v-if="(tab === 'nations' || tab === 'policies') && !nationId">
|
<p v-if="(tab === 'nations' || tab === 'policies' || tab === 'diplomacy') && !nationId">
|
||||||
국가를 선택하고 조회해 주세요. 멸망한 국가는 해당 월말 기준의 국가 목록에서 선택할 수 있습니다.
|
국가를 선택하고 조회해 주세요. 멸망한 국가는 해당 월말 기준의 국가 목록에서 선택할 수 있습니다.
|
||||||
</p>
|
</p>
|
||||||
|
<p v-if="tab === 'diplomacy' && !otherNationId">상대 국가를 선택하고 조회해 주세요.</p>
|
||||||
|
<AuditDiplomacyHistory
|
||||||
|
v-if="
|
||||||
|
tab === 'diplomacy' &&
|
||||||
|
route.query.tab === 'diplomacy' &&
|
||||||
|
route.query.nation &&
|
||||||
|
route.query.otherNation
|
||||||
|
"
|
||||||
|
v-bind="appliedDiplomacy"
|
||||||
|
/>
|
||||||
<AuditPolicyHistory
|
<AuditPolicyHistory
|
||||||
v-if="tab === 'policies' && route.query.tab === 'policies' && route.query.nation"
|
v-if="tab === 'policies' && route.query.tab === 'policies' && route.query.nation"
|
||||||
v-bind="appliedPolicy"
|
v-bind="appliedPolicy"
|
||||||
|
|||||||
@@ -185,6 +185,25 @@ coverage는 RECORDED_EVENTS_ONLY이며 최초 수집 전의 상태 완전성을
|
|||||||
본문 지연 조회/hash 상태·기수 전환의 접근 차단과 input_event 무증가를 검증했다.
|
본문 지연 조회/hash 상태·기수 전환의 접근 차단과 input_event 무증가를 검증했다.
|
||||||
외교 화면과 초기 기준 수집은 아직 남아 있다.
|
외교 화면과 초기 기준 수집은 아직 남아 있다.
|
||||||
|
|
||||||
|
### 외교 감사 화면
|
||||||
|
|
||||||
|
CHE/HWE의 기존 `PlayAuditView` 필터·PanelCard에 외교 이력을 추가했다. 국가/상대 국가,
|
||||||
|
기간과 선택 event를 URL에 보존하고 기존 정책 이력과 같은 표·상세·pagination 흐름을
|
||||||
|
사용한다. 상세 선택·닫기·오류 재시도는 국가/이력 목록을 다시 읽지 않는다. 필터 편집은
|
||||||
|
조회 적용 전 요청하지 않는다. 본문은 선택한 상세 응답에서만 읽는다.
|
||||||
|
|
||||||
|
원문 hash를 검증한 뒤 기존 `purifyDiplomacyHtml`로 만든 briefHtml/detailHtml만 렌더링한다.
|
||||||
|
원문 HTML은 별도 details 안에서 escaped text로 보여준다. 이 때문에 선택 문서의
|
||||||
|
응답에는 원문/정제본이 함께 오지만 추가 DB 조회나 중복 저장은 없다. 해시 불일치와
|
||||||
|
참조 부재에는 본문을 표시하지 않는다. 이전 문서 번호와 당시 actor/상태/실행 연결을
|
||||||
|
보이고, 무소속은 외교 국가 선택에서 제외한다.
|
||||||
|
|
||||||
|
실제 HTTP에서 script 포함 원문의 보존/표시용 정제를 확인하고, production bundle
|
||||||
|
Chromium의 CHE/HWE에서 desktop1280×720/mobile390×844, DPR1로 검증했다.
|
||||||
|
선택적 상세/재시도·deep link/reload·pagination·draft 적용과 표 내부 가로 스크롤,
|
||||||
|
원문 script 비실행을 검사한다. 외교 최초 기준과 최종 mutation inventory/전체 비용
|
||||||
|
검증은 남아 있으며 화면 추가만으로 R4 전체 완료를 판단하지 않는다.
|
||||||
|
|
||||||
## NPC·국방 정책 버전 저장 기반
|
## NPC·국방 정책 버전 저장 기반
|
||||||
|
|
||||||
`PlayAuditPolicy`는 현재 기수/국가/영역별 불변 revision과 이전 버전 ID를 보존한다.
|
`PlayAuditPolicy`는 현재 기수/국가/영역별 불변 revision과 이전 버전 ID를 보존한다.
|
||||||
|
|||||||
Reference in New Issue
Block a user