플레이 감사에 장수 수치 정렬과 시점별 도시 지도를 추가한다

This commit is contained in:
2026-09-26 04:52:18 +00:00
parent e35679335c
commit 51d0317a69
7 changed files with 514 additions and 8 deletions
@@ -0,0 +1,93 @@
import { TRPCError } from '@trpc/server';
import { asRecord } from '@sammo-ts/common';
import { z } from 'zod';
import { loadMapLayout, loadMapLayoutByName } from '../../maps/mapLayout.js';
import { auditProcedure, readAudit, readAuditWorld, findAuditMonth, zAuditMonth } from './shared.js';
import { citySelect, projectCurrentCity, zAuditCityData } from './projection.js';
/** 도시 표본 전체를 한 지도 단위로 읽는다. 장수/로그/trace는 읽지 않는다. */
export const cityMap = auditProcedure.input(z.object({ at: zAuditMonth.optional() }).strict()).query(({ ctx, input }) =>
readAudit(ctx, async (tx) => {
const world = await readAuditWorld(tx);
const sample = input.at ? await findAuditMonth(tx, world, input.at) : null;
if (input.at && !sample)
return {
...world,
collected: false,
sample,
map: null,
layout: null,
unmappedCityIds: [],
nextCursor: null,
};
const state = await tx.worldState.findFirst({ orderBy: { id: 'asc' }, select: { config: true } });
const environment = asRecord(asRecord(state?.config).environment);
const layout =
typeof environment.mapName === 'string' && environment.mapName.trim()
? await loadMapLayoutByName(environment.mapName)
: await loadMapLayout(ctx.profile.scenario);
const cities = sample
? (
await tx.playAuditCity.findMany({
where: { sampleId: sample.id },
select: { data: true },
take: 1025,
})
).map((row) => zAuditCityData.parse(row.data))
: (await tx.city.findMany({ select: citySelect, take: 1025 })).map(projectCurrentCity);
if (cities.length > 1024)
throw new TRPCError({
code: 'BAD_REQUEST',
message: '지도 조회 범위를 초과했습니다. 도시 목록으로 조회해 주세요.',
});
const nationIds = [...new Set(cities.map((city) => city.nationId))];
const identity = z.object({ id: z.number(), name: z.string(), color: z.string() });
const nations = sample
? (
await tx.playAuditNation.findMany({
where: { sampleId: sample.id, nationId: { in: nationIds } },
select: { data: true },
})
).map((row) => ({ ...identity.parse(row.data), capitalCityId: 0 }))
: await tx.nation.findMany({
where: { id: { in: nationIds } },
select: { id: true, name: true, color: true, capitalCityId: true },
});
const byCity = new Map(cities.map((city) => [city.id, city]));
const byNation = new Map(nations.map((nation) => [nation.id, nation]));
const layoutIds = new Set(layout.cityList.map((city) => city.id));
// 없는 표본을 무주 도시로 그리지 않는다. 당시 이름과 소유만 사용한다.
const visibleLayout = {
...layout,
cityList: layout.cityList
.filter((city) => byCity.has(city.id))
.map((city) => ({ ...city, name: byCity.get(city.id)!.name })),
};
return {
...world,
collected: true,
sample,
layout: visibleLayout,
nextCursor: null,
unmappedCityIds: cities.filter((city) => !layoutIds.has(city.id)).map((city) => city.id),
map: {
year: input.at?.year ?? world.year,
month: input.at?.month ?? world.month,
startYear: world.startYear,
cityList: visibleLayout.cityList.map((layoutCity): [number, number, number, number, number, number] => {
const city = byCity.get(layoutCity.id)!;
return [city.id, city.level, city.state, city.nationId, layoutCity.region, city.supplyState];
}),
nationList: nationIds.map((id): [number, string, string, number] => {
const nation = byNation.get(id);
return [
id,
nation?.name ?? (id === 0 ? '무주' : `국가 #${id} (미수집)`),
nation?.color ?? '#888888',
nation?.capitalCityId ?? 0,
];
}),
},
};
})
);
@@ -0,0 +1,108 @@
import { TRPCError } from '@trpc/server';
import { GamePrisma } from '@sammo-ts/infra';
import { z } from 'zod';
import { generalSelect, projectCurrentGeneral, zAuditGeneralData } from './projection.js';
export const zGeneralSort = z.enum([
'id',
'gold',
'rice',
'crew',
'train',
'atmos',
'leadership',
'strength',
'intelligence',
'experience',
'dedication',
'dex1',
'dex2',
'dex3',
'dex4',
'dex5',
]);
export const zGeneralCursor = z.object({ id: z.number().int().nonnegative(), value: z.number().nullable() }).strict();
const columns: Record<z.infer<typeof zGeneralSort>, string> = {
id: 'id',
gold: 'gold',
rice: 'rice',
crew: 'crew',
train: 'train',
atmos: 'atmos',
leadership: 'leadership',
strength: 'strength',
intelligence: 'intel',
experience: 'experience',
dedication: 'dedication',
dex1: 'dex1',
dex2: 'dex2',
dex3: 'dex3',
dex4: 'dex4',
dex5: 'dex5',
};
export const readSortedGenerals = async (
tx: GamePrisma.TransactionClient,
input: {
sort: z.infer<typeof zGeneralSort>;
order: 'asc' | 'desc';
limit: number;
cursor?: number | z.infer<typeof zGeneralCursor>;
nationId?: number;
cityId?: number;
population?: 'human' | 'npc' | 'troopNpc';
name?: string;
},
sampleId?: string
) => {
if (typeof input.cursor === 'number')
throw new TRPCError({ code: 'BAD_REQUEST', message: '정렬 조건에 맞는 다음 페이지를 선택해 주세요.' });
// SQL 식별자/JSON 경로는 서버 allowlist에서만 조립한다. 사용자 문자열은 parameter다.
const id = GamePrisma.raw(sampleId ? 'general_id' : 'id');
const path = ['leadership', 'strength', 'intelligence'].includes(input.sort)
? `stats,${input.sort}`
: input.sort.startsWith('dex')
? `dex,${input.sort}`
: input.sort;
const metric = sampleId
? GamePrisma.raw(`(data #>> '{${path}}')::double precision`)
: input.sort.startsWith('dex')
? GamePrisma.raw(
`CASE WHEN jsonb_typeof(meta->'${input.sort}') = 'number' THEN (meta->>'${input.sort}')::double precision ELSE 0 END`
)
: GamePrisma.raw(columns[input.sort]);
const filters: GamePrisma.Sql[] = [];
if (sampleId) filters.push(GamePrisma.sql`sample_id = ${sampleId}`);
if (input.nationId !== undefined) filters.push(GamePrisma.sql`nation_id = ${input.nationId}`);
if (input.cityId !== undefined) filters.push(GamePrisma.sql`city_id = ${input.cityId}`);
if (input.population === 'human') filters.push(GamePrisma.sql`npc_state < 2`);
if (input.population === 'npc') filters.push(GamePrisma.sql`npc_state >= 2 AND npc_state <> 5`);
if (input.population === 'troopNpc') filters.push(GamePrisma.sql`npc_state = 5`);
if (input.name)
filters.push(
GamePrisma.sql`${GamePrisma.raw(sampleId ? "data->>'name'" : 'name')} LIKE ${`%${input.name.replace(/[\\%_]/g, '\\$&')}%`}`
);
if (input.cursor) {
const { value, id: cursorId } = input.cursor;
filters.push(
value === null
? GamePrisma.sql`(${metric} IS NULL AND ${id} > ${cursorId})`
: GamePrisma.sql`(${metric} ${GamePrisma.raw(input.order === 'asc' ? '>' : '<')} ${value} OR (${metric} = ${value} AND ${id} > ${cursorId}) OR ${metric} IS NULL)`
);
}
const rows = await tx.$queryRaw<{ id: number; value: number | null; data: unknown }[]>(GamePrisma.sql`
SELECT ${id} AS id, ${metric} AS value, ${sampleId ? GamePrisma.raw('data') : GamePrisma.sql`NULL`} AS data
FROM ${GamePrisma.raw(sampleId ? 'play_audit_general' : 'general')}
WHERE ${filters.length ? GamePrisma.join(filters, ' AND ') : GamePrisma.sql`TRUE`}
ORDER BY ${metric} ${GamePrisma.raw(input.order.toUpperCase())} NULLS LAST, ${id} ASC LIMIT ${input.limit + 1}
`);
const page = rows.slice(0, input.limit);
const current = sampleId
? []
: await tx.general.findMany({ where: { id: { in: page.map((row) => row.id) } }, select: generalSelect });
const byId = new Map(current.map((row) => [row.id, projectCurrentGeneral(row)]));
return {
items: page.map((row) => (sampleId ? zAuditGeneralData.parse(row.data) : byId.get(row.id)!)),
nextCursor:
rows.length > input.limit ? { id: page[page.length - 1]!.id, value: page[page.length - 1]!.value } : null,
};
};
@@ -1,3 +1,6 @@
import { cityMap } from './cityMap.js';
import { TRPCError } from '@trpc/server';
import { readSortedGenerals, zGeneralSort, zGeneralCursor } from './generalSort.js';
import { requestState } from './requests.js';
import { decisionHistory, decisionDetail } from './decisions.js';
import { diplomacyHistory, diplomacyEvent } from './diplomacy.js';
@@ -27,6 +30,7 @@ import {
} from './projection.js';
export const playAuditRouter = router({
cityMap,
requestState,
decisionHistory,
decisionDetail,
@@ -161,11 +165,20 @@ export const playAuditRouter = router({
population: z.enum(['human', 'npc', 'troopNpc']).optional(),
name: z.string().trim().max(64).optional(),
order: z.enum(['asc', 'desc']).default('asc'),
sort: zGeneralSort.default('id'),
cursor: z.union([z.number().int().nonnegative(), zGeneralCursor]).optional(),
})
)
.query(({ ctx, input }) =>
readAudit(ctx, async (tx) => {
const world = await readAuditWorld(tx);
if (input.sort !== 'id') {
const sample = input.at ? await findAuditMonth(tx, world, input.at) : null;
if (input.at && !sample) return { ...world, sample, collected: false, items: [], nextCursor: null };
return { ...world, sample, collected: true, ...(await readSortedGenerals(tx, input, sample?.id)) };
}
if (typeof input.cursor === 'object')
throw new TRPCError({ code: 'BAD_REQUEST', message: '장수 번호 정렬의 다음 페이지가 아닙니다.' });
const npcState =
input.population === 'human'
? { lt: 2 }
@@ -2852,6 +2852,132 @@ integration('game API security over HTTP transport', () => {
expect((await get('capabilities', admin)).body).toMatchObject({
result: { data: { read: true, accounts: false } },
});
// 현재/과거 정렬은 전체 조건 안에서 수행하고 동점은 ID로 안정적으로 넘긴다.
const originalSortRows = await db.general.findMany({
where: { id: { in: [generalId, sameNationGeneralId] } },
select: { id: true, gold: true },
});
await db.general.updateMany({
where: { id: { in: [generalId, sameNationGeneralId] } },
data: { gold: 54321 },
});
const sortedInput = { nationId: ownerNationId, sort: 'gold', order: 'desc', limit: 1 };
expect((await get('generals', admin, sortedInput)).body).toMatchObject({
result: {
data: {
items: [{ id: generalId, gold: 54321 }],
nextCursor: { id: generalId, value: 54321 },
},
},
});
expect(
(await get('generals', admin, { ...sortedInput, cursor: { id: generalId, value: 54321 } })).body
).toMatchObject({ result: { data: { items: [{ id: sameNationGeneralId }] } } });
for (const row of originalSortRows)
await db.general.update({ where: { id: row.id }, data: { gold: row.gold } });
for (const sort of [
'gold',
'rice',
'crew',
'train',
'atmos',
'leadership',
'strength',
'intelligence',
'experience',
'dedication',
'dex1',
'dex2',
'dex3',
'dex4',
'dex5',
]) {
for (const at of [undefined, { year: 190, month: 1 }]) {
expect((await get('generals', admin, { sort, at })).status).toBe(200);
}
}
const oldGeneral = await db.playAuditGeneral.findUniqueOrThrow({
where: { sampleId_generalId: { sampleId, generalId } },
});
await db.playAuditGeneral.create({
data: {
sampleId,
generalId: sameNationGeneralId,
nationId: oldGeneral.nationId,
cityId: oldGeneral.cityId,
npcState: oldGeneral.npcState,
data: { ...asRecord(oldGeneral.data), id: sameNationGeneralId, name: '동점과거장수' },
},
});
const historicalValue = past.gold;
expect(
(await get('generals', admin, { sort: 'gold', order: 'desc', at: { year: 190, month: 1 }, limit: 1 }))
.body
).toMatchObject({
result: { data: { items: [{ id: generalId }], nextCursor: { id: generalId, value: historicalValue } } },
});
expect(
(
await get('generals', admin, {
sort: 'gold',
order: 'desc',
at: { year: 190, month: 1 },
limit: 1,
cursor: { id: generalId, value: historicalValue },
})
).body
).toMatchObject({ result: { data: { items: [{ id: sameNationGeneralId }], nextCursor: null } } });
await db.playAuditGeneral.delete({
where: { sampleId_generalId: { sampleId, generalId: sameNationGeneralId } },
});
expect((await get('generals', admin, { sort: 'gold', cursor: 1 })).status).toBe(400);
expect((await get('generals', admin, { sort: 'gold; DROP TABLE general' })).status).toBe(400);
expect(
(await get('generals', admin, { sort: 'gold', at: { year: 190, month: 1 }, name: '%' })).body
).toMatchObject({ result: { data: { items: [] } } });
for (const accessToken of [
undefined,
await token(['admin']),
await token(['admin.playAudit.read:other:default']),
]) {
expect((await get('cityMap', accessToken, {})).status).toBe(accessToken ? 403 : 401);
expect((await get('generals', accessToken, { sort: 'crew' })).status).toBe(accessToken ? 403 : 401);
}
const citySource = await db.playAuditCity.findFirstOrThrow({ where: { sampleId }, select: { data: true } });
await db.playAuditCity.create({
data: {
sampleId,
cityId: 1,
nationId: ownerNationId,
data: { ...asRecord(citySource.data), id: 1, name: '지도과거도시' },
},
});
const map = await get('cityMap', admin, { at: { year: 190, month: 1 } });
expect(map.status).toBe(200);
expect(map.body).toMatchObject({
result: {
data: {
collected: true,
map: {
year: 190,
month: 1,
cityList: expect.arrayContaining([[1, 4, 0, ownerNationId, expect.any(Number), 1]]),
},
layout: {
cityList: expect.arrayContaining([
expect.objectContaining({ id: 1, name: '지도과거도시' }),
]),
},
},
},
});
expect(JSON.stringify(map.body)).not.toContain('must-not-expose');
await db.playAuditCity.delete({ where: { sampleId_cityId: { sampleId, cityId: 1 } } });
expect((await get('cityMap', admin, { at: { year: 190, month: 2 } })).body).toMatchObject({
result: { data: { collected: false, map: null } },
});
expect((await get('cityMap', admin, {})).status).toBe(200);
expect((await get('cityMap', admin, { at: { year: 9999, month: 1 } })).status).toBe(400);
const first = await get('generals', admin, { limit: 1 });
expect(first.status).toBe(200);
expect(first.body).toMatchObject({
+67 -1
View File
@@ -546,7 +546,11 @@ const install = async (
...world,
collected: !input.at || (input.at as { month: number }).month !== 7,
sample: input.at ?? null,
nextCursor: input.cursor ? null : 1,
nextCursor: input.cursor
? null
: input.sort && input.sort !== 'id'
? { id: 1, value: 1200 }
: 1,
items:
input.at && (input.at as { month: number }).month === 7
? []
@@ -567,6 +571,27 @@ const install = async (
nation: { id: 2, name: '촉' },
city: { id: 3, name: '성도' },
});
case 'playAudit.cityMap':
return result({
...world,
collected: true,
sample: input.at ?? null,
nextCursor: null,
unmappedCityIds: [],
layout: {
mapName: 'che',
cityList: [{ id: 3, name: '성도', level: 4, region: 1, x: 200, y: 180, path: [] }],
regionMap: { 1: '익주' },
levelMap: { 4: '이' },
},
map: {
year: 190,
month: (input.at as { month: number } | undefined)?.month ?? 7,
startYear: 190,
cityList: [[3, 4, 0, 2, 1, 1]],
nationList: [[2, '촉', '#ff0000', 0]],
},
});
case 'playAudit.cityDetail':
return result({
...world,
@@ -1455,3 +1480,44 @@ for (const width of [1440, 390]) {
if (width < 1200) await expect(detail).toBeInViewport();
});
}
for (const width of [1280, 390]) {
test(`audit historical map and ranked generals at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await page.route('https://sam-image.hided.net/**', (route) =>
route.fulfill({
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg" width="700" height="500"><rect width="700" height="500" fill="#203c2d"/></svg>',
})
);
const requests = await install(page);
await page.goto(gamePath('/play-audit?tab=cities&at=month&year=190&month=6'));
expect(requests.some((request) => request.operation === 'playAudit.cityMap')).toBe(false);
await page.getByRole('link', { name: '도시 지도', exact: true }).click();
await expect(page.getByRole('button', { name: '성도', exact: true })).toBeVisible();
await page.getByRole('button', { name: '성도', exact: true }).click();
await expect(page).toHaveURL(/cityRecord=3/);
await expect(page.getByRole('heading', { name: '성도 (#3) · 촉' })).toBeVisible();
await capture(page, `audit-map-${width}`);
await page.reload();
await expect(page.getByRole('button', { name: '성도', exact: true })).toBeVisible();
await page.getByRole('button', { name: '이 시점의 모든 국가 주둔 장수', exact: true }).click();
await expect(page).toHaveURL(/city=3/);
await page.getByLabel('정렬 기준', { exact: true }).selectOption('gold');
await page.getByLabel('정렬 방향', { exact: true }).selectOption('desc');
await page.getByRole('button', { name: '조회', exact: true }).click();
await expect(page).toHaveURL(/sort=gold/);
await page.getByRole('button', { name: '다음 50개 불러오기' }).click();
await expect(page.getByRole('rowheader', { name: /다음장수/ })).toBeVisible();
expect(requests.filter((request) => request.operation === 'playAudit.generals').at(-1)?.input).toMatchObject({
sort: 'gold',
order: 'desc',
cityId: 3,
at: { year: 190, month: 6, kind: 'MONTH_END' },
cursor: { id: 1, value: 1200 },
});
await page.reload();
await expect(page.getByLabel('정렬 기준', { exact: true })).toHaveValue('gold');
await capture(page, `audit-ranked-${width}`);
});
}
+82 -3
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter, type LocationQueryRaw } from 'vue-router';
import MapViewer from '../components/main/MapViewer.vue';
import PanelCard from '../components/ui/PanelCard.vue';
import AuditNationSeries from '../components/playAudit/AuditNationSeries.vue';
import AuditNationSnapshot from '../components/playAudit/AuditNationSnapshot.vue';
@@ -24,6 +25,27 @@ const coverage = ref<Coverage | null>(null);
const nations = ref<Nations | null>(null);
const generals = ref<Generals | null>(null);
const cities = ref<Cities | null>(null);
const cityMap = ref<Awaited<ReturnType<typeof trpc.playAudit.cityMap.query>> | null>(null);
const cityView = computed(() => (route.query.view === 'map' ? 'map' : 'list'));
const sortOptions = [
['id', '장수 번호'],
['gold', '금'],
['rice', '쌀'],
['crew', '병력'],
['train', '훈련'],
['atmos', '사기'],
['leadership', '통솔'],
['strength', '무력'],
['intelligence', '지력'],
['experience', '경험'],
['dedication', '공헌'],
['dex1', '보병 숙련'],
['dex2', '궁병 숙련'],
['dex3', '기병 숙련'],
['dex4', '귀병 숙련'],
['dex5', '차병 숙련'],
] as const;
const generalSort = ref<(typeof sortOptions)[number][0]>('id');
const series = ref<Series | null>(null);
const nationSnapshot = ref<NationSnapshot | null>(null);
const authorized = ref(false);
@@ -151,7 +173,9 @@ const result = computed(() =>
tab.value === 'generals'
? generals.value
: tab.value === 'cities'
? cities.value
? cityView.value === 'map'
? cityMap.value
: cities.value
: nationSnapshot.value
? { ...nationSnapshot.value, nextCursor: null }
: series.value
@@ -231,6 +255,7 @@ const readQuery = () => {
cityId.value = route.query.city ? String(numeric(route.query.city, 0)) : '';
generalName.value = typeof route.query.name === 'string' ? route.query.name : '';
generalOrder.value = route.query.order === 'desc' ? 'desc' : 'asc';
generalSort.value = sortOptions.find(([key]) => key === route.query.sort)?.[0] ?? 'id';
population.value = ['human', 'npc', 'troopNpc'].includes(String(route.query.population))
? String(route.query.population)
: '';
@@ -254,6 +279,7 @@ const load = async (append = false) => {
if (!append) {
generals.value = null;
cities.value = null;
cityMap.value = null;
series.value = null;
nationSnapshot.value = null;
}
@@ -265,6 +291,7 @@ const load = async (append = false) => {
cityId: cityId.value === '' ? undefined : Number(cityId.value),
name: generalName.value.trim() || undefined,
order: generalOrder.value,
sort: generalSort.value,
population:
population.value === 'human' || population.value === 'npc' || population.value === 'troopNpc'
? population.value
@@ -276,6 +303,9 @@ const load = async (append = false) => {
...response,
items: append ? [...(generals.value?.items ?? []), ...response.items] : response.items,
};
} else if (tab.value === 'cities' && cityView.value === 'map') {
const response = await trpc.playAudit.cityMap.query({ at: at.value });
if (request === generation) cityMap.value = response;
} else if (tab.value === 'cities') {
const response = await trpc.playAudit.cities.query({
...filter,
@@ -337,6 +367,8 @@ const apply = async () => {
population: population.value || undefined,
name: tab.value === 'generals' ? generalName.value.trim() || undefined : undefined,
order: tab.value === 'generals' ? generalOrder.value : undefined,
sort: tab.value === 'generals' && generalSort.value !== 'id' ? generalSort.value : undefined,
view: tab.value === 'cities' ? cityView.value : undefined,
at: moment.value,
year: String(year.value),
month: String(month.value),
@@ -602,14 +634,26 @@ onMounted(async () => {
</select></label
>
<template v-if="tab === 'generals'">
<label
>정렬 기준<select
v-model="generalSort"
class="legacy-sort-select"
aria-label="정렬 기준"
>
<option v-for="[key, label] in sortOptions" :key="key" :value="key">
{{ label }}
</option>
</select></label
>
<label
>장수 이름<input v-model="generalName" maxlength="64" placeholder="이름 부분 검색"
/></label>
<label
>장수 번호 정렬<select
>{{ generalSort === 'id' ? '장수 번호 정렬' : '정렬 방향'
}}<select
class="legacy-sort-select"
v-model="generalOrder"
aria-label="장수 번호 정렬"
:aria-label="generalSort === 'id' ? '장수 번호 정렬' : '정렬 방향'"
>
<option value="asc">오름차순</option>
<option value="desc">내림차순</option>
@@ -737,6 +781,41 @@ onMounted(async () => {
</table>
</div>
</template>
<nav v-if="tab === 'cities'" class="menu-row" aria-label="도시 표시 방식">
<RouterLink
class="legacy-button"
:aria-current="cityView === 'list' ? 'page' : undefined"
:to="{ query: { ...route.query, view: 'list' } }"
>도시 목록</RouterLink
>
<RouterLink
class="legacy-button"
:aria-current="cityView === 'map' ? 'page' : undefined"
:to="{ query: { ...route.query, view: 'map' } }"
>도시 지도</RouterLink
>
</nav>
<template v-if="cityMap && tab === 'cities' && cityView === 'map'">
<p>
지도는 선택 시점의 모든 국가 도시를 표시합니다. 도시를 눌러 상세와 주둔 장수를 확인하세요.
</p>
<p v-if="!cityMap.collected">선택한 시점의 표본이 없습니다.</p>
<p v-else-if="cityMap.unmappedCityIds.length">
지도 위치가 없는 도시: {{ cityMap.unmappedCityIds.join(', ') }}. 도시 목록에서 조회할 수
있습니다.
</p>
<MapViewer
v-if="cityMap.map"
:map-data="cityMap.map"
:map-layout="cityMap.layout"
:loading="loading"
:selected-city-id="selectedCity"
:detail-mode="false"
fit-container
:show-current-city-marker="false"
@select-city="selectCity"
/>
</template>
<template v-if="cities && tab === 'cities'">
<p v-if="!cities.collected">선택한 시점의 표본이 없습니다.</p>
<p v-else-if="!cities.items.length">조건에 맞는 도시가 없습니다.</p>