플레이 감사를 3단 대시보드와 국가 추이 그래프로 개선한다

This commit is contained in:
2026-09-26 04:36:31 +00:00
parent 2b2edeaeff
commit e35679335c
12 changed files with 1040 additions and 321 deletions
@@ -1,3 +1,5 @@
import { GamePrisma } from '@sammo-ts/infra';
import { asRecord } from '@sammo-ts/common';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { auditProcedure, monthOrdinal, readAudit, readAuditWorld, zAuditMonth } from './shared.js';
@@ -5,6 +7,7 @@ import { auditProcedure, monthOrdinal, readAudit, readAuditWorld, zAuditMonth }
const zDex = z.object({ dex1: z.number(), dex2: z.number(), dex3: z.number(), dex4: z.number(), dex5: z.number() });
const zPopulation = z.object({
count: z.number().int().nonnegative(),
crew: z.number().nonnegative().nullable().default(null),
gold: z.number(),
rice: z.number(),
dex: zDex,
@@ -92,6 +95,34 @@ export const summarizeNationPeriod = (start: number, width: number, months: Nati
};
};
/** 같은 transaction에서 commit된 당월 정산만 반환한다. 미관측을 0으로 만들지 않는다. */
export const projectCurrentSettlement = (meta: unknown, year: number, month: number, nationId: number) => {
const flows = asRecord(asRecord(meta).playAuditFlows);
const matches = flows.year === year && flows.month === month;
const entries = asRecord(flows.entries);
const resource = (key: 'gold' | 'rice') => {
const row = asRecord(entries[`${nationId}:${key}`]);
if (
!matches ||
row.nationId !== nationId ||
row.resource !== key ||
typeof row.income !== 'number' ||
!Number.isFinite(row.income) ||
typeof row.paid !== 'number' ||
!Number.isFinite(row.paid)
)
return null;
return { income: row.income, paid: row.paid };
};
return {
year,
month,
gold: resource('gold'),
rice: resource('rice'),
complete: matches && flows.complete === true,
};
};
const zCalendarMonth = zAuditMonth.omit({ kind: true });
export const nationSeries = auditProcedure
.input(
@@ -151,6 +182,35 @@ export const nationSeries = auditProcedure
})
: [];
const dataBySample = new Map(nations.map((row) => [row.sampleId, zAuditNation.parse(row.data)]));
// 기존 집계는 같은 월의 장수 표본을 DB에서 합산한다. 원문은 전송하지 않는다.
const legacySamples = [...dataBySample]
.filter(([, nation]) => Object.values(nation.populations).some((group) => group.crew === null))
.map(([id]) => id);
if (legacySamples.length) {
const troops = await tx.$queryRaw<
{ sampleId: string; population: 'human' | 'npc' | 'troopNpc'; count: number; crew: number | null }[]
>(GamePrisma.sql`
SELECT sample_id AS "sampleId",
CASE WHEN npc_state = 5 THEN 'troopNpc' WHEN npc_state < 2 THEN 'human' ELSE 'npc' END AS population,
count(*)::int AS count,
CASE WHEN bool_and(jsonb_typeof(data->'crew') = 'number' AND data->'crew' IS NOT NULL)
THEN sum(CASE WHEN jsonb_typeof(data->'crew') = 'number' THEN (data->>'crew')::double precision END) ELSE NULL END AS crew
FROM play_audit_general
WHERE nation_id = ${input.nationId} AND sample_id IN (${GamePrisma.join(legacySamples)})
GROUP BY sample_id, population
`);
for (const sampleId of legacySamples) {
const nation = dataBySample.get(sampleId)!;
for (const key of ['human', 'npc', 'troopNpc'] as const) {
const group = nation.populations[key];
if (group.crew !== null) continue;
const row = troops.find((item) => item.sampleId === sampleId && item.population === key);
// 저장 인원수와 원본 표본 수가 같을 때만 확정한다.
if (group.count === 0 && !row) group.crew = 0;
else if (row?.count === group.count) group.crew = row.crew;
}
}
}
const sampleByMonth = new Map(samples.map((sample) => [monthOrdinal(sample.year, sample.month), sample]));
const items: ReturnType<typeof summarizeNationPeriod>[] = [];
for (let period = start; period <= end; period += width) {
@@ -166,6 +226,17 @@ export const nationSeries = auditProcedure
}
items.push(summarizeNationPeriod(period, width, months));
}
return { ...world, items, nextCursor: end < to ? dateOf(start + input.limit * width) : null };
const currentState =
world.serverId && end === current
? await tx.worldState.findFirst({ orderBy: { id: 'asc' }, select: { meta: true } })
: null;
return {
...world,
items,
currentSettlement: currentState
? projectCurrentSettlement(currentState.meta, world.year, world.month, input.nationId)
: null,
nextCursor: end < to ? dateOf(start + input.limit * width) : null,
};
})
);
@@ -1,12 +1,15 @@
import { describe, expect, it } from 'vitest';
import {
summarizeNationPeriod,
projectCurrentSettlement,
zAuditNation,
type AuditNationData,
type NationMonthPoint,
} from '../src/router/playAudit/nationSeries.js';
const population = {
count: 0,
crew: null,
gold: 0,
rice: 0,
dex: { dex1: 0, dex2: 0, dex3: 0, dex4: 0, dex5: 0 },
@@ -89,3 +92,44 @@ describe('play audit half-year summary', () => {
});
});
});
describe('current committed settlement', () => {
it.each([
[1, 'gold'],
[7, 'rice'],
] as const)('exposes month %s settlement before month-end', (month, resource) => {
const meta = {
playAuditFlows: {
year: 200,
month,
complete: true,
entries: { [`2:${resource}`]: { nationId: 2, resource, income: 1234.5, paid: 234 } },
},
};
expect(projectCurrentSettlement(meta, 200, month, 2)[resource]).toEqual({ income: 1234.5, paid: 234 });
expect(projectCurrentSettlement(meta, 200, month + 1, 2)[resource]).toBeNull();
expect(projectCurrentSettlement(meta, 200, month, 3)[resource]).toBeNull();
});
it('keeps absent and malformed observations unknown, preserves actual zero', () => {
expect(projectCurrentSettlement({}, 200, 1, 2).gold).toBeNull();
const meta = {
playAuditFlows: {
year: 200,
month: 1,
complete: false,
entries: { '2:gold': { nationId: 2, resource: 'gold', income: 0, paid: 0 } },
},
};
expect(projectCurrentSettlement(meta, 200, 1, 2)).toMatchObject({
gold: { income: 0, paid: 0 },
complete: false,
});
meta.playAuditFlows.entries['2:gold'].income = NaN;
expect(projectCurrentSettlement(meta, 200, 1, 2).gold).toBeNull();
});
it('reads old snapshots without inventing troop counts', () => {
const old = JSON.parse(JSON.stringify(nation(1)));
delete old.populations.human.crew;
expect(zAuditNation.parse(old).populations.human.crew).toBeNull();
});
});
@@ -3119,6 +3119,39 @@ integration('game API security over HTTP transport', () => {
},
},
});
await db.playAuditNation.update({
where: { sampleId_nationId: { sampleId: `${seasonId}:190:6`, nationId: ownerNationId } },
data: {
data: {
...asRecord(finalNation.data),
populations: {
human: { ...population, count: 2 },
npc: { ...population, count: 1 },
troopNpc: population,
},
},
},
});
await db.playAuditGeneral.createMany({
data: [1, 2].map((id) => ({
sampleId: `${seasonId}:190:6`,
generalId: id,
nationId: ownerNationId,
cityId: 0,
npcState: 0,
data: { crew: id * 1200 },
})),
});
await db.playAuditGeneral.create({
data: {
sampleId: `${seasonId}:190:6`,
generalId: 3,
nationId: ownerNationId,
cityId: 0,
npcState: 2,
data: { crew: 'invalid' },
},
});
const series = await get('nationSeries', admin, {
nationId: ownerNationId,
from: { year: 190, month: 1 },
@@ -3134,7 +3167,10 @@ integration('game API security over HTTP transport', () => {
year: 190,
month: 1,
complete: true,
stock: { gold: 600 },
stock: {
gold: 600,
populations: { human: { crew: 3600 }, npc: { crew: null }, troopNpc: { crew: 0 } },
},
flows: { incomeGold: 21, incomeRice: 0 },
},
],
@@ -3166,6 +3202,59 @@ integration('game API security over HTTP transport', () => {
result: { data: { items: [{ complete: false, stock: null, flows: { incomeGold: null } }] } },
});
// 정산 commit 직후, 아직 월말 표본이 없는 상태를 HTTP로 확인한다.
for (const [settlementMonth, resource] of [
[1, 'gold'],
[7, 'rice'],
] as const) {
await db.worldState.update({
where: { id: fixtureWorldId },
data: {
currentYear: 191,
currentMonth: settlementMonth,
meta: {
serverId: seasonId,
scenarioMeta: { startYear: 190 },
playAuditFlows: {
year: 191,
month: settlementMonth,
complete: true,
entries: {
[`${ownerNationId}:${resource}`]: {
nationId: ownerNationId,
resource,
income: 8765.5,
paid: 4321,
},
},
},
},
},
});
const input = {
nationId: ownerNationId,
from: { year: 191, month: settlementMonth },
to: { year: 191, month: settlementMonth },
};
expect((await get('nationSeries', admin, input)).body).toMatchObject({
result: {
data: {
currentSettlement: {
year: 191,
month: settlementMonth,
[resource]: { income: 8765.5, paid: 4321 },
},
items: [{ stock: null, complete: false }],
},
},
});
expect((await get('nationSeries', undefined, input)).status).toBe(401);
expect((await get('nationSeries', await token(['admin']), input)).status).toBe(403);
expect(
(await get('nationSeries', await token(['admin.playAudit.read:other:default']), input)).status
).toBe(403);
}
// A synchronized opening may start before the scenario's gameplay year.
await db.worldState.update({
where: { id: fixtureWorldId },
@@ -10,6 +10,7 @@ export type AuditPopulation = 'human' | 'npc' | 'troopNpc';
export interface AuditPopulationSummary {
count: number;
crew: number;
gold: number;
rice: number;
dex: AuditDex;
@@ -44,6 +45,7 @@ export interface AuditNationSnapshot {
const emptyDex = (): AuditDex => ({ dex1: 0, dex2: 0, dex3: 0, dex4: 0, dex5: 0 });
const emptyPopulation = (): AuditPopulationSummary => ({
count: 0,
crew: 0,
gold: 0,
rice: 0,
dex: emptyDex(),
@@ -173,6 +175,7 @@ export const buildAuditSnapshot = (input: {
const population = nations.get(general.nationId)?.populations[projected.population];
if (!population) continue;
population.count++;
population.crew += projected.crew;
population.gold += projected.gold;
population.rice += projected.rice;
for (const key of AUDIT_DEX_KEYS) population.dex[key] += projected.dex[key];
@@ -76,13 +76,15 @@ describe('play audit monthly projection', () => {
it('separates humans, NPCs and troop NPCs and retains empty nations and neutral generals', () => {
const humans = [buildGeneral(1, 1), { ...buildGeneral(2, 1), npcState: 1, gold: 0 }];
humans[0]!.meta.dex1 = 10;
humans[0]!.crew = 3210;
humans[1]!.crew = 790;
const result = buildAuditSnapshot({
nations: [buildNation(0, 0, {}), buildNation(1, 0, {}), buildNation(2, 0, {})],
cities: [buildCity(1, 2)],
generals: [
...humans,
{ ...buildGeneral(3, 1), npcState: 2 },
{ ...buildGeneral(4, 1), npcState: 5 },
{ ...buildGeneral(3, 1), npcState: 2, crew: 1200 },
{ ...buildGeneral(4, 1), npcState: 5, crew: 800 },
buildGeneral(5, 0),
],
settlements: [],
@@ -91,12 +93,13 @@ describe('play audit monthly projection', () => {
const nation = result.nations.find((row) => row.id === 1)!;
expect(nation.populations.human).toMatchObject({
count: 2,
crew: 4000,
gold: 2000,
averageGold: 1000,
averageDex: { dex1: 5 },
});
expect(nation.populations.npc.count).toBe(1);
expect(nation.populations.troopNpc.count).toBe(1);
expect(nation.populations.npc).toMatchObject({ count: 1, crew: 1200 });
expect(nation.populations.troopNpc).toMatchObject({ count: 1, crew: 800 });
expect(result.nations.find((row) => row.id === 2)!.populations.human.averageGold).toBeNull();
expect(result.nations.find((row) => row.id === 0)!.populations.npc.count).toBe(1);
expect(result.cities[0]!.nationId).toBe(2);
+103 -5
View File
@@ -14,7 +14,16 @@ const world = {
collectionStart: { year: 190, month: 1, tick: '0', observedAt: '2026-09-16T00:00:00.000Z' },
};
const dex = { dex1: 100, dex2: 200, dex3: 300, dex4: 400, dex5: 500 };
const population = { count: 2, gold: 200, rice: 400, dex, averageGold: 100, averageRice: 200, averageDex: dex };
const population = {
crew: 5000,
count: 2,
gold: 200,
rice: 400,
dex,
averageGold: 100,
averageRice: 200,
averageDex: dex,
};
const general = {
id: 1,
name: '감사장수',
@@ -76,7 +85,8 @@ const install = async (
page: Page,
denied = false,
baseline: boolean | 'document' | 'created' | 'removed' = false,
executionStatus?: 'PREPARING' | 'BLOCKED'
executionStatus?: 'PREPARING' | 'BLOCKED',
dashboard = false
) => {
const requests: { operation: string; input: Record<string, unknown> }[] = [];
await page.addInitScript((profile) => {
@@ -442,6 +452,13 @@ const install = async (
case 'playAudit.nationSeries':
return result({
...world,
currentSettlement: {
year: 190,
month: 7,
complete: true,
gold: null,
rice: { income: 4321, paid: 1234 },
},
nextCursor: null,
items: [
{
@@ -471,7 +488,33 @@ const install = async (
settlementsComplete: true,
})),
},
],
].flatMap((point) =>
dashboard
? Array.from({ length: 6 }, (_, index) => ({
...point,
month: index + 1,
periodMonths: 1,
from: { year: 190, month: index + 1 },
to: { year: 190, month: index + 1 },
stockAsOf: { year: 190, month: index + 1 },
stock:
index === 2
? null
: {
...point.stock,
gold: 600 + index * 150,
rice: 1200 - index * 90,
populations: {
human: { ...population, crew: 5000 + index * 1000 },
npc: population,
troopNpc: population,
},
},
flows: { ...point.flows, incomeGold: index === 0 ? 2100 : 0 },
complete: index !== 2,
}))
: [point]
),
});
case 'playAudit.nationSnapshot':
return result({
@@ -952,7 +995,9 @@ test('policy filter drafts do not read until applied, including default dates',
await page.goto(gamePath('/play-audit?tab=policies&nation=2'));
await expect(page.getByRole('button', { name: '버전 2', exact: true })).toBeVisible();
await page.getByRole('link', { name: '국방 설정', exact: true }).click();
await expect.poll(() => requests.filter(({ operation }) => operation === 'playAudit.policyHistory').at(-1)?.input.area).toBe('DEFENCE');
await expect
.poll(() => requests.filter(({ operation }) => operation === 'playAudit.policyHistory').at(-1)?.input.area)
.toBe('DEFENCE');
const count = requests.filter(({ operation }) => operation === 'playAudit.policyHistory').length;
await page.getByLabel('시작 월', { exact: true }).fill('3');
await page.getByRole('button', { name: '조회', exact: true }).focus();
@@ -1337,7 +1382,10 @@ for (const width of [1280, 390]) {
await menu.getByRole('link', { name: '국방 설정', exact: true }).click();
await expect(page).toHaveURL(/policyArea=DEFENCE/);
await page.goBack();
await expect(menu.getByRole('link', { name: 'NPC 국가 정책', exact: true })).toHaveAttribute('aria-current', 'page');
await expect(menu.getByRole('link', { name: 'NPC 국가 정책', exact: true })).toHaveAttribute(
'aria-current',
'page'
);
await page.goBack();
await expect(menu.getByRole('link', { name: 'NPC', exact: true })).toHaveAttribute('aria-current', 'page');
await menu.getByRole('link', { name: '도시', exact: true }).hover();
@@ -1357,3 +1405,53 @@ for (const width of [1280, 390]) {
await page.screenshot({ path: `/tmp/play-audit-menu/${width}.png`, fullPage: true });
});
}
for (const width of [1440, 390]) {
test(`audit dashboard charts, nation navigation and inspector at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 960 });
const requests = await install(page, false, false, undefined, true);
await page.goto(gamePath('/play-audit'));
await page.getByRole('button', { name: '촉 #2', exact: true }).click();
await expect(page).toHaveURL(/nation=2/);
await expect(page.getByRole('region', { name: '당월 정산', exact: true })).toContainText('4,321');
const charts = page.locator('.audit-chart canvas');
await expect(charts).toHaveCount(4);
const sizes = await charts.evaluateAll((nodes) =>
nodes.map((node) => ({
width: node.getBoundingClientRect().width,
height: node.getBoundingClientRect().height,
}))
);
for (const size of sizes) {
expect(size.width).toBeGreaterThan(width === 390 ? 260 : 600);
expect(size.height).toBeGreaterThanOrEqual(260);
}
await page.getByLabel('지표', { exact: true }).selectOption('crew');
await expect(page.getByRole('cell', { name: '15,000', exact: true })).toBeVisible();
const query = requests.findLast((request) => request.operation === 'playAudit.nationSeries')?.input;
expect(query).toMatchObject({ nationId: 2, resolution: 'month' });
await capture(page, `dashboard-${width}`);
await page.getByRole('button', { name: '최근 6개월', exact: true }).click();
await expect(page).toHaveURL(/fromMonth=2/);
await page
.getByRole('navigation', { name: '플레이 감사 메뉴' })
.getByRole('link', { name: '장수', exact: true })
.click();
await page.getByRole('button', { name: '감사장수 (#1)', exact: true }).click();
const detail = page.getByRole('complementary', { name: '선택한 대상 상세' });
await expect(detail).toContainText('병력 5,000');
await expect(detail).toBeFocused();
if (width >= 1200) {
const left = await page.getByRole('region', { name: '감사 분석', exact: true }).boundingBox();
const right = await detail.boundingBox();
expect(right!.x).toBeGreaterThanOrEqual(left!.x + left!.width);
}
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
await capture(page, `inspector-${width}`);
await page.reload();
await expect(detail).toContainText('병력 5,000');
await expect(detail).toBeFocused();
if (width < 1200) await expect(detail).toBeInViewport();
});
}
+1
View File
@@ -46,6 +46,7 @@
"@trpc/client": "^11.8.1",
"@trpc/server": "^11.8.1",
"@vueuse/core": "^14.1.0",
"chart.js": "4.5.1",
"date-fns": "^4.1.0",
"es-toolkit": "^1.43.0",
"mitt": "^3.0.1",
@@ -0,0 +1,94 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import {
Chart,
CategoryScale,
LinearScale,
LineController,
LineElement,
PointElement,
Tooltip,
Legend,
} from 'chart.js';
Chart.register(CategoryScale, LinearScale, LineController, LineElement, PointElement, Tooltip, Legend);
const props = defineProps<{
title: string;
labels: string[];
lines: { label: string; values: (number | null)[]; color: string }[];
}>();
const canvas = ref<HTMLCanvasElement | null>(null);
let chart: Chart<'line'> | undefined;
const render = () => {
if (!canvas.value) return;
chart?.destroy();
chart = new Chart(canvas.value, {
type: 'line',
data: {
labels: props.labels,
datasets: props.lines.map((line) => ({
label: line.label,
data: line.values,
borderColor: line.color,
backgroundColor: line.color,
borderWidth: 2,
pointRadius: 3,
pointHitRadius: 12,
spanGaps: false,
})),
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
interaction: { mode: 'index', intersect: false },
plugins: { legend: { labels: { color: '#e5eee9' } } },
scales: {
x: { ticks: { color: '#c3d0c8', maxTicksLimit: 12 }, grid: { color: '#34473e' } },
y: { ticks: { color: '#c3d0c8' }, grid: { color: '#34473e' } },
},
},
});
};
onMounted(render);
watch(() => [props.labels, props.lines], render, { deep: true });
onBeforeUnmount(() => chart?.destroy());
</script>
<template>
<section class="audit-chart" :aria-label="title">
<h3>{{ title }}</h3>
<div class="chart-canvas">
<canvas
ref="canvas"
role="img"
:aria-label="`${title}. 아래 수치 표에서 정확한 값을 확인할 수 있습니다.`"
/>
</div>
<p v-if="!lines.some((line) => line.values.some((value) => value !== null))">이 기간에 수집된 값이 없습니다.</p>
</section>
</template>
<style scoped>
.audit-chart {
min-width: 0;
padding: 12px;
background: #14231c;
border: 1px solid #536b60;
border-radius: 6px;
}
h3 {
margin: 0 0 8px;
font-size: var(--sammo-font-size-emphasis);
}
.chart-canvas {
position: relative;
height: 300px;
min-width: 0;
}
@media (max-width: 600px) {
.chart-canvas {
height: 260px;
}
}
</style>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import AuditLineChart from './AuditLineChart.vue';
import type { trpc } from '../../utils/trpc';
type Series = Awaited<ReturnType<typeof trpc.playAudit.nationSeries.query>>;
@@ -10,6 +11,7 @@ const metric = ref('gold');
const metrics = [
['gold', '국고 금'],
['rice', '국고 쌀'],
['crew', '총 병사수'],
['tech', '기술력'],
['appliedRate', '적용 세율'],
['incomeGold', '실제 금 수입'],
@@ -31,6 +33,8 @@ const label = computed(() => metrics.find(([key]) => key === metric.value)?.[1]
const value = (point: Point): number | null => {
const stock = point.stock;
switch (metric.value) {
case 'crew':
return totalCrew(point);
case 'totalGold':
return stock?.populations[population.value].gold ?? null;
case 'totalRice':
@@ -59,6 +63,28 @@ const value = (point: Point): number | null => {
return null;
}
};
const totalCrew = (point: Point): number | null => {
if (!point.stock) return null;
const groups = Object.values(point.stock.populations);
return groups.some((group) => group.crew == null) ? null : groups.reduce((sum, group) => sum + group.crew!, 0);
};
const chartLabels = computed(() => props.data.items.map((point) => `${point.year}년 ${point.month}월`));
const resourceLines = computed(() => [
{ label: '국고 금', color: '#f5cc64', values: props.data.items.map((point) => point.stock?.gold ?? null) },
{ label: '국고 쌀', color: '#91d9a0', values: props.data.items.map((point) => point.stock?.rice ?? null) },
]);
const crewLines = computed(() => [{ label: '총 병사수', color: '#83cafa', values: props.data.items.map(totalCrew) }]);
const dexLines = computed(() =>
(['dex1', 'dex2', 'dex3', 'dex4', 'dex5'] as const).map((key, index) => ({
label: ['보병', '궁병', '기병', '귀병', '차병'][index]!,
color: ['#f5cc64', '#91d9a0', '#83cafa', '#e2a3f3', '#ff9c88'][index]!,
values: props.data.items.map((point) => point.stock?.populations[population.value].averageDex[key] ?? null),
}))
);
const incomeLines = computed(() => [
{ label: '실제 금 수입', color: '#f5cc64', values: props.data.items.map((point) => point.flows.incomeGold) },
{ label: '실제 쌀 수입', color: '#91d9a0', values: props.data.items.map((point) => point.flows.incomeRice) },
]);
const format = (number: number | null) =>
number === null ? '자료 없음' : number.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
const maximum = computed(() => Math.max(1, ...props.data.items.map((row) => Math.abs(value(row) ?? 0))));
@@ -67,6 +93,42 @@ const date = (point: { year: number; month: number } | null) =>
</script>
<template>
<section v-if="data.currentSettlement" class="settlement-now" aria-label="당월 정산">
<h3>{{ data.currentSettlement.year }}년 {{ data.currentSettlement.month }}월 정산</h3>
<p>금은 1월, 쌀은 7월에 정산됩니다. 저장된 실제 수입·지급액은 월말 전에 확인할 수 있습니다.</p>
<div class="settlement-values">
<p v-for="resource in ['gold', 'rice'] as const" :key="resource">
<strong>{{ resource === 'gold' ? '금' : '쌀' }}</strong>
<template v-if="data.currentSettlement[resource]">
수입 {{ format(data.currentSettlement[resource]!.income) }} · 지급
{{ format(data.currentSettlement[resource]!.paid) }}
<span>{{ data.currentSettlement.complete ? '정산 관측됨' : '부분 관측' }}</span>
</template>
<span v-else>당월 관측된 정산 없음</span>
</p>
</div>
</section>
<div class="chart-grid">
<AuditLineChart title="국가 금·쌀 변화" :labels="chartLabels" :lines="resourceLines" />
<AuditLineChart title="총 병사수 변화" :labels="chartLabels" :lines="crewLines" />
<AuditLineChart title="실제 금·쌀 수입" :labels="chartLabels" :lines="incomeLines" />
</div>
<div class="population-buttons" aria-label="숙련도 장수 집단">
<button
v-for="[key, text] in [
['human', '유저'],
['npc', 'NPC'],
['troopNpc', '부대장 NPC'],
] as const"
:key="key"
class="legacy-button"
:aria-pressed="population === key"
@click="population = key"
>
{{ text }}
</button>
</div>
<AuditLineChart title="병종별 평균 숙련도 변화" :labels="chartLabels" :lines="dexLines" />
<div class="series-controls">
<label
>지표
@@ -142,6 +204,41 @@ const date = (point: { year: number; month: number } | null) =>
</template>
<style scoped>
.chart-grid {
display: grid;
gap: 16px;
margin: 16px 0;
}
.population-buttons {
display: flex;
gap: 8px;
margin: 16px 0 8px;
flex-wrap: wrap;
}
.population-buttons [aria-pressed='true'] {
background: #254e3c;
border-color: #b6d6c2;
}
.settlement-now {
padding: 12px;
border: 1px solid #8aa986;
background: #203428;
}
.settlement-now h3 {
margin-top: 0;
}
.settlement-values {
display: flex;
flex-wrap: wrap;
gap: 8px 24px;
}
.settlement-values span {
margin-left: 8px;
}
.series-controls {
margin-top: 20px;
}
.series-controls {
display: flex;
flex-wrap: wrap;
+483 -303
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter, type LocationQueryRaw } from 'vue-router';
import PanelCard from '../components/ui/PanelCard.vue';
import AuditNationSeries from '../components/playAudit/AuditNationSeries.vue';
@@ -70,7 +70,23 @@ const year = ref(0);
const month = ref(1);
const fromYear = ref(0);
const fromMonth = ref(1);
const resolution = ref<'month' | 'halfYear'>('halfYear');
const resolution = ref<'month' | 'halfYear'>('month');
const inspector = ref<HTMLElement | null>(null);
const nationSearch = ref('');
const visibleNations = computed(
() => nations.value?.items.filter((item) => item.name.includes(nationSearch.value)) ?? []
);
const chooseNation = (id: string) =>
router.push({
query: {
...route.query,
nation: id || undefined,
general: undefined,
cityRecord: undefined,
decision: undefined,
},
});
let generation = 0;
const numeric = (value: unknown, fallback: number) =>
typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : fallback;
@@ -96,6 +112,13 @@ const selectedCity = computed(() =>
? Number(route.query.cityRecord)
: null
);
const focusInspector = async () => {
if (!authorized.value || !coverage.value || (selectedGeneral.value === null && selectedCity.value === null)) return;
await nextTick();
inspector.value?.focus({ preventScroll: true });
if (window.innerWidth < 1200) inspector.value?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
watch([selectedGeneral, selectedCity], focusInspector);
const selectGeneral = (id: number) =>
router.push({ query: { ...route.query, general: String(id), cityRecord: undefined, decision: undefined } });
const closeGeneral = () => router.push({ query: { ...route.query, general: undefined, decision: undefined } });
@@ -216,11 +239,11 @@ const readQuery = () => {
month.value = numeric(route.query.month, coverage.value?.month ?? 1);
const defaultStart = Math.max(
(coverage.value?.startYear ?? year.value) * 12 + (coverage.value?.startMonth ?? 1) - 1,
year.value * 12 + month.value - 6
year.value * 12 + month.value - (tab.value === 'nations' ? 12 : 6)
);
fromYear.value = numeric(route.query.fromYear, Math.floor(defaultStart / 12));
fromMonth.value = numeric(route.query.fromMonth, (defaultStart % 12) + 1);
resolution.value = route.query.resolution === 'month' ? 'month' : 'halfYear';
resolution.value = route.query.resolution === 'halfYear' ? 'halfYear' : 'month';
};
const message = (cause: unknown) => (cause instanceof Error ? cause.message : '자료를 조회하지 못했습니다.');
const load = async (append = false) => {
@@ -329,12 +352,26 @@ const apply = async () => {
if (before === route.fullPath) await refresh();
}
};
const recentPeriod = async (months: number) => {
if (!coverage.value) return;
year.value = coverage.value.year;
month.value = coverage.value.month;
const first = Math.max(
coverage.value.startYear * 12 + coverage.value.startMonth - 1,
year.value * 12 + month.value - months
);
fromYear.value = Math.floor(first / 12);
fromMonth.value = (first % 12) + 1;
moment.value = 'current';
await apply();
};
const refresh = async () => {
const dataRequest = load();
const request = generation;
const results = await Promise.allSettled([dataRequest, loadNations()]);
if (request !== generation) return;
for (const response of results) if (response.status === 'rejected') error.value = message(response.reason);
await focusInspector();
};
const showCityGenerals = async (id: number) => {
readQuery();
@@ -416,6 +453,10 @@ onMounted(async () => {
상태·정책 수집 시작: {{ coverage.collectionStart.year }}년 {{ coverage.collectionStart.month }}월 ·
{{ coverage.collectionStart.observedAt }}
</p>
</template>
</PanelCard>
<div v-if="authorized && coverage" class="audit-workspace">
<aside class="audit-sidebar" aria-label="감사 탐색">
<nav class="audit-navigation" aria-label="플레이 감사 메뉴">
<div class="menu-row" aria-label="감사 분류">
<RouterLink
@@ -440,336 +481,475 @@ onMounted(async () => {
>
</div>
</nav>
<form class="filters" @submit.prevent="apply">
<label
>국가<select class="legacy-sort-select" v-model="nationId">
<option value="">
{{
tab === 'nations' || tab === 'policies' || tab === 'diplomacy'
? '국가 선택'
: '모든 국가'
}}
</option>
<option v-if="tab !== 'diplomacy'" value="0">무소속</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="
nationId &&
nationId !== '0' &&
!nations?.items.some((item) => String(item.id) === nationId)
"
:value="nationId"
>
국가 #{{ nationId }}
</option>
</select></label
>
<section class="nation-browser" aria-label="국가 탐색">
<h2>국가</h2>
<input v-model="nationSearch" aria-label="국가 이름 검색" placeholder="국가 이름 검색" />
<button class="legacy-button" :aria-pressed="!route.query.nation" @click="chooseNation('')">
전체 국가
</button>
<button
v-for="nation in visibleNations"
:key="nation.id"
class="legacy-button nation-choice"
:aria-pressed="String(route.query.nation) === String(nation.id)"
@click="chooseNation(String(nation.id))"
>
{{ nation.name }} <small>#{{ nation.id }}</small>
</button>
<p v-if="!visibleNations.length">조건에 맞는 국가가 없습니다.</p>
<button
v-if="nations?.nextCursor != null"
class="legacy-button"
v-if="nations?.nextCursor !== null && nations?.nextCursor !== undefined"
type="button"
:disabled="loading"
@click="moreNations"
>
국가 더 불러오기
</button>
<label
>국가 목록·상태 기준<select class="legacy-sort-select" v-model="moment">
<option value="current">현재</option>
<option value="month">월말</option>
<option value="final">최종 표본</option>
<option value="initial">수집 시작 기준</option>
</select></label
>
<label
>{{ tab === 'nations' || tab === 'policies' || tab === 'diplomacy' ? '종료 연도' : '표본 연도'
}}<input
v-model.number="year"
type="number"
:min="coverage.startYear"
:max="coverage.year"
required
/></label>
<label
>월<input
v-model.number="month"
type="number"
:min="year === coverage.startYear ? coverage.startMonth : 1"
:max="year === coverage.year ? coverage.month : 12"
required
/></label>
<template
v-if="
(tab === 'nations' && moment !== 'final' && moment !== 'initial') ||
tab === 'policies' ||
tab === 'diplomacy'
"
>
</section>
</aside>
<section class="audit-content" aria-label="감사 분석">
<PanelCard title="조회 조건">
<div v-if="tab === 'nations'" class="period-shortcuts" aria-label="빠른 조회 기간">
<button
v-for="months in [6, 12, 24]"
:key="months"
class="legacy-button"
:disabled="loading"
@click="recentPeriod(months)"
>
최근 {{ months }}개월
</button>
</div>
<form class="filters" @submit.prevent="apply">
<p class="selected-nation">{{ nationId ? nationName(Number(nationId)) : '전체 국가' }}</p>
<label
>시작 연도<input
v-model.number="fromYear"
>국가 목록·상태 기준<select class="legacy-sort-select" v-model="moment">
<option value="current">현재</option>
<option value="month">월말</option>
<option value="final">최종 표본</option>
<option value="initial">수집 시작 기준</option>
</select></label
>
<label
>{{
tab === 'nations' || tab === 'policies' || tab === 'diplomacy'
? '종료 연도'
: '표본 연도'
}}<input
v-model.number="year"
type="number"
:min="coverage.startYear"
:max="coverage.year"
required
/></label>
<label
>시작 월<input
v-model.number="fromMonth"
>월<input
v-model.number="month"
type="number"
:min="fromYear === coverage.startYear ? coverage.startMonth : 1"
:max="fromYear === coverage.year ? coverage.month : 12"
:min="year === coverage.startYear ? coverage.startMonth : 1"
:max="year === coverage.year ? coverage.month : 12"
required
/></label>
<label v-if="tab === 'nations'"
>간격<select class="legacy-sort-select" v-model="resolution">
<option value="halfYear">반기 (1~6월 / 7~12월)</option>
<option value="month">매월</option>
<template
v-if="
(tab === 'nations' && moment !== 'final' && moment !== 'initial') ||
tab === 'policies' ||
tab === 'diplomacy'
"
>
<label
>시작 연도<input
v-model.number="fromYear"
type="number"
:min="coverage.startYear"
:max="coverage.year"
required
/></label>
<label
>시작 월<input
v-model.number="fromMonth"
type="number"
:min="fromYear === coverage.startYear ? coverage.startMonth : 1"
:max="fromYear === coverage.year ? coverage.month : 12"
required
/></label>
<label v-if="tab === 'nations'"
>간격<select class="legacy-sort-select" v-model="resolution">
<option value="halfYear">반기 (1~6월 / 7~12월)</option>
<option value="month">매월</option>
</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
>
</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)"
<template v-if="tab === 'generals'">
<label
>장수 이름<input v-model="generalName" maxlength="64" placeholder="이름 부분 검색"
/></label>
<label
>장수 번호 정렬<select
class="legacy-sort-select"
v-model="generalOrder"
aria-label="장수 번호 정렬"
>
<option value="asc">오름차순</option>
<option value="desc">내림차순</option>
</select></label
>
{{ nation.name }} (#{{ nation.id }})
</option>
<option
v-if="
otherNationId && !nations?.items.some((item) => String(item.id) === otherNationId)
"
:value="otherNationId"
>
국가 #{{ otherNationId }}
</option>
</select></label
>
<template v-if="tab === 'generals'">
<label
>장수 이름<input v-model="generalName" maxlength="64" placeholder="이름 부분 검색"
/></label>
<label
>장수 번호 정렬<select
class="legacy-sort-select"
v-model="generalOrder"
aria-label="장수 번호 정렬"
>
<option value="asc">오름차순</option>
<option value="desc">내림차순</option>
</select></label
>
<label>도시 번호<input v-model="cityId" type="number" min="0" placeholder="모든 도시" /></label>
</template>
<button class="legacy-button" type="submit" :disabled="loading">조회</button>
</form>
</template>
</PanelCard>
<PanelCard
v-if="authorized && coverage"
:title="
tab === 'nations'
? '국가 시계열'
: tab === 'generals'
? '전체 장수'
: tab === 'policies'
? '정책 변경 이력'
: tab === 'diplomacy'
? '외교 이력'
: '도시 상태'
"
>
<p v-if="result">조회 시각 {{ result.asOf }} · tick {{ result.tick ?? '없음' }}</p>
<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"
/>
<AuditNationSeries v-if="series && tab === 'nations'" :data="series" />
<AuditNationSnapshot v-if="nationSnapshot && tab === 'nations'" :data="nationSnapshot" />
<template v-if="generals && tab === 'generals'">
<p v-if="!generals.collected">선택한 시점의 표본이 없습니다.</p>
<p v-else-if="!generals.items.length">조건에 맞는 장수가 없습니다.</p>
<div v-else class="table-scroll" tabindex="0" aria-label="장수 목록">
<table>
<thead>
<tr>
<th>장수</th>
<th>국가·위치</th>
<th>금 / 쌀</th>
<th>병력 / 훈련 / 사기</th>
<th>능력·숙련</th>
</tr>
</thead>
<tbody>
<tr v-for="general in generals.items" :key="general.id">
<th scope="row">
<button class="legacy-button" @click="selectGeneral(general.id)">
{{ general.name }} (#{{ general.id }})</button
><br />{{
general.npcState < 2 ? '유저' : general.npcState === 5 ? '부대장 NPC' : 'NPC'
}}
</th>
<td>
{{ nationName(general.nationId) }}<br />도시 #{{ general.cityId }} · 부대 #{{
general.troopId
}}
</td>
<td>{{ format(general.gold) }} / {{ format(general.rice) }}</td>
<td>
{{ format(general.crew) }} / {{ format(general.train) }} / {{ format(general.atmos)
}}<br />병종 #{{ general.crewTypeId }}
</td>
<td>
<details>
<summary>상세 보기</summary>
<p>
통솔 {{ general.stats.leadership }} · 무력 {{ general.stats.strength }} ·
지력 {{ general.stats.intelligence }}
</p>
<p>
경험 {{ format(general.experience) }} · 공헌
{{ format(general.dedication) }} · 관직 {{ general.officerLevel }}
</p>
<p>나이 {{ general.age }} · 부상 {{ general.injury }}</p>
<p>
숙련 (보 / 궁 / 기 / 귀 / 차):
{{ Object.values(general.dex).map(format).join(' / ') }}
</p>
<p>
성격 {{ general.role.personality ?? '없음' }} · 내정 특기
{{ general.role.specialDomestic ?? '없음' }} · 전투 특기
{{ general.role.specialWar ?? '없음' }}
</p>
<p>
장비 (말 / 무기 / 책 / 도구):
{{
Object.values(general.role.items)
.map((item) => item ?? '없음')
.join(' / ')
}}
</p>
</details>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<template v-if="cities && tab === 'cities'">
<p v-if="!cities.collected">선택한 시점의 표본이 없습니다.</p>
<p v-else-if="!cities.items.length">조건에 맞는 도시가 없습니다.</p>
<div v-else class="table-scroll" tabindex="0" aria-label="도시 목록">
<table>
<thead>
<tr>
<th>도시</th>
<th>국가</th>
<th>인구</th>
<th>내정 (현재 / 최대)</th>
<th>주둔 장수</th>
</tr>
</thead>
<tbody>
<tr v-for="city in cities.items" :key="city.id">
<th scope="row">
<button class="legacy-button" @click="selectCity(city.id)">
{{ city.name }} (#{{ city.id }})
</button>
</th>
<td>{{ nationName(city.nationId) }}</td>
<td>{{ format(city.population) }} / {{ format(city.populationMax) }}</td>
<td>
<details>
<summary>내정 보기</summary>
<p>
농업 {{ city.agriculture }} / {{ city.agricultureMax }} · 상업
{{ city.commerce }} / {{ city.commerceMax }}
</p>
<p>
치안 {{ city.security }} / {{ city.securityMax }} · 성벽 {{ city.wall }} /
{{ city.wallMax }} · 수비 {{ city.defence }} / {{ city.defenceMax }}
</p>
<p>
민심 {{ city.trust }} · 보급 {{ city.supplyState }} · 전방
{{ city.frontState }} · 상태 {{ city.state }} · 규모 {{ city.level }}
</p>
</details>
</td>
<td>
<button
class="legacy-button"
type="button"
:disabled="loading"
@click="showCityGenerals(city.id)"
<label
>도시 번호<input v-model="cityId" type="number" min="0" placeholder="모든 도시"
/></label>
</template>
<button class="legacy-button" type="submit" :disabled="loading">조회</button>
</form>
</PanelCard>
<PanelCard
v-if="authorized && coverage"
:title="
tab === 'nations'
? '국가 시계열'
: tab === 'generals'
? '전체 장수'
: tab === 'policies'
? '정책 변경 이력'
: tab === 'diplomacy'
? '외교 이력'
: '도시 상태'
"
>
<p v-if="result">조회 시각 {{ result.asOf }} · tick {{ result.tick ?? '없음' }}</p>
<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"
/>
<AuditNationSeries v-if="series && tab === 'nations'" :data="series" />
<AuditNationSnapshot v-if="nationSnapshot && tab === 'nations'" :data="nationSnapshot" />
<template v-if="generals && tab === 'generals'">
<p v-if="!generals.collected">선택한 시점의 표본이 없습니다.</p>
<p v-else-if="!generals.items.length">조건에 맞는 장수가 없습니다.</p>
<div v-else class="table-scroll" tabindex="0" aria-label="장수 목록">
<table>
<thead>
<tr>
<th>장수</th>
<th>국가·위치</th>
<th>금 / 쌀</th>
<th>병력 / 훈련 / 사기</th>
<th>능력·숙련</th>
</tr>
</thead>
<tbody>
<tr
v-for="general in generals.items"
:key="general.id"
:class="{ 'selected-row': selectedGeneral === general.id }"
>
모든 국가의 주둔 장수
</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<button
class="legacy-button"
v-if="result?.nextCursor != null"
type="button"
:disabled="loading"
@click="load(true)"
>
다음 50개 불러오기
</button>
<button class="legacy-button" v-if="error && authorized" type="button" :disabled="loading" @click="refresh">
다시 조회
</button>
</PanelCard>
<AuditGeneralDetail
v-if="authorized && selectedGeneral !== null"
:general-id="selectedGeneral"
:at="selectedAt"
@close="closeGeneral"
/>
<AuditCityDetail
v-if="authorized && selectedCity !== null"
:city-id="selectedCity"
:at="selectedAt"
@close="closeCity"
@generals="showCityGenerals"
/>
<th scope="row">
<button
class="legacy-button"
:aria-pressed="selectedGeneral === general.id"
@click="selectGeneral(general.id)"
>
{{ general.name }} (#{{ general.id }})</button
><br />{{
general.npcState < 2
? '유저'
: general.npcState === 5
? '부대장 NPC'
: 'NPC'
}}
</th>
<td>
{{ nationName(general.nationId) }}<br />도시 #{{ general.cityId }} · 부대
#{{ general.troopId }}
</td>
<td>{{ format(general.gold) }} / {{ format(general.rice) }}</td>
<td>
{{ format(general.crew) }} / {{ format(general.train) }} /
{{ format(general.atmos) }}<br />병종 #{{ general.crewTypeId }}
</td>
<td>
<details>
<summary>상세 보기</summary>
<p>
통솔 {{ general.stats.leadership }} · 무력
{{ general.stats.strength }} · 지력 {{ general.stats.intelligence }}
</p>
<p>
경험 {{ format(general.experience) }} · 공헌
{{ format(general.dedication) }} · 관직 {{ general.officerLevel }}
</p>
<p>나이 {{ general.age }} · 부상 {{ general.injury }}</p>
<p>
숙련 (보 / 궁 / 기 / 귀 / 차):
{{ Object.values(general.dex).map(format).join(' / ') }}
</p>
<p>
성격 {{ general.role.personality ?? '없음' }} · 내정 특기
{{ general.role.specialDomestic ?? '없음' }} · 전투 특기
{{ general.role.specialWar ?? '없음' }}
</p>
<p>
장비 (말 / 무기 / 책 / 도구):
{{
Object.values(general.role.items)
.map((item) => item ?? '없음')
.join(' / ')
}}
</p>
</details>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<template v-if="cities && tab === 'cities'">
<p v-if="!cities.collected">선택한 시점의 표본이 없습니다.</p>
<p v-else-if="!cities.items.length">조건에 맞는 도시가 없습니다.</p>
<div v-else class="table-scroll" tabindex="0" aria-label="도시 목록">
<table>
<thead>
<tr>
<th>도시</th>
<th>국가</th>
<th>인구</th>
<th>내정 (현재 / 최대)</th>
<th>주둔 장수</th>
</tr>
</thead>
<tbody>
<tr v-for="city in cities.items" :key="city.id">
<th scope="row">
<button class="legacy-button" @click="selectCity(city.id)">
{{ city.name }} (#{{ city.id }})
</button>
</th>
<td>{{ nationName(city.nationId) }}</td>
<td>{{ format(city.population) }} / {{ format(city.populationMax) }}</td>
<td>
<details>
<summary>내정 보기</summary>
<p>
농업 {{ city.agriculture }} / {{ city.agricultureMax }} · 상업
{{ city.commerce }} / {{ city.commerceMax }}
</p>
<p>
치안 {{ city.security }} / {{ city.securityMax }} · 성벽
{{ city.wall }} / {{ city.wallMax }} · 수비 {{ city.defence }} /
{{ city.defenceMax }}
</p>
<p>
민심 {{ city.trust }} · 보급 {{ city.supplyState }} · 전방
{{ city.frontState }} · 상태 {{ city.state }} · 규모
{{ city.level }}
</p>
</details>
</td>
<td>
<button
class="legacy-button"
type="button"
:disabled="loading"
@click="showCityGenerals(city.id)"
>
모든 국가의 주둔 장수
</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<button
class="legacy-button"
v-if="result?.nextCursor != null"
type="button"
:disabled="loading"
@click="load(true)"
>
다음 50개 불러오기
</button>
<button
class="legacy-button"
v-if="error && authorized"
type="button"
:disabled="loading"
@click="refresh"
>
다시 조회
</button>
</PanelCard>
</section>
<aside ref="inspector" class="audit-inspector" tabindex="-1" aria-label="선택한 대상 상세">
<PanelCard v-if="selectedGeneral === null && selectedCity === null" title="대상 상세">
<p>장수나 도시 이름을 누르면 이곳에 상세 정보가 표시됩니다.</p>
<p v-if="nationId">선택 국가: {{ nationName(Number(nationId)) }}</p>
<RouterLink class="legacy-button" :to="menuTarget('generals')">선택 국가 장수 보기</RouterLink>
</PanelCard>
<AuditGeneralDetail
v-if="authorized && selectedGeneral !== null"
:general-id="selectedGeneral"
:at="selectedAt"
@close="closeGeneral"
/>
<AuditCityDetail
v-if="authorized && selectedCity !== null"
:city-id="selectedCity"
:at="selectedAt"
@close="closeCity"
@generals="showCityGenerals"
/>
</aside>
</div>
</main>
</template>
<style scoped>
.audit-workspace {
display: grid;
grid-template-columns: 190px minmax(0, 1fr) 340px;
gap: 16px;
align-items: start;
}
.audit-sidebar,
.audit-content,
.audit-inspector {
min-width: 0;
}
.audit-content {
display: grid;
gap: 12px;
}
.audit-sidebar,
.audit-inspector {
position: sticky;
top: 12px;
max-height: calc(100vh - 24px);
overflow: auto;
}
.audit-sidebar {
padding: 10px;
border: 1px solid #536b60;
border-radius: 6px;
background: #14231c;
}
.audit-inspector:focus-visible {
outline: 2px solid #d1e6a1;
outline-offset: 2px;
}
.nation-browser {
display: grid;
gap: 6px;
}
.nation-browser h2 {
margin: 8px 0;
font-size: var(--sammo-font-size-emphasis);
}
.nation-browser input {
min-width: 0;
width: 100%;
box-sizing: border-box;
padding: 6px;
background: #000;
color: #fff;
border: 1px solid #91a39a;
}
.nation-choice {
text-align: left;
overflow-wrap: anywhere;
}
.nation-browser [aria-pressed='true'],
.selected-row {
background: #254e3c;
border-color: #b6d6c2;
}
.period-shortcuts {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.selected-nation {
flex-basis: 100%;
margin: 0;
font-weight: bold;
}
@media (min-width: 1200px) {
.audit-sidebar .menu-row {
flex-direction: column;
}
}
@media (max-width: 1199px) {
.audit-workspace {
grid-template-columns: 180px minmax(0, 1fr);
}
.audit-inspector {
grid-column: 2;
position: static;
max-height: none;
}
}
@media (max-width: 700px) {
.audit-workspace {
grid-template-columns: minmax(0, 1fr);
}
.audit-sidebar,
.audit-inspector {
position: static;
max-height: none;
grid-column: 1;
}
.nation-browser {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.nation-browser h2,
.nation-browser input {
grid-column: 1 / -1;
}
}
.audit-page {
max-width: 1200px;
max-width: 1920px;
margin: 0 auto;
padding: 8px;
display: grid;
gap: 12px;
}
.audit-page > :deep(.panel-card) {
.audit-page :deep(.panel-card) {
min-width: 0;
}
.audit-navigation {