feat: 이전 서버 기록 조회 화면을 통합
지난 플레이를 현재 장수 카드와 전투·숙련도·기록 컴포넌트로 표시하고, 중앙 archive의 소유자 기반 조회를 연결한다. 명예의 전당과 왕조에 현재/이전 서버 source 분리를 추가한다.
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
|
||||
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
|
||||
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
const operationNames = (route: Route) =>
|
||||
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
|
||||
|
||||
const isLegacyRequest = (route: Route): boolean =>
|
||||
decodeURIComponent(`${route.request().url()} ${route.request().postData() ?? ''}`).includes('legacy');
|
||||
|
||||
const installArchiveViews = async (page: Page) => {
|
||||
await page.addInitScript((profile) => {
|
||||
localStorage.setItem('sammo-game-token', 'ga_archive_views');
|
||||
localStorage.setItem('sammo-game-profile', profile);
|
||||
}, gameProfile);
|
||||
await page.route(gameTrpcRoute, async (route) => {
|
||||
const legacy = isLegacyRequest(route);
|
||||
const results = operationNames(route).map((operation) => {
|
||||
if (operation === 'auth.status') return response({ ok: true });
|
||||
if (operation === 'lobby.info') return response({ myGeneral: null });
|
||||
if (operation === 'ranking.getHallOfFameOptions') {
|
||||
return response([
|
||||
{
|
||||
sourceProfile: legacy ? 'hwe' : 'che',
|
||||
season: legacy ? 1 : 2,
|
||||
scenarios: [{ id: 7, name: legacy ? '이전 시나리오' : '현재 시나리오', count: 1 }],
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (operation === 'ranking.getHallOfFame') {
|
||||
return response({
|
||||
source: legacy ? 'legacy' : 'current',
|
||||
sourceProfile: legacy ? 'hwe' : 'che',
|
||||
sections: [
|
||||
{
|
||||
title: '명 성',
|
||||
valueType: 'int',
|
||||
entries: [
|
||||
{
|
||||
generalId: 1,
|
||||
name: legacy ? '이전장수' : '현재장수',
|
||||
ownerName: null,
|
||||
nationName: legacy ? '이전국' : '현재국',
|
||||
bgColor: '#330000',
|
||||
fgColor: '#ffffff',
|
||||
picture: null,
|
||||
imageServer: 0,
|
||||
value: 100,
|
||||
printValue: '100',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
if (operation === 'dynasty.getList') {
|
||||
return response({
|
||||
source: legacy ? 'legacy' : 'current',
|
||||
current: legacy ? null : { year: 220, month: 1 },
|
||||
entries: [
|
||||
{
|
||||
id: legacy ? 101 : 1,
|
||||
source: legacy ? 'legacy' : 'current',
|
||||
sourceProfile: legacy ? 'hwe' : 'che',
|
||||
serverId: legacy ? 'hwe-old-1' : 'che-current-1',
|
||||
phase: legacy ? '이전 1기' : '현재 1기',
|
||||
name: '촉',
|
||||
year: 215,
|
||||
month: 4,
|
||||
color: '#800000',
|
||||
type: '병가',
|
||||
power: 100,
|
||||
gennum: 5,
|
||||
citynum: 3,
|
||||
l12name: '유비',
|
||||
l11name: '제갈량',
|
||||
l10name: '관우',
|
||||
l9name: '방통',
|
||||
l8name: '장비',
|
||||
l7name: '법정',
|
||||
l6name: '조운',
|
||||
l5name: '마량',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
if (operation === 'dynasty.getDetail') {
|
||||
return response({
|
||||
source: 'legacy',
|
||||
sourceProfile: 'hwe',
|
||||
emperor: {
|
||||
id: 101,
|
||||
serverId: 'hwe-old-1',
|
||||
winnerNationId: 1,
|
||||
phase: '이전 1기',
|
||||
nationCount: '1 / 2',
|
||||
nationName: '촉',
|
||||
nationHist: '병가',
|
||||
genCount: '5 / 10',
|
||||
personalHist: '의리',
|
||||
specialHist: '상재',
|
||||
name: '촉',
|
||||
type: '병가',
|
||||
color: '#800000',
|
||||
year: 215,
|
||||
month: 4,
|
||||
power: 100,
|
||||
gennum: 5,
|
||||
citynum: 3,
|
||||
pop: '1000',
|
||||
poprate: '100%',
|
||||
gold: 100,
|
||||
rice: 100,
|
||||
l12name: '유비',
|
||||
l11name: '제갈량',
|
||||
l10name: '관우',
|
||||
l9name: '방통',
|
||||
l8name: '장비',
|
||||
l7name: '법정',
|
||||
l6name: '조운',
|
||||
l5name: '마량',
|
||||
tiger: '',
|
||||
eagle: '',
|
||||
gen: '',
|
||||
history: [],
|
||||
},
|
||||
nations: [],
|
||||
});
|
||||
}
|
||||
return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } };
|
||||
});
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
|
||||
});
|
||||
};
|
||||
|
||||
test('명예의 전당은 현재 기록과 이전 서버 기록을 분리해 조회한다', async ({ page }) => {
|
||||
await installArchiveViews(page);
|
||||
await page.setViewportSize({ width: 1000, height: 800 });
|
||||
await page.goto('hall-of-fame');
|
||||
|
||||
await expect(page.getByText('현재장수')).toBeVisible();
|
||||
await page.getByLabel('기록 구분').selectOption('legacy');
|
||||
await expect(page.getByText('이전장수')).toBeVisible();
|
||||
await expect(page.getByLabel('시나리오 검색')).toContainText('HWE / 이전 시나리오');
|
||||
await expect(page.locator('.legacy-hall-page')).toHaveCSS('width', '1000px');
|
||||
});
|
||||
|
||||
test('왕조 일람과 상세는 이전 서버 source와 profile을 유지한다', async ({ page }) => {
|
||||
await installArchiveViews(page);
|
||||
await page.setViewportSize({ width: 1200, height: 800 });
|
||||
await page.goto('dynasty');
|
||||
|
||||
await expect(page.getByText('현재 1기')).toBeVisible();
|
||||
await page.getByLabel('기록 구분').selectOption('legacy');
|
||||
await expect(page.getByText(/이전 1기.*HWE 이전 서버/)).toBeVisible();
|
||||
const detailLink = page.getByRole('link', { name: '자세히' });
|
||||
await expect(detailLink).toHaveAttribute('href', /dynasty\/101\?source=legacy$/);
|
||||
await detailLink.click();
|
||||
await expect(page.getByText(/이전 1기.*HWE 이전 서버/)).toBeVisible();
|
||||
await expect(page.locator('.dynasty-page')).toHaveCSS('width', '1000px');
|
||||
});
|
||||
@@ -8,7 +8,7 @@ const response = (data: unknown) => ({ result: { data } });
|
||||
const operationNames = (route: Route) =>
|
||||
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
|
||||
|
||||
const installArchive = async (page: Page) => {
|
||||
const installArchive = async (page: Page, options: { battleAvailable?: boolean } = {}) => {
|
||||
await page.addInitScript((profile) => {
|
||||
localStorage.setItem('sammo-game-token', 'ga_archive');
|
||||
localStorage.setItem('sammo-game-profile', profile);
|
||||
@@ -21,7 +21,10 @@ const installArchive = async (page: Page) => {
|
||||
return response({
|
||||
seasons: [
|
||||
{
|
||||
sourceProfile: 'che',
|
||||
source: 'legacy',
|
||||
serverId: 'che_2024_01',
|
||||
openedAt: '2024-01-31T00:00:00.000Z',
|
||||
date: '2024-01-31T00:00:00.000Z',
|
||||
season: 51,
|
||||
scenario: 2,
|
||||
@@ -54,11 +57,67 @@ const installArchive = async (page: Page) => {
|
||||
}
|
||||
if (operation === 'archive.myPastPlayDetail') {
|
||||
return response({
|
||||
sourceProfile: 'che',
|
||||
source: 'legacy',
|
||||
serverId: 'che_2024_01',
|
||||
generalNo: 17,
|
||||
name: '관우',
|
||||
lastYearMonth: 21403,
|
||||
history: ['<C>●</>214년 3월: 촉에 임관', '<Y>●</>214년 1월: 성도에서 거병'],
|
||||
dynastyPath: '/dynasty/7?source=legacy',
|
||||
nation: { name: '촉', color: '#800000' },
|
||||
general: {
|
||||
id: 17,
|
||||
name: '관우',
|
||||
picture: null,
|
||||
imageServer: 0,
|
||||
npcState: 0,
|
||||
officerLevel: 12,
|
||||
officerLevelText: '황제',
|
||||
generalType: '용장',
|
||||
stats: { leadership: 91, strength: 98, intelligence: 77 },
|
||||
gold: 12_000,
|
||||
rice: 8_000,
|
||||
crew: 7_000,
|
||||
train: 100,
|
||||
atmos: 100,
|
||||
injury: 0,
|
||||
experience: 23_000,
|
||||
dedication: 1_200,
|
||||
crewTypeId: 1,
|
||||
crewTypeName: '보병',
|
||||
traits: { personal: '대담', specialDomestic: '상재', specialWar: '신산' },
|
||||
progression: {
|
||||
experienceLevel: 12,
|
||||
dedicationLevel: 8,
|
||||
dedicationText: '황제',
|
||||
statExperience: { leadership: 12, strength: 14, intelligence: 8 },
|
||||
statUpgradeLimit: 30,
|
||||
dex: [125_000, 250_000, 375_000, 500_000, 625_000],
|
||||
},
|
||||
},
|
||||
masteryAvailable: true,
|
||||
battle: {
|
||||
available: options.battleAvailable ?? true,
|
||||
warnum: 16,
|
||||
wins: 10,
|
||||
losses: 6,
|
||||
strategies: 4,
|
||||
killCrew: 12_000,
|
||||
deathCrew: 8_000,
|
||||
winRate: 62.5,
|
||||
killRate: 75,
|
||||
recentWar: '2024-01-30T03:00:00.000Z',
|
||||
},
|
||||
logs: {
|
||||
generalHistory: {
|
||||
available: true,
|
||||
entries: [
|
||||
{ id: 2, text: '<C>●</>214년 3월: 촉에 임관' },
|
||||
{ id: 1, text: '<Y>●</>214년 1월: 성도에서 거병' },
|
||||
],
|
||||
},
|
||||
battleDetail: { available: false, entries: [] },
|
||||
battleResult: { available: false, entries: [] },
|
||||
generalAction: { available: false, entries: [] },
|
||||
},
|
||||
});
|
||||
}
|
||||
return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } };
|
||||
@@ -76,6 +135,15 @@ test('지난 플레이 관직은 숫자 대신 저장된 Ref 표시명으로 나
|
||||
await expect(generalRow).not.toContainText('che_');
|
||||
});
|
||||
|
||||
test('보존되지 않은 과거 전투 집계는 0으로 꾸미지 않고 가용성 경계를 표시한다', async ({ page }) => {
|
||||
await installArchive(page, { battleAvailable: false });
|
||||
await page.goto('past-plays');
|
||||
await page.locator('.detail-toggle').click();
|
||||
|
||||
await expect(page.locator('[data-general-battle-summary]')).toHaveText('전투 집계가 보존되지 않았습니다.');
|
||||
await expect(page.locator('[data-general-battle-summary]')).not.toContainText('승률');
|
||||
});
|
||||
|
||||
test('past plays is available without a current general and preserves desktop interaction geometry', async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -87,19 +155,46 @@ test('past plays is available without a current general and preserves desktop in
|
||||
await expect(root).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: '내 지난 플레이 보기' })).toBeVisible();
|
||||
await expect(page.getByText('천하쟁패 · 51기')).toBeVisible();
|
||||
await expect(page.getByText('이전 서버 기록')).toBeVisible();
|
||||
await expect(page.getByText('che', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(/2024.*개장/)).toBeVisible();
|
||||
await expect(page.locator('.general-name')).toHaveText('관우');
|
||||
await expect(page.locator('tbody tr').filter({ hasText: '관우' })).toContainText('황제');
|
||||
await expect(page.getByRole('link', { name: '이 기수 국가 정보' })).toHaveAttribute('href', gamePath('/dynasty/7'));
|
||||
const historyToggle = page.locator('.history-toggle');
|
||||
await expect(historyToggle).toHaveText('보기 (2)');
|
||||
await historyToggle.click();
|
||||
const detailToggle = page.locator('.detail-toggle');
|
||||
await expect(detailToggle).toHaveText('상세 보기');
|
||||
await detailToggle.hover();
|
||||
const beforePress = await detailToggle.boundingBox();
|
||||
await page.mouse.down();
|
||||
expect(await detailToggle.evaluate((element) => getComputedStyle(element).transform)).not.toBe('none');
|
||||
await page.mouse.up();
|
||||
expect((await detailToggle.boundingBox())?.y).toBeCloseTo(beforePress?.y ?? 0, 0);
|
||||
await expect(page.getByText('214년 3월: 촉에 임관')).toBeVisible();
|
||||
await expect(historyToggle).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(detailToggle).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(page.locator('.archive-general-card')).toHaveAttribute('data-general-basic-card', '');
|
||||
await expect(page.locator('.archive-general-card [role="progressbar"]')).toHaveCount(14);
|
||||
await expect(page.locator('[data-general-battle-summary]')).toContainText('승률62.5%');
|
||||
await expect(page.locator('[data-general-battle-summary]')).toContainText('살상률75.0%');
|
||||
await expect(page.locator('[data-log-type="battleDetail"]')).toContainText(
|
||||
'이 기수에는 전투 기록이 보존되지 않았습니다.'
|
||||
);
|
||||
await expect(page.locator('[data-log-type="battleResult"]')).toContainText(
|
||||
'이 기수에는 전투 결과가 보존되지 않았습니다.'
|
||||
);
|
||||
await expect(page.locator('[data-log-type="generalAction"]')).toContainText(
|
||||
'이 기수에는 개인 기록이 보존되지 않았습니다.'
|
||||
);
|
||||
await expect(page.locator('[data-log-type="generalHistory"] C')).toHaveCount(0);
|
||||
await expect(page.getByRole('link', { name: '이 기수 국가 정보' })).toHaveAttribute(
|
||||
'href',
|
||||
`${gamePath('/dynasty/7')}?source=legacy`
|
||||
);
|
||||
|
||||
const geometry = await root.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const titleRect = element.querySelector('.title-row')!.getBoundingClientRect();
|
||||
const tableRect = element.querySelector('table')!.getBoundingClientRect();
|
||||
const detailGrid = element.querySelector('.detail-grid')!;
|
||||
const card = element.querySelector('[data-general-basic-card]')!.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
x: rect.x,
|
||||
@@ -107,6 +202,8 @@ test('past plays is available without a current general and preserves desktop in
|
||||
minHeight: rect.height,
|
||||
title: { x: titleRect.x, y: titleRect.y, width: titleRect.width },
|
||||
tableWidth: tableRect.width,
|
||||
detailColumns: getComputedStyle(detailGrid).gridTemplateColumns,
|
||||
cardWidth: card.width,
|
||||
color: style.color,
|
||||
backgroundColor: style.backgroundColor,
|
||||
};
|
||||
@@ -116,13 +213,14 @@ test('past plays is available without a current general and preserves desktop in
|
||||
width: 1000,
|
||||
title: { x: 100, y: 0, width: 1000 },
|
||||
tableWidth: 1000,
|
||||
cardWidth: 497,
|
||||
color: 'rgb(238, 238, 238)',
|
||||
backgroundColor: 'rgb(21, 21, 21)',
|
||||
backgroundColor: 'rgb(48, 32, 22)',
|
||||
});
|
||||
|
||||
const refresh = page.getByRole('button', { name: '새로고침' });
|
||||
await refresh.hover();
|
||||
expect(await refresh.evaluate((element) => getComputedStyle(element).color)).toBe('rgb(135, 206, 235)');
|
||||
await expect(refresh).toHaveCSS('color', 'rgb(135, 206, 235)');
|
||||
await refresh.focus();
|
||||
expect(await refresh.evaluate((element) => document.activeElement === element)).toBe(true);
|
||||
|
||||
@@ -140,6 +238,9 @@ test('past plays keeps the legacy-width table scrollable on a mobile viewport',
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('past-plays');
|
||||
|
||||
await page.locator('.detail-toggle').click();
|
||||
await expect(page.locator('.archive-general-card')).toBeVisible();
|
||||
|
||||
const scroll = page.locator('.table-scroll');
|
||||
const metrics = await scroll.evaluate((element) => ({
|
||||
clientWidth: element.clientWidth,
|
||||
@@ -149,4 +250,14 @@ test('past plays keeps the legacy-width table scrollable on a mobile viewport',
|
||||
// The shared legacy shell keeps its historical 500 px minimum canvas.
|
||||
expect(metrics).toEqual({ clientWidth: 500, scrollWidth: 940, overflowX: 'auto' });
|
||||
await expect(page.locator('.title-row')).toHaveCSS('flex-direction', 'column');
|
||||
await expect(page.locator('.detail-grid')).toHaveCSS('grid-template-columns', '498px');
|
||||
const detailMetrics = await page.locator('.detail-shell').evaluate((element) => ({
|
||||
width: element.getBoundingClientRect().width,
|
||||
scrollWidth: element.scrollWidth,
|
||||
recordBottom: element.querySelector('[data-general-record-panels]')!.getBoundingClientRect().bottom,
|
||||
shellBottom: element.getBoundingClientRect().bottom,
|
||||
}));
|
||||
expect(detailMetrics.width).toBe(498);
|
||||
expect(detailMetrics.scrollWidth).toBe(498);
|
||||
expect(detailMetrics.recordBottom).toBeLessThanOrEqual(detailMetrics.shellBottom);
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ export default defineConfig({
|
||||
'legacyLogHtml.spec.ts',
|
||||
'directoryLists.spec.ts',
|
||||
'pastPlays.spec.ts',
|
||||
'legacyArchiveViews.spec.ts',
|
||||
'nationGeneralSecret.spec.ts',
|
||||
'npcPolicy.spec.ts',
|
||||
'auction.spec.ts',
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export const GENERAL_RECORD_TYPES = ['generalHistory', 'battleDetail', 'battleResult', 'generalAction'] as const;
|
||||
|
||||
export type GeneralRecordType = (typeof GENERAL_RECORD_TYPES)[number];
|
||||
|
||||
export type GeneralRecordEntry = {
|
||||
id: number | string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type GeneralRecordCollection = Partial<Record<GeneralRecordType, GeneralRecordEntry[]>>;
|
||||
@@ -0,0 +1,122 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { formatServerDateTime } from '@sammo-ts/common';
|
||||
|
||||
export type GeneralBattleSummaryData = {
|
||||
available?: boolean;
|
||||
experience?: number | null;
|
||||
dedicationText?: string | null;
|
||||
warnum?: number | null;
|
||||
wins?: number | null;
|
||||
losses?: number | null;
|
||||
strategies?: number | null;
|
||||
killCrew?: number | null;
|
||||
deathCrew?: number | null;
|
||||
winRate?: number | null;
|
||||
killRate?: number | null;
|
||||
recentWar?: string | null;
|
||||
};
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
summary: GeneralBattleSummaryData;
|
||||
showWinRate?: boolean;
|
||||
rateScale?: 'ratio' | 'percent';
|
||||
}>(),
|
||||
{ showWinRate: false, rateScale: 'ratio' }
|
||||
);
|
||||
|
||||
const numberText = (value: number | null | undefined): string =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value.toLocaleString('ko-KR') : '-';
|
||||
|
||||
const rateText = (value: number): string => `${(props.rateScale === 'percent' ? value : value * 100).toFixed(1)}%`;
|
||||
|
||||
const winRate = computed(() => {
|
||||
if (typeof props.summary.winRate === 'number' && Number.isFinite(props.summary.winRate)) {
|
||||
return rateText(props.summary.winRate);
|
||||
}
|
||||
const battles = props.summary.warnum;
|
||||
const wins = props.summary.wins;
|
||||
if (typeof battles !== 'number' || battles <= 0 || typeof wins !== 'number') return '-';
|
||||
return `${((wins / battles) * 100).toFixed(1)}%`;
|
||||
});
|
||||
|
||||
const killRate = computed(() => {
|
||||
if (typeof props.summary.killRate === 'number' && Number.isFinite(props.summary.killRate)) {
|
||||
return rateText(props.summary.killRate);
|
||||
}
|
||||
const killed = props.summary.killCrew;
|
||||
const lost = props.summary.deathCrew;
|
||||
if (typeof killed !== 'number' || typeof lost !== 'number' || lost <= 0) return '-';
|
||||
return `${((killed / lost) * 100).toFixed(1)}%`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="battle-general-extra" data-general-battle-summary>
|
||||
<div v-if="summary.available === false" class="battle-summary-unavailable">
|
||||
전투 집계가 보존되지 않았습니다.
|
||||
</div>
|
||||
<template v-else>
|
||||
<span>명성</span><strong>{{ numberText(summary.experience) }}</strong> <span>계급</span
|
||||
><strong>{{ summary.dedicationText || '-' }}</strong> <span>전투</span
|
||||
><strong>{{ numberText(summary.warnum) }}<template v-if="summary.warnum != null">회</template></strong>
|
||||
<span>승리</span><strong>{{ numberText(summary.wins) }}</strong> <span>패배</span
|
||||
><strong>{{ numberText(summary.losses) }}</strong> <span>계략</span
|
||||
><strong>{{ numberText(summary.strategies) }}</strong> <span>사살</span
|
||||
><strong>{{ numberText(summary.killCrew) }}</strong> <span>피살</span
|
||||
><strong>{{ numberText(summary.deathCrew) }}</strong>
|
||||
<template v-if="showWinRate">
|
||||
<span>승률</span><strong>{{ winRate }}</strong> <span>살상률</span><strong>{{ killRate }}</strong>
|
||||
</template>
|
||||
<span class="battle-general-extra__recent-label">최근 전투</span>
|
||||
<strong class="battle-general-extra__recent-value">
|
||||
{{ formatServerDateTime(summary.recentWar, { format: 'monthDayTime', fallback: '-' }) }}
|
||||
</strong>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.battle-general-extra {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
|
||||
.battle-general-extra > * {
|
||||
min-height: 24px;
|
||||
box-sizing: border-box;
|
||||
border-right: 1px solid #777;
|
||||
border-bottom: 1px solid #777;
|
||||
padding: 2px 5px;
|
||||
}
|
||||
|
||||
.battle-general-extra > span {
|
||||
background-color: rgb(20 75 42 / 70%);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.battle-general-extra > strong {
|
||||
overflow: hidden;
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.battle-general-extra > .battle-general-extra__recent-label {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.battle-general-extra > .battle-general-extra__recent-value {
|
||||
grid-column: 2 / -1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.battle-summary-unavailable {
|
||||
grid-column: 1 / -1;
|
||||
color: #bbb;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import SkeletonLines from '../ui/SkeletonLines.vue';
|
||||
import { GENERAL_RECORD_TYPES, type GeneralRecordCollection, type GeneralRecordType } from '../generalRecords';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
records: GeneralRecordCollection;
|
||||
loading?: boolean;
|
||||
trustedHtml?: boolean;
|
||||
unavailable?: GeneralRecordType[];
|
||||
}>(),
|
||||
{
|
||||
loading: false,
|
||||
trustedHtml: false,
|
||||
unavailable: () => [],
|
||||
}
|
||||
);
|
||||
|
||||
const labels: Record<GeneralRecordType, string> = {
|
||||
generalHistory: '장수 열전',
|
||||
battleDetail: '전투 기록',
|
||||
battleResult: '전투 결과',
|
||||
generalAction: '개인 기록',
|
||||
};
|
||||
|
||||
const unavailableText: Record<GeneralRecordType, string> = {
|
||||
generalHistory: '이 기수에는 장수 열전이 보존되지 않았습니다.',
|
||||
battleDetail: '이 기수에는 전투 기록이 보존되지 않았습니다.',
|
||||
battleResult: '이 기수에는 전투 결과가 보존되지 않았습니다.',
|
||||
generalAction: '이 기수에는 개인 기록이 보존되지 않았습니다.',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="log-grid" data-general-record-panels>
|
||||
<div v-for="type in GENERAL_RECORD_TYPES" :key="type" class="log-block" :data-log-type="type">
|
||||
<div class="log-title">{{ labels[type] }}</div>
|
||||
<SkeletonLines v-if="loading" :lines="3" />
|
||||
<template v-else-if="props.unavailable.includes(type)">
|
||||
<div class="empty unavailable">{{ unavailableText[type] }}</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-if="(records[type]?.length ?? 0) === 0" class="empty">기록이 없습니다.</div>
|
||||
<template v-for="entry in records[type] ?? []" :key="entry.id">
|
||||
<!-- Current-season logs have already passed the trusted formatter boundary. -->
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div v-if="trustedHtml" class="log-line" v-html="entry.content" />
|
||||
<div v-else class="log-line">{{ entry.content }}</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.log-grid {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.log-block {
|
||||
min-height: 0;
|
||||
border: 1px solid #666;
|
||||
padding: 0;
|
||||
background-color: #302016;
|
||||
background-image: var(--sammo-texture-walnut);
|
||||
}
|
||||
|
||||
.log-title {
|
||||
display: flex;
|
||||
min-height: 34px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
border-bottom: 1px solid #666;
|
||||
color: orange;
|
||||
background-color: #000;
|
||||
background-image: var(--sammo-texture-green);
|
||||
font-size: 1.3em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.log-line {
|
||||
padding: 2px 8px;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.log-line :deep(.hidden_but_copyable) {
|
||||
color: transparent !important;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 2px 8px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.unavailable {
|
||||
color: #bbb;
|
||||
}
|
||||
</style>
|
||||
@@ -3,9 +3,15 @@ import { formatServerDateTime } from '@sammo-ts/common';
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import PanelCard from '../components/ui/PanelCard.vue';
|
||||
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
||||
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
|
||||
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
|
||||
import GeneralBattleSummary from '../components/main/GeneralBattleSummary.vue';
|
||||
import GeneralRecordPanels from '../components/main/GeneralRecordPanels.vue';
|
||||
import {
|
||||
GENERAL_RECORD_TYPES,
|
||||
type GeneralRecordCollection,
|
||||
type GeneralRecordType,
|
||||
} from '../components/generalRecords';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { getNpcColor } from '../utils/npcColor';
|
||||
import { formatLog } from '../utils/formatLog';
|
||||
@@ -13,17 +19,10 @@ import { formatLog } from '../utils/formatLog';
|
||||
type BattleCenterResponse = Awaited<ReturnType<typeof trpc.nation.getBattleCenter.query>>;
|
||||
type GeneralEntry = BattleCenterResponse['generals'][number];
|
||||
|
||||
type LogType = 'generalHistory' | 'battleResult' | 'battleDetail' | 'generalAction';
|
||||
type LogType = GeneralRecordType;
|
||||
type LogLine = { id: number; html: string };
|
||||
|
||||
const logTypes: LogType[] = ['generalHistory', 'battleDetail', 'battleResult', 'generalAction'];
|
||||
|
||||
const logLabels: Record<LogType, string> = {
|
||||
generalHistory: '장수 열전',
|
||||
battleDetail: '전투 기록',
|
||||
battleResult: '전투 결과',
|
||||
generalAction: '개인 기록',
|
||||
};
|
||||
const logTypes: LogType[] = [...GENERAL_RECORD_TYPES];
|
||||
|
||||
const orderOptions = [
|
||||
{ key: 'recentWar', label: '최근 전투' },
|
||||
@@ -49,6 +48,12 @@ const logs = reactive<Record<LogType, LogLine[]>>({
|
||||
generalAction: [],
|
||||
});
|
||||
|
||||
const currentRecords = computed<GeneralRecordCollection>(() =>
|
||||
Object.fromEntries(
|
||||
logTypes.map((type) => [type, logs[type].map((entry) => ({ id: entry.id, content: entry.html }))])
|
||||
)
|
||||
);
|
||||
|
||||
const resolveErrorMessage = (value: unknown): string => {
|
||||
if (value instanceof Error) {
|
||||
return value.message;
|
||||
@@ -275,27 +280,20 @@ onMounted(() => {
|
||||
:nation-color="data?.nation.color"
|
||||
>
|
||||
<template v-if="selectedGeneral" #details>
|
||||
<div class="battle-general-extra">
|
||||
<span>명성</span
|
||||
><strong>{{ selectedGeneral.experience.toLocaleString('ko-KR') }}</strong>
|
||||
<span>계급</span><strong>{{ selectedGeneral.progression.dedicationText }}</strong>
|
||||
<span>전투</span><strong>{{ selectedGeneral.warnum }}회</strong> <span>승리</span
|
||||
><strong>{{ selectedGeneral.battleStats.kills }}</strong> <span>패배</span
|
||||
><strong>{{ selectedGeneral.battleStats.deaths }}</strong> <span>계략</span
|
||||
><strong>{{ selectedGeneral.battleStats.fire }}</strong> <span>사살</span
|
||||
><strong>{{ selectedGeneral.battleStats.killCrew.toLocaleString('ko-KR') }}</strong>
|
||||
<span>피살</span
|
||||
><strong>{{ selectedGeneral.battleStats.deathCrew.toLocaleString('ko-KR') }}</strong>
|
||||
<span class="battle-general-extra__recent-label">최근 전투</span>
|
||||
<strong class="battle-general-extra__recent-value">
|
||||
{{
|
||||
formatServerDateTime(selectedGeneral.recentWar, {
|
||||
format: 'monthDayTime',
|
||||
fallback: '-',
|
||||
})
|
||||
}}
|
||||
</strong>
|
||||
</div>
|
||||
<GeneralBattleSummary
|
||||
:summary="{
|
||||
available: true,
|
||||
experience: selectedGeneral.experience,
|
||||
dedicationText: selectedGeneral.progression.dedicationText,
|
||||
warnum: selectedGeneral.warnum,
|
||||
wins: selectedGeneral.battleStats.kills,
|
||||
losses: selectedGeneral.battleStats.deaths,
|
||||
strategies: selectedGeneral.battleStats.fire,
|
||||
killCrew: selectedGeneral.battleStats.killCrew,
|
||||
deathCrew: selectedGeneral.battleStats.deathCrew,
|
||||
recentWar: selectedGeneral.recentWar,
|
||||
}"
|
||||
/>
|
||||
<LegacyGeneralProgress :general="selectedGeneral" :show-primary="false" />
|
||||
</template>
|
||||
</GeneralBasicCard>
|
||||
@@ -304,16 +302,7 @@ onMounted(() => {
|
||||
|
||||
<div class="stack">
|
||||
<PanelCard title="장수 기록" subtitle="열전과 전투 기록">
|
||||
<div class="log-grid">
|
||||
<div v-for="type in logTypes" :key="type" class="log-block" :data-log-type="type">
|
||||
<div class="log-title">{{ logLabels[type] }}</div>
|
||||
<SkeletonLines v-if="loading || logLoading" :lines="3" />
|
||||
<template v-else>
|
||||
<div v-if="logs[type].length === 0" class="empty">기록이 없습니다.</div>
|
||||
<div v-for="entry in logs[type]" :key="entry.id" class="log-line" v-html="entry.html" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<GeneralRecordPanels :records="currentRecords" :loading="loading || logLoading" trusted-html />
|
||||
</PanelCard>
|
||||
</div>
|
||||
</section>
|
||||
@@ -354,81 +343,6 @@ onMounted(() => {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.battle-general-extra {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
|
||||
.battle-general-extra > * {
|
||||
min-height: 24px;
|
||||
box-sizing: border-box;
|
||||
border-right: 1px solid #777;
|
||||
border-bottom: 1px solid #777;
|
||||
padding: 2px 5px;
|
||||
}
|
||||
|
||||
.battle-general-extra > span {
|
||||
background-color: rgb(20 75 42 / 70%);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.battle-general-extra > strong {
|
||||
overflow: hidden;
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.battle-general-extra > .battle-general-extra__recent-label {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.battle-general-extra > .battle-general-extra__recent-value {
|
||||
grid-column: 2 / -1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.log-grid {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.log-block {
|
||||
border: 1px solid #666;
|
||||
padding: 0;
|
||||
background-color: #302016;
|
||||
background-image: var(--sammo-texture-walnut);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.log-title {
|
||||
min-height: 34px;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-bottom: 1px solid #666;
|
||||
color: orange;
|
||||
background: #000;
|
||||
font-size: 1.3em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.log-line {
|
||||
padding: 2px 8px;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.log-line :deep(.hidden_but_copyable) {
|
||||
color: transparent !important;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 2px 8px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* PanelCard is retained as a data wrapper, but its presentation follows the
|
||||
flat bootstrap rows used by the reference page. */
|
||||
:deep(.panel-card) {
|
||||
@@ -466,8 +380,7 @@ onMounted(() => {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
:deep(.panel-header),
|
||||
.log-title {
|
||||
:deep(.panel-header) {
|
||||
background-image: var(--sammo-texture-green);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const data = ref<DynastyDetailPayload | null>(null);
|
||||
const source = computed<'current' | 'legacy'>(() => (route.query.source === 'legacy' ? 'legacy' : 'current'));
|
||||
|
||||
const emperorId = computed(() => {
|
||||
const idParam = route.params.id;
|
||||
@@ -39,7 +40,7 @@ const loadDetail = async (): Promise<void> => {
|
||||
loading.value = true;
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
data.value = await trpc.dynasty.getDetail.query({ emperorId: emperorId.value });
|
||||
data.value = await trpc.dynasty.getDetail.query({ emperorId: emperorId.value, source: source.value });
|
||||
} catch (error) {
|
||||
data.value = null;
|
||||
errorMessage.value = error instanceof Error ? error.message : '왕조 정보를 불러오지 못했습니다.';
|
||||
@@ -50,7 +51,7 @@ const loadDetail = async (): Promise<void> => {
|
||||
|
||||
const formatArchiveDate = (value: string): string => formatServerDateTime(value);
|
||||
|
||||
watch(emperorId, loadDetail);
|
||||
watch([emperorId, source], loadDetail);
|
||||
onMounted(loadDetail);
|
||||
</script>
|
||||
|
||||
@@ -63,7 +64,8 @@ onMounted(loadDetail);
|
||||
역 대 왕 조<br />
|
||||
<button class="native-button" type="button" @click="closePage">창 닫기</button>
|
||||
<span class="all-link">
|
||||
<RouterLink to="/dynasty"
|
||||
<RouterLink
|
||||
:to="{ path: '/dynasty', query: source === 'legacy' ? { source: 'legacy' } : {} }"
|
||||
><button class="native-button" type="button">전체보기</button></RouterLink
|
||||
>
|
||||
</span>
|
||||
@@ -88,7 +90,12 @@ onMounted(loadDetail);
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="phase-heading centered" colspan="6">
|
||||
<span class="large-text">{{ data.emperor.phase }}</span>
|
||||
<span class="large-text">
|
||||
{{ data.emperor.phase }}
|
||||
<template v-if="data.source === 'legacy'">
|
||||
[{{ data.sourceProfile.toUpperCase() }} 이전 서버]
|
||||
</template>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { legacyNationTextColor } from '../utils/legacyNationColor';
|
||||
import { trpc } from '../utils/trpc';
|
||||
@@ -8,9 +8,11 @@ import { trpc } from '../utils/trpc';
|
||||
type DynastyListPayload = Awaited<ReturnType<typeof trpc.dynasty.getList.query>>;
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const data = ref<DynastyListPayload | null>(null);
|
||||
const selectedSource = ref<'current' | 'legacy'>(route.query.source === 'legacy' ? 'legacy' : 'current');
|
||||
|
||||
const closePage = async (): Promise<void> => {
|
||||
if (window.opener) {
|
||||
@@ -24,7 +26,7 @@ const loadDynasty = async (): Promise<void> => {
|
||||
loading.value = true;
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
data.value = await trpc.dynasty.getList.query();
|
||||
data.value = await trpc.dynasty.getList.query({ source: selectedSource.value });
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '왕조일람을 불러오지 못했습니다.';
|
||||
} finally {
|
||||
@@ -33,6 +35,7 @@ const loadDynasty = async (): Promise<void> => {
|
||||
};
|
||||
|
||||
onMounted(loadDynasty);
|
||||
watch(selectedSource, loadDynasty);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -48,6 +51,14 @@ onMounted(loadDynasty);
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="record-source legacy-bg0">
|
||||
기록 구분 :
|
||||
<select v-model="selectedSource" aria-label="기록 구분">
|
||||
<option value="current">현재 서버 기록</option>
|
||||
<option value="legacy">이전 서버 기록</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMessage" class="legacy-message error" role="alert">{{ errorMessage }}</div>
|
||||
<div v-else-if="loading && !data" class="legacy-message" role="status">불러오는 중...</div>
|
||||
|
||||
@@ -57,7 +68,9 @@ onMounted(loadDynasty);
|
||||
<tr>
|
||||
<td class="current-heading" colspan="8">
|
||||
<span class="large-text">현재 ({{ data.current.year }}年 {{ data.current.month }}月)</span>
|
||||
<RouterLink to="/yearbook"><button class="native-button" type="button">역사 보기</button></RouterLink>
|
||||
<RouterLink to="/yearbook"
|
||||
><button class="native-button" type="button">역사 보기</button></RouterLink
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -83,11 +96,24 @@ onMounted(loadDynasty);
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="phase-heading" colspan="8">
|
||||
<span class="large-text">{{ entry.phase }}</span>
|
||||
<RouterLink :to="`/dynasty/${entry.id}`">
|
||||
<span class="large-text"
|
||||
>{{ entry.phase
|
||||
}}<template v-if="entry.source === 'legacy'">
|
||||
[{{ entry.sourceProfile.toUpperCase() }} 이전 서버]</template
|
||||
></span
|
||||
>
|
||||
<RouterLink
|
||||
:to="{
|
||||
path: `/dynasty/${entry.id}`,
|
||||
query: entry.source === 'legacy' ? { source: 'legacy' } : {},
|
||||
}"
|
||||
>
|
||||
<button class="native-button" type="button">자세히</button>
|
||||
</RouterLink>
|
||||
<RouterLink v-if="entry.serverId" :to="{ path: '/yearbook', query: { serverID: entry.serverId } }">
|
||||
<RouterLink
|
||||
v-if="entry.serverId && entry.source === 'current'"
|
||||
:to="{ path: '/yearbook', query: { serverID: entry.serverId } }"
|
||||
>
|
||||
<button class="native-button" type="button">역사 보기</button>
|
||||
</RouterLink>
|
||||
</td>
|
||||
@@ -138,14 +164,10 @@ onMounted(loadDynasty);
|
||||
<table class="legacy-table legacy-bg0 footer-table spaced-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<button class="native-button" type="button" @click="closePage">창 닫기</button><br />
|
||||
</td>
|
||||
<td><button class="native-button" type="button" @click="closePage">창 닫기</button><br /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="banner">
|
||||
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD
|
||||
</td>
|
||||
<td class="banner">삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -183,6 +205,18 @@ onMounted(loadDynasty);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.record-source {
|
||||
box-sizing: border-box;
|
||||
width: 1000px;
|
||||
border: 1px solid gray;
|
||||
padding: 3px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.record-source select {
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.title-table {
|
||||
height: 47px;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIc
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
type HallOption = {
|
||||
sourceProfile: string;
|
||||
season: number;
|
||||
scenarios: Array<{ id: number; name: string; count: number }>;
|
||||
};
|
||||
@@ -42,6 +43,8 @@ const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const options = ref<HallOption[]>([]);
|
||||
const selectedSource = ref<'current' | 'legacy'>('current');
|
||||
const selectedProfile = ref<string | null>(null);
|
||||
const selectedSeason = ref<number | null>(null);
|
||||
const selectedScenario = ref<number | null>(null);
|
||||
const data = ref<HallPayload | null>(null);
|
||||
@@ -51,10 +54,11 @@ const selection = computed({
|
||||
selectedSeason.value === null
|
||||
? ''
|
||||
: selectedScenario.value === null
|
||||
? `season:${selectedSeason.value}`
|
||||
: `scenario:${selectedSeason.value}:${selectedScenario.value}`,
|
||||
? `season:${selectedProfile.value ?? ''}:${selectedSeason.value}`
|
||||
: `scenario:${selectedProfile.value ?? ''}:${selectedSeason.value}:${selectedScenario.value}`,
|
||||
set: (value: string) => {
|
||||
const [kind, season, scenario] = value.split(':');
|
||||
const [kind, profile, season, scenario] = value.split(':');
|
||||
selectedProfile.value = profile || null;
|
||||
selectedSeason.value = Number(season);
|
||||
selectedScenario.value = kind === 'scenario' ? Number(scenario) : null;
|
||||
},
|
||||
@@ -72,9 +76,17 @@ const closePage = async (): Promise<void> => {
|
||||
|
||||
const loadOptions = async (): Promise<void> => {
|
||||
try {
|
||||
options.value = await trpc.ranking.getHallOfFameOptions.query();
|
||||
if (options.value.length > 0 && selectedSeason.value === null) {
|
||||
selectedSeason.value = options.value[0]!.season;
|
||||
options.value = await trpc.ranking.getHallOfFameOptions.query({ source: selectedSource.value });
|
||||
const first = options.value[0];
|
||||
if (first) {
|
||||
selectedProfile.value = first.sourceProfile;
|
||||
selectedSeason.value = first.season;
|
||||
selectedScenario.value = null;
|
||||
} else {
|
||||
selectedProfile.value = null;
|
||||
selectedSeason.value = null;
|
||||
selectedScenario.value = null;
|
||||
data.value = null;
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '명예의 전당 옵션을 불러오지 못했습니다.';
|
||||
@@ -90,6 +102,11 @@ const loadHall = async (): Promise<void> => {
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
data.value = (await trpc.ranking.getHallOfFame.query({
|
||||
source: selectedSource.value,
|
||||
sourceProfile:
|
||||
selectedSource.value === 'legacy'
|
||||
? (selectedProfile.value as 'che' | 'kwe' | 'pwe' | 'twe' | 'nya' | 'pya' | 'hwe')
|
||||
: undefined,
|
||||
season: selectedSeason.value,
|
||||
scenario: selectedScenario.value ?? undefined,
|
||||
})) as HallPayload;
|
||||
@@ -100,10 +117,14 @@ const loadHall = async (): Promise<void> => {
|
||||
}
|
||||
};
|
||||
|
||||
watch([selectedSeason, selectedScenario], () => {
|
||||
watch([selectedProfile, selectedSeason, selectedScenario], () => {
|
||||
void loadHall();
|
||||
});
|
||||
|
||||
watch(selectedSource, () => {
|
||||
void loadOptions();
|
||||
});
|
||||
|
||||
onMounted(loadOptions);
|
||||
</script>
|
||||
|
||||
@@ -114,17 +135,29 @@ onMounted(loadOptions);
|
||||
<button class="legacy-button" type="button" @click="closePage">창 닫기</button>
|
||||
</div>
|
||||
|
||||
<label class="archive-source">
|
||||
기록 구분 :
|
||||
<select v-model="selectedSource" aria-label="기록 구분">
|
||||
<option value="current">현재 서버 기록</option>
|
||||
<option value="legacy">이전 서버 기록</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="scenario-search">
|
||||
시나리오 검색 :
|
||||
<select v-model="selection" aria-label="시나리오 검색">
|
||||
<template v-for="season in options" :key="season.season">
|
||||
<option :value="`season:${season.season}`">* 시즌 : {{ season.season }} 종합 *</option>
|
||||
<template v-for="season in options" :key="`${season.sourceProfile}:${season.season}`">
|
||||
<option :value="`season:${season.sourceProfile}:${season.season}`">
|
||||
* {{ selectedSource === 'legacy' ? `${season.sourceProfile.toUpperCase()} / ` : '' }}시즌 :
|
||||
{{ season.season }} 종합 *
|
||||
</option>
|
||||
<option
|
||||
v-for="scenario in season.scenarios"
|
||||
:key="`${season.season}:${scenario.id}`"
|
||||
:value="`scenario:${season.season}:${scenario.id}`"
|
||||
:key="`${season.sourceProfile}:${season.season}:${scenario.id}`"
|
||||
:value="`scenario:${season.sourceProfile}:${season.season}:${scenario.id}`"
|
||||
>
|
||||
{{ scenario.name }}({{ scenario.count }}회)
|
||||
{{ selectedSource === 'legacy' ? `${season.sourceProfile.toUpperCase()} / ` : ''
|
||||
}}{{ scenario.name }}({{ scenario.count }}회)
|
||||
</option>
|
||||
</template>
|
||||
</select>
|
||||
@@ -210,6 +243,19 @@ onMounted(loadOptions);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.archive-source {
|
||||
display: block;
|
||||
padding: 2px 0 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.archive-source select {
|
||||
height: 20px;
|
||||
border: 1px solid #555;
|
||||
background: #ddd;
|
||||
color: #303030;
|
||||
}
|
||||
|
||||
.scenario-search select {
|
||||
width: 189px;
|
||||
height: 20px;
|
||||
|
||||
@@ -1,21 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
|
||||
import GeneralBattleSummary, { type GeneralBattleSummaryData } from '../components/main/GeneralBattleSummary.vue';
|
||||
import GeneralRecordPanels from '../components/main/GeneralRecordPanels.vue';
|
||||
import {
|
||||
GENERAL_RECORD_TYPES,
|
||||
type GeneralRecordCollection,
|
||||
type GeneralRecordType,
|
||||
} from '../components/generalRecords';
|
||||
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
|
||||
import PanelCard from '../components/ui/PanelCard.vue';
|
||||
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
type Archive = Awaited<ReturnType<typeof trpc.archive.myPastPlays.query>>;
|
||||
type PastPlayDetail = Awaited<ReturnType<typeof trpc.archive.myPastPlayDetail.query>>;
|
||||
type DetailState = {
|
||||
open: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
detail: PastPlayDetail | null;
|
||||
type ArchiveSeason = Archive['seasons'][number] & {
|
||||
sourceProfile?: string;
|
||||
source?: string;
|
||||
openedAt?: string | null;
|
||||
date?: string | null;
|
||||
};
|
||||
|
||||
type ArchiveGeneral = {
|
||||
id: number;
|
||||
name: string;
|
||||
picture?: string | null;
|
||||
imageServer?: number | null;
|
||||
npcState: number;
|
||||
officerLevel: number;
|
||||
officerLevelText: string;
|
||||
officerCityName?: string | null;
|
||||
generalType?: string;
|
||||
leadershipBonus?: number;
|
||||
stats: { leadership: number; strength: number; intelligence: number };
|
||||
gold: number;
|
||||
rice: number;
|
||||
crew: number;
|
||||
train: number;
|
||||
atmos: number;
|
||||
injury: number;
|
||||
experience: number;
|
||||
dedication: number;
|
||||
age?: number;
|
||||
retirementYear?: number;
|
||||
turnTime?: string | null;
|
||||
crewTypeId?: number;
|
||||
crewTypeName?: string;
|
||||
traits?: { personal: string; specialWar: string; specialDomestic: string };
|
||||
progression: {
|
||||
experienceLevel: number;
|
||||
dedicationLevel: number;
|
||||
dedicationText: string;
|
||||
statExperience: { leadership: number; strength: number; intelligence: number };
|
||||
statUpgradeLimit: number;
|
||||
dex: number[];
|
||||
};
|
||||
};
|
||||
|
||||
type ArchiveLogChannel = {
|
||||
available: boolean;
|
||||
entries: Array<{ id: number | string; text: string }>;
|
||||
};
|
||||
|
||||
type PastPlayDetail = {
|
||||
sourceProfile: string;
|
||||
source: string;
|
||||
serverId: string;
|
||||
generalNo: number;
|
||||
dynastyPath: string | null;
|
||||
nation: { name: string; color: string } | null;
|
||||
general: ArchiveGeneral;
|
||||
masteryAvailable: boolean;
|
||||
battle: GeneralBattleSummaryData & {
|
||||
winRate?: number | null;
|
||||
killRate?: number | null;
|
||||
};
|
||||
logs: Partial<Record<GeneralRecordType, ArchiveLogChannel>>;
|
||||
};
|
||||
|
||||
type PastPlayDetailInput = {
|
||||
sourceProfile: string;
|
||||
source: string;
|
||||
serverId: string;
|
||||
generalNo: number;
|
||||
};
|
||||
|
||||
const queryPastPlayDetail = trpc.archive.myPastPlayDetail.query as unknown as (
|
||||
input: PastPlayDetailInput
|
||||
) => Promise<PastPlayDetail>;
|
||||
|
||||
const archive = ref<Archive | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const details = ref<Record<string, DetailState>>({});
|
||||
const selectedKey = ref<string | null>(null);
|
||||
const detail = ref<PastPlayDetail | null>(null);
|
||||
const detailLoading = ref(false);
|
||||
const detailError = ref<string | null>(null);
|
||||
|
||||
const loadArchive = async () => {
|
||||
if (loading.value) return;
|
||||
@@ -33,43 +114,74 @@ const loadArchive = async () => {
|
||||
const yearMonth = (value: number): string => `${Math.floor(value / 100)}년 ${value % 100}월`;
|
||||
const valueOrDash = (value: number | string | null): string => (value === null || value === '' ? '-' : String(value));
|
||||
const plainLog = (value: string): string => value.replace(/<[^>]+>/g, '');
|
||||
const detailKey = (serverId: string, generalNo: number): string => `${serverId}:${generalNo}`;
|
||||
const seasonSourceProfile = (season: ArchiveSeason): string => season.sourceProfile ?? '현재 서버';
|
||||
const seasonSource = (season: ArchiveSeason): string => season.source ?? 'current';
|
||||
const detailKey = (season: ArchiveSeason, generalNo: number): string =>
|
||||
`${seasonSourceProfile(season)}:${seasonSource(season)}:${season.serverId}:${generalNo}`;
|
||||
const isSelectedSeason = (season: ArchiveSeason): boolean =>
|
||||
selectedKey.value?.startsWith(`${seasonSourceProfile(season)}:${seasonSource(season)}:${season.serverId}:`) ??
|
||||
false;
|
||||
|
||||
const toggleHistory = async (serverId: string, generalNo: number): Promise<void> => {
|
||||
const key = detailKey(serverId, generalNo);
|
||||
const current = details.value[key];
|
||||
if (current?.open) {
|
||||
details.value = { ...details.value, [key]: { ...current, open: false } };
|
||||
return;
|
||||
}
|
||||
if (current?.detail) {
|
||||
details.value = { ...details.value, [key]: { ...current, open: true } };
|
||||
const formatOpenedAt = (season: ArchiveSeason): string => {
|
||||
const value = season.openedAt ?? season.date;
|
||||
if (!value) return '개장일 미상';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '개장일 미상';
|
||||
return `${new Intl.DateTimeFormat('ko-KR', { dateStyle: 'medium', timeZone: 'Asia/Seoul' }).format(date)} 개장`;
|
||||
};
|
||||
|
||||
const selectGeneral = async (season: ArchiveSeason, generalNo: number): Promise<void> => {
|
||||
const key = detailKey(season, generalNo);
|
||||
if (selectedKey.value === key) {
|
||||
selectedKey.value = null;
|
||||
detail.value = null;
|
||||
detailError.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
details.value = {
|
||||
...details.value,
|
||||
[key]: { open: true, loading: true, error: null, detail: null },
|
||||
};
|
||||
selectedKey.value = key;
|
||||
detail.value = null;
|
||||
detailError.value = null;
|
||||
detailLoading.value = true;
|
||||
try {
|
||||
const detail = await trpc.archive.myPastPlayDetail.query({ serverId, generalNo });
|
||||
details.value = {
|
||||
...details.value,
|
||||
[key]: { open: true, loading: false, error: null, detail },
|
||||
};
|
||||
const result = await queryPastPlayDetail({
|
||||
sourceProfile: seasonSourceProfile(season),
|
||||
source: seasonSource(season),
|
||||
serverId: season.serverId,
|
||||
generalNo,
|
||||
});
|
||||
if (selectedKey.value === key) detail.value = result;
|
||||
} catch (cause) {
|
||||
details.value = {
|
||||
...details.value,
|
||||
[key]: {
|
||||
open: true,
|
||||
loading: false,
|
||||
error: cause instanceof Error ? cause.message : '장수 열전을 불러오지 못했습니다.',
|
||||
detail: null,
|
||||
},
|
||||
};
|
||||
if (selectedKey.value === key) {
|
||||
detailError.value = cause instanceof Error ? cause.message : '지난 장수 상세 기록을 불러오지 못했습니다.';
|
||||
}
|
||||
} finally {
|
||||
if (selectedKey.value === key) detailLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const archiveRecords = computed<GeneralRecordCollection>(() => {
|
||||
const result: GeneralRecordCollection = {};
|
||||
for (const type of GENERAL_RECORD_TYPES) {
|
||||
const channel = detail.value?.logs[type];
|
||||
result[type] = (channel?.entries ?? []).map((entry) => ({ id: entry.id, content: plainLog(entry.text) }));
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
const unavailableRecords = computed<GeneralRecordType[]>(() =>
|
||||
GENERAL_RECORD_TYPES.filter((type) => {
|
||||
const channel = detail.value?.logs[type];
|
||||
return channel ? !channel.available : type !== 'generalHistory';
|
||||
})
|
||||
);
|
||||
|
||||
const battleSummary = computed<GeneralBattleSummaryData>(() => ({
|
||||
...detail.value?.battle,
|
||||
experience: detail.value?.general.experience ?? null,
|
||||
dedicationText: detail.value?.general.progression.dedicationText ?? null,
|
||||
}));
|
||||
|
||||
onMounted(() => {
|
||||
void loadArchive();
|
||||
});
|
||||
@@ -85,28 +197,27 @@ onMounted(() => {
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<p class="page-note">종료된 기수에 보관된 내 장수 기록입니다.</p>
|
||||
<p class="page-note">이전 서버에서 종료된 기수에 보관된 내 장수 기록입니다.</p>
|
||||
<p v-if="error" class="error-row">{{ error }}</p>
|
||||
<p v-else-if="loading && !archive" class="empty-row">불러오는 중...</p>
|
||||
<p v-else-if="archive?.seasons.length === 0" class="empty-row">보관된 지난 플레이가 없습니다.</p>
|
||||
|
||||
<section v-for="season in archive?.seasons ?? []" :key="season.serverId" class="season-card">
|
||||
<section v-for="season in archive?.seasons ?? []" :key="detailKey(season, 0)" class="season-card">
|
||||
<div class="season-heading legacy-bg2">
|
||||
<strong>{{ season.serverId }}</strong>
|
||||
<div class="season-actions">
|
||||
<div class="season-identity">
|
||||
<strong class="archive-label">이전 서버 기록</strong>
|
||||
<strong>{{ seasonSourceProfile(season) }}</strong>
|
||||
<span>{{ season.serverId }}</span>
|
||||
</div>
|
||||
<div class="season-meta">
|
||||
<span>{{ formatOpenedAt(season) }}</span>
|
||||
<span>
|
||||
{{ season.scenarioName ?? '시나리오 미상' }}
|
||||
<template v-if="season.season !== null"> · {{ season.season }}기</template>
|
||||
</span>
|
||||
<RouterLink
|
||||
v-if="season.dynastyId !== null"
|
||||
class="legacy-button nation-archive-link"
|
||||
:to="`/dynasty/${season.dynastyId}`"
|
||||
>
|
||||
이 기수 국가 정보
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
@@ -121,85 +232,83 @@ onMounted(() => {
|
||||
<th>성격</th>
|
||||
<th>내정 특기</th>
|
||||
<th>전투 특기</th>
|
||||
<th>장수 열전</th>
|
||||
<th>상세</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="general in season.generals" :key="general.generalNo">
|
||||
<tr>
|
||||
<td class="general-name">{{ general.name }}</td>
|
||||
<td>
|
||||
<span
|
||||
class="nation-name"
|
||||
:style="{ backgroundColor: general.nationColor, color: '#ffffff' }"
|
||||
>
|
||||
{{ general.nationName }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ yearMonth(general.lastYearMonth) }}</td>
|
||||
<td>{{ valueOrDash(general.leadership) }}</td>
|
||||
<td>{{ valueOrDash(general.strength) }}</td>
|
||||
<td>{{ valueOrDash(general.intel) }}</td>
|
||||
<td>{{ valueOrDash(general.officerLevelText) }}</td>
|
||||
<td>{{ valueOrDash(general.personal) }}</td>
|
||||
<td>{{ valueOrDash(general.special) }}</td>
|
||||
<td>{{ valueOrDash(general.special2) }}</td>
|
||||
<td>
|
||||
<button
|
||||
class="legacy-button history-toggle"
|
||||
type="button"
|
||||
:aria-expanded="
|
||||
details[detailKey(season.serverId, general.generalNo)]?.open ?? false
|
||||
"
|
||||
@click="toggleHistory(season.serverId, general.generalNo)"
|
||||
>
|
||||
{{
|
||||
details[detailKey(season.serverId, general.generalNo)]?.open
|
||||
? '접기'
|
||||
: `보기 (${general.historyCount})`
|
||||
}}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="details[detailKey(season.serverId, general.generalNo)]?.open" class="history-row">
|
||||
<td colspan="11">
|
||||
<p
|
||||
v-if="details[detailKey(season.serverId, general.generalNo)]?.loading"
|
||||
role="status"
|
||||
>
|
||||
장수 열전을 불러오는 중...
|
||||
</p>
|
||||
<p
|
||||
v-else-if="details[detailKey(season.serverId, general.generalNo)]?.error"
|
||||
class="history-error"
|
||||
role="alert"
|
||||
>
|
||||
{{ details[detailKey(season.serverId, general.generalNo)]?.error }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="
|
||||
details[detailKey(season.serverId, general.generalNo)]?.detail?.history
|
||||
.length === 0
|
||||
"
|
||||
>
|
||||
보관된 장수 열전이 없습니다.
|
||||
</p>
|
||||
<ol v-else class="history-list">
|
||||
<li
|
||||
v-for="(entry, index) in details[
|
||||
detailKey(season.serverId, general.generalNo)
|
||||
]?.detail?.history ?? []"
|
||||
:key="index"
|
||||
>
|
||||
{{ plainLog(entry) }}
|
||||
</li>
|
||||
</ol>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-for="general in season.generals" :key="general.generalNo">
|
||||
<td class="general-name">{{ general.name }}</td>
|
||||
<td>
|
||||
<span
|
||||
class="nation-name"
|
||||
:style="{ backgroundColor: general.nationColor, color: '#fff' }"
|
||||
>
|
||||
{{ general.nationName }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ yearMonth(general.lastYearMonth) }}</td>
|
||||
<td>{{ valueOrDash(general.leadership) }}</td>
|
||||
<td>{{ valueOrDash(general.strength) }}</td>
|
||||
<td>{{ valueOrDash(general.intel) }}</td>
|
||||
<td>{{ valueOrDash(general.officerLevelText) }}</td>
|
||||
<td>{{ valueOrDash(general.personal) }}</td>
|
||||
<td>{{ valueOrDash(general.special) }}</td>
|
||||
<td>{{ valueOrDash(general.special2) }}</td>
|
||||
<td>
|
||||
<button
|
||||
class="legacy-button detail-toggle"
|
||||
type="button"
|
||||
:aria-expanded="selectedKey === detailKey(season, general.generalNo)"
|
||||
@click="selectGeneral(season, general.generalNo)"
|
||||
>
|
||||
{{ selectedKey === detailKey(season, general.generalNo) ? '접기' : '상세 보기' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="isSelectedSeason(season)" class="detail-region">
|
||||
<SkeletonLines v-if="detailLoading" :lines="8" />
|
||||
<p v-else-if="detailError" class="detail-error" role="alert">{{ detailError }}</p>
|
||||
<div v-else-if="detail" class="detail-shell">
|
||||
<div class="detail-source">
|
||||
<span>{{ detail.sourceProfile }} · {{ detail.source }}</span>
|
||||
<RouterLink
|
||||
v-if="detail.dynastyPath"
|
||||
class="legacy-button nation-archive-link"
|
||||
:to="detail.dynastyPath"
|
||||
>
|
||||
이 기수 국가 정보
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="detail-grid">
|
||||
<PanelCard title="장수 정보">
|
||||
<GeneralBasicCard
|
||||
class="archive-general-card"
|
||||
:general="detail.general"
|
||||
:loading="false"
|
||||
:nation-color="detail.nation?.color"
|
||||
>
|
||||
<template #details>
|
||||
<GeneralBattleSummary :summary="battleSummary" show-win-rate rate-scale="percent" />
|
||||
<LegacyGeneralProgress
|
||||
v-if="detail.masteryAvailable"
|
||||
:general="detail.general"
|
||||
:show-primary="false"
|
||||
/>
|
||||
<div v-else class="archive-unavailable">
|
||||
이 기수에는 숙련도 기록이 보존되지 않았습니다.
|
||||
</div>
|
||||
</template>
|
||||
</GeneralBasicCard>
|
||||
</PanelCard>
|
||||
<PanelCard title="장수 기록" subtitle="보존된 과거 기록">
|
||||
<GeneralRecordPanels :records="archiveRecords" :unavailable="unavailableRecords" />
|
||||
</PanelCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -210,13 +319,17 @@ onMounted(() => {
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
color: #eee;
|
||||
background: #151515;
|
||||
background-color: #302016;
|
||||
background-image: var(--sammo-texture-walnut);
|
||||
font-family: var(--sammo-font-sans);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.title-row,
|
||||
.season-heading {
|
||||
border: 1px solid #555;
|
||||
background: #2b2b2b;
|
||||
border: 1px solid #666;
|
||||
background-color: #14241b;
|
||||
background-image: var(--sammo-texture-green);
|
||||
}
|
||||
|
||||
.title-row {
|
||||
@@ -233,8 +346,12 @@ onMounted(() => {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.title-row nav {
|
||||
.title-row nav,
|
||||
.season-identity,
|
||||
.season-meta,
|
||||
.detail-source {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
@@ -242,11 +359,12 @@ onMounted(() => {
|
||||
box-sizing: border-box;
|
||||
min-height: 28px;
|
||||
padding: 4px 9px;
|
||||
border: 1px solid #777;
|
||||
border-radius: 0;
|
||||
color: #eee;
|
||||
background: #333;
|
||||
border: 1px solid #2d5d7f;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
background: #315f86;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -257,6 +375,10 @@ onMounted(() => {
|
||||
color: skyblue;
|
||||
}
|
||||
|
||||
.legacy-button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.legacy-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
@@ -267,15 +389,17 @@ onMounted(() => {
|
||||
.error-row {
|
||||
margin: 0;
|
||||
padding: 12px 10px;
|
||||
border-inline: 1px solid #555;
|
||||
border-bottom: 1px solid #555;
|
||||
border-inline: 1px solid #666;
|
||||
border-bottom: 1px solid #666;
|
||||
}
|
||||
|
||||
.page-note {
|
||||
.page-note,
|
||||
.season-heading span {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.error-row {
|
||||
.error-row,
|
||||
.detail-error {
|
||||
color: #ff8d8d;
|
||||
}
|
||||
|
||||
@@ -294,19 +418,15 @@ onMounted(() => {
|
||||
color: skyblue;
|
||||
}
|
||||
|
||||
.season-heading span {
|
||||
color: #bbb;
|
||||
.archive-label {
|
||||
padding: 2px 5px;
|
||||
border: 1px solid #777;
|
||||
color: #fff !important;
|
||||
background: #00582c;
|
||||
}
|
||||
|
||||
.season-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nation-archive-link {
|
||||
min-height: 24px;
|
||||
padding-block: 2px;
|
||||
.season-meta {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.table-scroll {
|
||||
@@ -324,7 +444,7 @@ table {
|
||||
th,
|
||||
td {
|
||||
padding: 6px 7px;
|
||||
border: 1px solid #555;
|
||||
border: 1px solid #666;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -348,45 +468,75 @@ th {
|
||||
text-shadow: 0 1px 1px #000;
|
||||
}
|
||||
|
||||
.history-toggle {
|
||||
.detail-toggle,
|
||||
.nation-archive-link {
|
||||
min-height: 24px;
|
||||
padding-block: 2px;
|
||||
}
|
||||
|
||||
.history-row td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
.detail-region {
|
||||
border: 1px solid #666;
|
||||
background: #101010;
|
||||
}
|
||||
|
||||
.history-row p {
|
||||
.detail-error {
|
||||
margin: 0;
|
||||
color: #bbb;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.history-error {
|
||||
color: #ff8d8d !important;
|
||||
.detail-source {
|
||||
justify-content: space-between;
|
||||
min-height: 34px;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid #666;
|
||||
background-color: #14241b;
|
||||
background-image: var(--sammo-texture-green);
|
||||
}
|
||||
|
||||
.history-list {
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
margin: 0;
|
||||
padding-left: 26px;
|
||||
color: #ddd;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.detail-grid :deep(.panel-card) {
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.detail-grid :deep(.panel-body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.detail-grid :deep(.panel-title) {
|
||||
color: skyblue;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.archive-unavailable {
|
||||
min-height: 28px;
|
||||
padding: 5px 8px;
|
||||
border-top: 1px solid #666;
|
||||
color: #bbb;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.title-row,
|
||||
.season-heading {
|
||||
.season-heading,
|
||||
.season-identity,
|
||||
.season-meta {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.season-actions {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
.season-meta {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user