feat: 프로필 감사 화면에 외교 이력과 안전한 문서 상세 연결
This commit is contained in:
@@ -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 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"
|
||||
|
||||
Reference in New Issue
Block a user