feat: 프로필 감사 화면에 외교 이력과 안전한 문서 상세 연결

This commit is contained in:
2026-09-16 06:29:26 +00:00
parent ff9bb8a428
commit 02ee706af7
6 changed files with 537 additions and 11 deletions
@@ -1,3 +1,4 @@
import { purifyDiplomacyHtml } from '../../security/diplomacyHtml.js';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
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,
brief: document.textBrief,
detail: document.textDetail,
briefHtml: purifyDiplomacyHtml(document.textBrief),
detailHtml: purifyDiplomacyHtml(document.textDetail),
writtenAt: document.date,
}
: null,
@@ -2255,7 +2255,7 @@ integration('game API security over HTTP transport', () => {
srcSignerId: generalId,
state: 'CANCELLED',
textBrief: '당시 외교 문서',
textDetail: '<p>역사 본문</p>',
textDetail: '<p>역사 본문</p><script>window.auditInjected=true</script>',
},
});
await db.playAuditDiplomacyEvent.createMany({
@@ -2338,7 +2338,10 @@ integration('game API security over HTTP transport', () => {
after: { state: 'ACTIVATED' },
inputSequence: '9007199254740993',
documentStatus: 'AVAILABLE',
document: { detail: '<p>역사 본문</p>' },
document: {
detail: '<p>역사 본문</p><script>window.auditInjected=true</script>',
detailHtml: '<p>역사 본문</p>',
},
},
},
},
+152
View File
@@ -89,6 +89,79 @@ const install = async (page: Page, denied = false) => {
],
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':
return result({
...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 expect(page.getByRole('button', { name: '장수 기록 조회', exact: true })).toBeVisible();
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;
await page.getByRole('button', { name: '장수 기록 조회', exact: true }).click();
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 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>
+62 -9
View File
@@ -5,6 +5,7 @@ import PanelCard from '../components/ui/PanelCard.vue';
import AuditNationSeries from '../components/playAudit/AuditNationSeries.vue';
import AuditNationSnapshot from '../components/playAudit/AuditNationSnapshot.vue';
import AuditGeneralDetail from '../components/playAudit/AuditGeneralDetail.vue';
import AuditDiplomacyHistory from '../components/playAudit/AuditDiplomacyHistory.vue';
import AuditPolicyHistory from '../components/playAudit/AuditPolicyHistory.vue';
import AuditCityDetail from '../components/playAudit/AuditCityDetail.vue';
import { usePageExit } from '../composables/usePageExit';
@@ -52,6 +53,13 @@ const appliedPolicy = computed(() => {
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 cityId = ref('');
const population = ref('');
@@ -124,10 +132,11 @@ const result = computed(() =>
: series.value
);
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)
: 'nations';
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)) : '';
cityId.value = route.query.city ? String(numeric(route.query.city, 0)) : '';
population.value = ['human', 'npc', 'troopNpc'].includes(String(route.query.population))
@@ -183,7 +192,7 @@ const load = async (append = false) => {
...response,
items: append ? [...(cities.value?.items ?? []), ...response.items] : response.items,
};
} else if (tab.value === 'policies') {
} else if (tab.value === 'policies' || tab.value === 'diplomacy') {
// 정책 목록/상세는 해당 component가 필요한 요청만 실행한다.
} else if (nationId.value !== '' && (moment.value === 'final' || moment.value === 'initial')) {
const response = await trpc.playAudit.nationSnapshot.query({
@@ -229,6 +238,7 @@ const apply = async () => {
const query = {
tab: tab.value,
nation: nationId.value || undefined,
otherNation: tab.value === 'diplomacy' ? otherNationId.value || undefined : undefined,
city: cityId.value || undefined,
population: population.value || undefined,
at: moment.value,
@@ -275,7 +285,9 @@ const moreNations = async () => {
watch(
() =>
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) {
@@ -331,14 +343,19 @@ onMounted(async () => {
<option value="generals">전체 장수</option>
<option value="cities">도시 상태</option>
<option value="policies">정책 변경 이력</option>
<option value="diplomacy">외교 이력</option>
</select></label
>
<label
>국가<select class="legacy-sort-select" v-model="nationId">
<option value="">
{{ tab === 'nations' || tab === 'policies' ? '국가 선택' : '모든 국가' }}
{{
tab === 'nations' || tab === 'policies' || tab === 'diplomacy'
? '국가 선택'
: '모든 국가'
}}
</option>
<option value="0">무소속</option>
<option v-if="tab !== 'diplomacy'" value="0">무소속</option>
<option
v-for="nation in nations?.items.filter((item) => item.id !== 0)"
:key="nation.id"
@@ -376,7 +393,7 @@ onMounted(async () => {
</select></label
>
<label
>{{ tab === 'nations' || tab === 'policies' ? '종료 연도' : '표본 연도'
>{{ tab === 'nations' || tab === 'policies' || tab === 'diplomacy' ? '종료 연도' : '표본 연도'
}}<input
v-model.number="year"
type="number"
@@ -393,7 +410,11 @@ onMounted(async () => {
required
/></label>
<template
v-if="(tab === 'nations' && moment !== 'final' && moment !== 'initial') || tab === 'policies'"
v-if="
(tab === 'nations' && moment !== 'final' && moment !== 'initial') ||
tab === 'policies' ||
tab === 'diplomacy'
"
>
<label
>시작 연도<input
@@ -418,6 +439,26 @@ onMounted(async () => {
</select></label
>
</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'"
>정책 영역<select class="legacy-sort-select" v-model="policyArea">
<option value="NPC_VALUES">NPC 국가 정책</option>
@@ -450,13 +491,25 @@ onMounted(async () => {
? '전체 장수'
: tab === 'policies'
? '정책 변경 이력'
: '도시 상태'
: tab === 'diplomacy'
? '외교 이력'
: '도시 상태'
"
>
<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 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
v-if="tab === 'policies' && route.query.tab === 'policies' && route.query.nation"
v-bind="appliedPolicy"
+19
View File
@@ -185,6 +185,25 @@ coverage는 RECORDED_EVENTS_ONLY이며 최초 수집 전의 상태 완전성을
본문 지연 조회/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·국방 정책 버전 저장 기반
`PlayAuditPolicy`는 현재 기수/국가/영역별 불변 revision과 이전 버전 ID를 보존한다.