플레이 감사를 3단 대시보드와 국가 추이 그래프로 개선한다
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user