Complete legacy-compatible in-game information menus

This commit is contained in:
2026-07-26 05:20:32 +00:00
parent 8fac491c9d
commit 0127fd501a
20 changed files with 3269 additions and 940 deletions
+17 -3
View File
@@ -10,6 +10,7 @@ import NationInfoView from '../views/NationInfoView.vue';
import GlobalInfoView from '../views/GlobalInfoView.vue';
import CurrentCityView from '../views/CurrentCityView.vue';
import NationGeneralsView from '../views/NationGeneralsView.vue';
import NationSecretView from '../views/NationSecretView.vue';
import NationPersonnelView from '../views/NationPersonnelView.vue';
import NationStratFinanView from '../views/NationStratFinanView.vue';
import ChiefCenterView from '../views/ChiefCenterView.vue';
@@ -20,7 +21,6 @@ import NotFoundView from '../views/NotFoundView.vue';
import TournamentView from '../views/TournamentView.vue';
import BettingView from '../views/BettingView.vue';
import MyPageView from '../views/MyPageView.vue';
import MySettingsView from '../views/MySettingsView.vue';
import BoardView from '../views/BoardView.vue';
import DiplomacyView from '../views/DiplomacyView.vue';
import BestGeneralView from '../views/BestGeneralView.vue';
@@ -32,6 +32,7 @@ import TroopView from '../views/TroopView.vue';
import YearbookView from '../views/YearbookView.vue';
import NationBettingView from '../views/NationBettingView.vue';
import NpcListView from '../views/NpcListView.vue';
import TrafficView from '../views/TrafficView.vue';
import { useSessionStore } from '../stores/session';
const routes = [
@@ -148,6 +149,15 @@ const routes = [
requiresGeneral: true,
},
},
{
path: '/nation/secret',
name: 'nation-secret',
component: NationSecretView,
meta: {
requiresAuth: true,
requiresGeneral: true,
},
},
{
path: '/nation/personnel',
name: 'nation-personnel',
@@ -251,6 +261,11 @@ const routes = [
requiresGeneral: true,
},
},
{
path: '/traffic',
name: 'traffic',
component: TrafficView,
},
{
path: '/npc-list',
name: 'npc-list',
@@ -276,8 +291,7 @@ const routes = [
},
{
path: '/my-settings',
name: 'my-settings',
component: MySettingsView,
redirect: '/my-page',
meta: {
requiresAuth: true,
requiresGeneral: true,
+180 -56
View File
@@ -3,7 +3,6 @@ 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 GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
import { trpc } from '../utils/trpc';
import { getNpcColor } from '../utils/npcColor';
import { formatLog } from '../utils/formatLog';
@@ -269,7 +268,26 @@ onMounted(() => {
</PanelCard>
<PanelCard title="장수 정보">
<GeneralBasicCard :general="selectedGeneral" :loading="loading" />
<SkeletonLines v-if="loading" :lines="5" />
<div v-else-if="selectedGeneral" class="battle-general-card">
<div class="battle-general-name">
{{ selectedGeneral.name }} (관직 {{ selectedGeneral.officerLevel }})
</div>
<div class="battle-general-grid">
<span>통솔</span><strong>{{ selectedGeneral.stats.leadership }}</strong> <span>무력</span
><strong>{{ selectedGeneral.stats.strength }}</strong> <span>지력</span
><strong>{{ selectedGeneral.stats.intelligence }}</strong> <span>자금</span
><strong>{{ selectedGeneral.gold }}</strong> <span>군량</span
><strong>{{ selectedGeneral.rice }}</strong> <span>병력</span
><strong>{{ selectedGeneral.crew }}</strong> <span>훈련</span
><strong>{{ selectedGeneral.train }}</strong> <span>사기</span
><strong>{{ selectedGeneral.atmos }}</strong> <span>부상</span
><strong>{{ selectedGeneral.injury }}</strong> <span>경험</span
><strong>{{ selectedGeneral.experience }}</strong> <span>공헌</span
><strong>{{ selectedGeneral.dedication }}</strong> <span>전투</span
><strong>{{ selectedGeneral.warnum }}</strong>
</div>
</div>
<div v-if="selectedGeneral" class="general-meta">
<div>최근 : {{ selectedGeneral.turnTime ? selectedGeneral.turnTime.slice(-5) : '-' }}</div>
<div>최근 전투: {{ selectedGeneral.recentWar || '-' }}</div>
@@ -286,12 +304,7 @@ onMounted(() => {
<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"
/>
<div v-for="entry in logs[type]" :key="entry.id" class="log-line" v-html="entry.html" />
</template>
</div>
</div>
@@ -303,126 +316,237 @@ onMounted(() => {
<style scoped>
.battle-page {
width: 100%;
min-width: 500px;
max-width: 1000px;
min-height: 100vh;
padding: 24px;
margin: 0 auto;
padding: 0;
display: flex;
flex-direction: column;
gap: 24px;
gap: 0;
color: #fff;
background-color: #302016;
background-image: url('/image/game/back_walnut.jpg');
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-size: 14px;
line-height: 1.5;
}
.page-header {
position: relative;
min-height: 32px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
padding: 0 8px;
border: 1px solid #666;
background-color: #302016;
background-image: url('/image/game/back_walnut.jpg');
}
.page-title {
font-size: 1.6rem;
font-weight: 700;
font-size: 17px;
font-weight: 500;
}
.page-subtitle {
color: rgba(232, 221, 196, 0.7);
margin-top: 6px;
display: none;
}
.header-actions {
position: absolute;
left: 0;
top: 0;
display: flex;
flex-wrap: wrap;
gap: 10px;
gap: 4px;
}
.layout-grid {
display: grid;
grid-template-columns: minmax(0, 320px) minmax(0, 1fr);
gap: 18px;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0;
}
.stack {
display: flex;
flex-direction: column;
gap: 18px;
display: contents;
}
.selector-row {
display: grid;
grid-template-columns: auto minmax(140px, 1fr) minmax(180px, 2fr) auto;
gap: 8px;
grid-template-columns: 8.333% 33.333% 50% 8.333%;
gap: 0;
align-items: center;
}
.select-input {
min-width: 0;
padding: 6px 8px;
border: 1px solid rgba(201, 164, 90, 0.4);
background: rgba(12, 12, 12, 0.7);
height: 36px;
padding: 4px 6px;
border: 1px solid #777;
border-radius: 0;
background: #303030;
color: inherit;
font-size: 0.85rem;
font: inherit;
}
.ghost {
border: 1px solid rgba(201, 164, 90, 0.5);
background: transparent;
min-height: 32px;
border: 1px solid #777;
border-radius: 0;
background: #303030;
color: inherit;
padding: 6px 10px;
font-size: 0.8rem;
padding: 4px 8px;
font: inherit;
cursor: pointer;
}
.general-meta {
margin-top: 10px;
font-size: 0.85rem;
color: rgba(232, 221, 196, 0.75);
margin: 0;
padding: 6px 8px;
color: #ccc;
display: grid;
gap: 4px;
}
.log-grid {
.battle-general-card {
min-height: 292px;
background-color: #172a52;
background-image: url('/image/game/back_blue.jpg');
}
.battle-general-name {
min-height: 24px;
padding: 2px 6px;
text-align: center;
border-bottom: 1px solid #777;
background: rgba(220, 220, 220, 0.85);
color: #111;
font-weight: 700;
}
.battle-general-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
grid-template-columns: repeat(6, 1fr);
}
.battle-general-grid > * {
min-height: 24px;
padding: 2px 5px;
border-right: 1px solid #777;
border-bottom: 1px solid #777;
}
.battle-general-grid > span {
background-color: rgba(20, 75, 42, 0.7);
color: #fff;
text-align: center;
}
.battle-general-grid > strong {
text-align: right;
font-weight: 500;
}
.log-grid {
display: contents;
}
.log-block {
border: 1px solid rgba(201, 164, 90, 0.3);
padding: 8px;
background: rgba(12, 12, 12, 0.6);
min-height: 160px;
border: 1px solid #666;
padding: 0;
background: #111;
min-height: 180px;
}
.log-title {
font-weight: 600;
margin-bottom: 6px;
font-size: 0.9rem;
min-height: 34px;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid #666;
color: orange;
background: #252525;
font-size: 1.3em;
font-weight: 500;
}
.log-line {
padding: 4px 0;
border-bottom: 1px dashed rgba(201, 164, 90, 0.2);
}
.log-line:last-child {
border-bottom: none;
padding: 2px 8px;
border-bottom: 0;
}
.empty {
color: rgba(232, 221, 196, 0.6);
font-size: 0.85rem;
padding: 2px 8px;
color: #999;
}
.error {
color: #f08a5d;
font-size: 0.9rem;
padding: 5px 8px;
color: #ff7777;
border: 1px solid #a33;
text-align: center;
}
@media (max-width: 1024px) {
/* PanelCard is retained as a data wrapper, but its presentation follows the
flat bootstrap rows used by the reference page. */
:deep(.panel-card) {
height: 100%;
border: 1px solid #666;
border-radius: 0;
background-color: #302016;
background-image: url('/image/game/back_walnut.jpg');
box-shadow: none;
}
.stack:first-child :deep(.panel-card:first-child) {
grid-column: 1 / -1;
border: 0;
}
.stack:first-child :deep(.panel-card:first-child .panel-header) {
display: none;
}
.stack:first-child :deep(.panel-card:first-child .panel-body) {
padding: 0;
}
.stack:nth-child(2) :deep(.panel-card),
.stack:nth-child(2) :deep(.panel-body) {
display: contents;
}
.stack:nth-child(2) :deep(.panel-header) {
display: none;
}
:deep(.panel-header) {
min-height: 29px;
justify-content: center;
padding: 0;
}
:deep(.panel-title) {
color: skyblue;
font-size: 18px;
font-weight: 500;
}
:deep(.panel-header),
.log-title {
background-image: url('/image/game/back_green.jpg');
}
@media (max-width: 991px) {
.battle-page {
width: 500px;
}
.layout-grid {
grid-template-columns: 1fr;
}
.selector-row {
grid-template-columns: 16.666% 25% 41.666% 16.666%;
}
.log-grid {
grid-template-columns: 1fr;
}
}
+6 -1
View File
@@ -104,6 +104,10 @@ watch(
<RouterLink class="ghost" to="/global-info">중원 정보</RouterLink>
<RouterLink class="ghost" to="/current-city">현재 도시</RouterLink>
<RouterLink class="ghost" to="/nation/generals">세력 장수</RouterLink>
<RouterLink v-if="(boardAccess?.permission ?? -1) >= 1" class="ghost" to="/nation/secret"
>암행부</RouterLink
>
<span v-else class="ghost disabled" aria-disabled="true">암행부</span>
<RouterLink class="ghost" to="/nation/personnel">인사부</RouterLink>
<RouterLink class="ghost" to="/troop">부대 편성</RouterLink>
<RouterLink class="ghost" to="/nation/finance">내무부</RouterLink>
@@ -115,10 +119,11 @@ watch(
<RouterLink class="ghost" to="/dynasty">왕조일람</RouterLink>
<RouterLink class="ghost" to="/yearbook">연감</RouterLink>
<RouterLink class="ghost" to="/nation-betting">천통국 베팅</RouterLink>
<RouterLink class="ghost" to="/traffic">접속량정보</RouterLink>
<RouterLink class="ghost" to="/npc-list">빙의일람</RouterLink>
<a class="ghost" href="/xe/community" target="_blank" rel="noopener">게시판</a>
<RouterLink class="ghost" to="/battle-simulator">전투 시뮬레이터</RouterLink>
<RouterLink class="ghost" to="/my-page"> 정보</RouterLink>
<RouterLink class="ghost" to="/my-page"> 정보&amp;설정</RouterLink>
<RouterLink class="ghost" :class="{ highlight: tournamentStage === 1 }" to="/tournament"
>토너먼트</RouterLink
>
+543 -467
View File
@@ -1,19 +1,16 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useMediaQuery } from '@vueuse/core';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
import CityBasicCard from '../components/main/CityBasicCard.vue';
import NationBasicCard from '../components/main/NationBasicCard.vue';
import { trpc } from '../utils/trpc';
import { formatLog } from '../utils/formatLog';
const SCREEN_MODE_KEY = 'sammo-screen-mode';
const SCREEN_MODE_KEY = 'sam.screenMode';
const CUSTOM_CSS_KEY = 'sam_customCSS';
type ScreenMode = 'auto' | '500px' | '1000px';
type LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction';
type ItemSlotKey = 'horse' | 'weapon' | 'book' | 'item';
type MyGeneralResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
type WorldStateSnapshot = {
type WorldSnapshot = {
currentYear: number;
currentMonth: number;
tickSeconds: number;
@@ -21,48 +18,54 @@ type WorldStateSnapshot = {
meta: Record<string, unknown>;
} | null;
type LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction';
type LogLine = {
id: number;
html: string;
};
type ItemSlotKey = 'horse' | 'weapon' | 'book' | 'item';
type ItemSlot = {
key: ItemSlotKey;
label: string;
code: string | null;
};
const logTypes: LogType[] = ['generalHistory', 'battleDetail', 'battleResult', 'generalAction'];
const logLabels: Record<LogType, string> = {
generalHistory: '장수 열전',
battleDetail: '전투 기록',
battleResult: '전투 결과',
generalAction: '개인 기록',
type SettingForm = {
tnmt: number;
defence_train: number;
use_treatment: number;
use_auto_nation_turn: number;
};
const data = ref<MyGeneralResponse | null>(null);
const world = ref<WorldSnapshot>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const data = ref<MyGeneralResponse | null>(null);
const worldState = ref<WorldStateSnapshot>(null);
const screenMode = ref<ScreenMode>('auto');
const customCss = ref('');
const cssSaving = ref(false);
let cssTimer: number | null = null;
const logs = reactive<Record<LogType, LogLine[]>>({
const form = reactive<SettingForm>({
tnmt: 1,
defence_train: 80,
use_treatment: 10,
use_auto_nation_turn: 1,
});
const logTypes: LogType[] = ['generalAction', 'battleDetail', 'generalHistory', 'battleResult'];
const logLabels: Record<LogType, string> = {
generalAction: '개인 기록',
battleDetail: '전투 기록',
generalHistory: '장수 열전',
battleResult: '전투 결과',
};
const logColors: Record<LogType, string> = {
generalAction: 'skyblue',
battleDetail: 'orange',
generalHistory: 'skyblue',
battleResult: 'orange',
};
const logs = reactive<Record<LogType, Array<{ id: number; html: string }>>>({
generalHistory: [],
battleDetail: [],
battleResult: [],
generalAction: [],
});
const logLoading = reactive<Record<LogType, boolean>>({
generalHistory: false,
battleDetail: false,
battleResult: false,
generalAction: false,
});
const logHasMore = reactive<Record<LogType, boolean>>({
generalHistory: true,
battleDetail: true,
@@ -70,520 +73,593 @@ const logHasMore = reactive<Record<LogType, boolean>>({
generalAction: true,
});
const activeLogTab = ref<LogType>('generalAction');
const isMobile = useMediaQuery('(max-width: 1024px)');
const screenMode = ref<'auto' | '500px' | '1000px'>('auto');
const errorText = (value: unknown): string =>
value instanceof Error ? value.message : typeof value === 'string' ? value : 'unknown_error';
const resolveErrorMessage = (value: unknown): string => {
if (value instanceof Error) {
return value.message;
}
if (typeof value === 'string') {
return value;
}
return 'unknown_error';
const asRecord = (value: unknown): Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
const numberValue = (value: unknown, fallback: number): number => {
const parsed = typeof value === 'number' ? value : Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
};
const resolveNumber = (value: unknown, fallback: number): number => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return fallback;
};
const statusLine = computed(() =>
world.value
? `${world.value.currentYear}${world.value.currentMonth}월 · ${Math.max(
1,
Math.round(world.value.tickSeconds / 60)
)}분 턴`
: '내 정보를 불러오는 중'
);
const statusLine = computed(() => {
if (!worldState.value) {
return '내 정보를 불러오는 중';
}
const turnTerm = resolveNumber((worldState.value.config as Record<string, unknown>)?.turnTermMinutes, 0);
const termLabel = turnTerm > 0 ? ` · 턴 ${turnTerm}` : '';
return `${worldState.value.currentYear}${worldState.value.currentMonth}${termLabel}`;
});
const itemSlots = computed<ItemSlot[]>(() => {
const items = data.value?.general?.items;
return [
{ key: 'horse', label: '말', code: items?.horse ?? null },
{ key: 'weapon', label: '무기', code: items?.weapon ?? null },
{ key: 'book', label: '서적', code: items?.book ?? null },
{ key: 'item', label: '아이템', code: items?.item ?? null },
];
});
const canSave = computed(() => (data.value?.settings.myset ?? 1) > 0);
const penalties = computed(() => Object.entries(data.value?.penalties ?? {}));
const items = computed<Array<{ key: ItemSlotKey; name: string; code: string | null }>>(() => [
{ key: 'horse', name: '말', code: data.value?.general.items.horse ?? null },
{ key: 'weapon', name: '무기', code: data.value?.general.items.weapon ?? null },
{ key: 'book', name: '서적', code: data.value?.general.items.book ?? null },
{ key: 'item', name: '도구', code: data.value?.general.items.item ?? null },
]);
const autorunUser = computed(() => asRecord(world.value?.meta.autorun_user));
const showAutoNationTurn = computed(() => Boolean(asRecord(autorunUser.value.options).chief));
const showVacation = computed(() => !autorunUser.value.limit_minutes);
const actionAvailability = computed(() => {
const general = data.value?.general;
const meta = (worldState.value?.meta ?? {}) as Record<string, unknown>;
const config = (worldState.value?.config ?? {}) as Record<string, unknown>;
const autorunUser = (meta.autorun_user ?? {}) as Record<string, unknown>;
const turntime = meta.turntime ? new Date(String(meta.turntime)) : null;
const opentime = meta.opentime ? new Date(String(meta.opentime)) : null;
const preopen = Boolean(turntime && opentime && turntime.getTime() <= opentime.getTime());
const npcMode = resolveNumber(config.npcMode, 0);
const meta = world.value?.meta ?? {};
const config = world.value?.config ?? {};
const constConfig = asRecord(config.const);
const availableInstantAction = asRecord(constConfig.availableInstantAction ?? config.availableInstantAction);
const turnTime = meta.turntime ? new Date(String(meta.turntime)) : null;
const openTime = meta.opentime ? new Date(String(meta.opentime)) : null;
const preopen = Boolean(turnTime && openTime && turnTime.getTime() <= openTime.getTime());
const npcMode = numberValue(config.npcMode ?? config.npcmode, 0);
return {
canDieOnPrestart: Boolean(preopen && general && general.npcState === 0 && general.nationId === 0),
canBuildNationCandidate: Boolean(preopen && general && general.nationId === 0),
canVacation: !(autorunUser.limit_minutes ?? false),
canInstantRetreat: Boolean(general && general.nationId > 0),
canSelectOtherGeneral: Boolean(npcMode === 2 && general && general.npcState === 0),
dieOnPrestart: Boolean(preopen && general?.npcState === 0 && general.nationId === 0),
buildNationCandidate: Boolean(preopen && general?.nationId === 0),
instantRetreat: Boolean(availableInstantAction.instantRetreat),
selectOtherGeneral: Boolean(npcMode === 2 && general?.npcState === 0),
};
});
const loadLogs = async () => {
if (!data.value?.general?.id) {
return;
const applyCustomCss = (text: string) => {
let style = document.getElementById('sammo-custom-css') as HTMLStyleElement | null;
if (!style) {
style = document.createElement('style');
style.id = 'sammo-custom-css';
document.head.appendChild(style);
}
await Promise.all(
logTypes.map((type) => loadLog(type))
);
style.textContent = text;
};
const loadLog = async (type: LogType, beforeId?: number) => {
if (logLoading[type]) {
return;
}
if (logLoading[type]) return;
logLoading[type] = true;
try {
const response = await trpc.general.getMyLog.query({ type, beforeId });
const formatted = response.logs.map((entry) => ({
id: entry.id,
html: formatLog(entry.text),
}));
if (beforeId) {
logs[type].push(...formatted);
} else {
logs[type] = formatted;
}
logHasMore[type] = formatted.length >= 24;
} catch (err) {
error.value = resolveErrorMessage(err);
const next = response.logs.map((entry) => ({ id: entry.id, html: formatLog(entry.text) }));
logs[type] = beforeId ? [...logs[type], ...next] : next;
logHasMore[type] = next.length >= 24;
} catch (cause) {
error.value = errorText(cause);
} finally {
logLoading[type] = false;
}
};
const loadMyPage = async () => {
if (loading.value) {
return;
}
const loadPage = async () => {
if (loading.value) return;
loading.value = true;
error.value = null;
try {
const general = await trpc.general.me.query();
const world = await (trpc.world.getState.query as unknown as () => Promise<WorldStateSnapshot>)();
const [general, state] = await Promise.all([
trpc.general.me.query(),
trpc.world.getState.query() as Promise<WorldSnapshot>,
]);
data.value = general;
worldState.value = world ?? null;
await loadLogs();
} catch (err) {
error.value = resolveErrorMessage(err);
world.value = state;
if (general) {
Object.assign(form, general.settings);
}
await Promise.all(logTypes.map((type) => loadLog(type)));
} catch (cause) {
error.value = errorText(cause);
} finally {
loading.value = false;
}
};
const confirmAction = async (message: string, action: () => Promise<void>) => {
if (!confirm(message)) {
return;
}
const saveSettings = async () => {
if (!canSave.value) return;
try {
await action();
await loadMyPage();
} catch (err) {
alert(`실패했습니다: ${resolveErrorMessage(err)}`);
await trpc.general.setMySetting.mutate({ ...form });
await loadPage();
} catch (cause) {
alert(`실패했습니다: ${errorText(cause)}`);
}
};
const handleDieOnPrestart = () =>
confirmAction('정말로 삭제하시겠습니까?', async () => {
await trpc.general.dieOnPrestart.mutate();
window.location.reload();
});
const handleBuildNationCandidate = () =>
confirmAction('거병 이후 장수를 삭제할 수 없습니다. 거병하시겠습니까?', async () => {
await trpc.general.buildNationCandidate.mutate();
});
const handleInstantRetreat = () =>
confirmAction('아군 접경으로 이동할까요?', async () => {
await trpc.general.instantRetreat.mutate();
});
const handleVacation = () =>
confirmAction('휴가 기능을 신청할까요?', async () => {
await trpc.general.vacation.mutate();
});
const handleDropItem = (slot: ItemSlot) =>
confirmAction(`${slot.label}(${slot.code ?? '-'})을(를) 파기하시겠습니까?`, async () => {
await trpc.general.dropItem.mutate({ itemType: slot.key });
});
const refreshScreenMode = () => {
if (typeof window === 'undefined') {
return;
}
const mode = window.localStorage.getItem(SCREEN_MODE_KEY);
if (mode === '500px' || mode === '1000px') {
screenMode.value = mode;
} else {
screenMode.value = 'auto';
const confirmMutation = async (message: string, mutation: () => Promise<unknown>) => {
if (!confirm(message)) return;
try {
await mutation();
await loadPage();
} catch (cause) {
alert(`실패했습니다: ${errorText(cause)}`);
}
};
watch(
() => isMobile.value,
(value) => {
if (value && !logTypes.includes(activeLogTab.value)) {
activeLogTab.value = 'generalAction';
}
}
);
const dropItem = (item: { key: ItemSlotKey; name: string; code: string | null }) =>
confirmMutation(`${item.code ?? item.name}을(를) 버리시겠습니까?`, () =>
trpc.general.dropItem.mutate({ itemType: item.key })
);
watch(screenMode, (mode) => {
localStorage.setItem(SCREEN_MODE_KEY, mode);
document.dispatchEvent(new CustomEvent('tryChangeScreenMode'));
});
watch(customCss, (text) => {
if (cssTimer !== null) window.clearTimeout(cssTimer);
cssSaving.value = true;
cssTimer = window.setTimeout(() => {
localStorage.setItem(CUSTOM_CSS_KEY, text);
applyCustomCss(text);
cssSaving.value = false;
}, 500);
});
onMounted(() => {
refreshScreenMode();
void loadMyPage();
const storedMode = localStorage.getItem(SCREEN_MODE_KEY);
screenMode.value = storedMode === '500px' || storedMode === '1000px' ? storedMode : 'auto';
customCss.value = localStorage.getItem(CUSTOM_CSS_KEY) ?? '';
applyCustomCss(customCss.value);
void loadPage();
});
</script>
<template>
<main class="my-page" :class="`screen-${screenMode}`">
<header class="page-header">
<div>
<h1 class="page-title"> 정보</h1>
<p class="page-subtitle">{{ statusLine }}</p>
</div>
<div class="header-actions">
<RouterLink class="ghost" to="/">메인</RouterLink>
<RouterLink class="ghost" to="/my-settings">게임 설정</RouterLink>
<button class="ghost" @click="loadMyPage">새로고침</button>
</div>
</header>
<main id="container" class="legacy-page bg0" :class="`screen-${screenMode}`">
<div class="title-row">
<span> </span>
<RouterLink class="legacy-button" to="/">돌아가기</RouterLink>
<button class="legacy-button" type="button" @click="loadPage">새로고침</button>
</div>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="error" class="error-row">{{ error }}</div>
<div class="status-row">{{ statusLine }}</div>
<section class="layout-grid">
<div class="stack">
<PanelCard title="장수 상태">
<GeneralBasicCard :general="data?.general ?? null" :loading="loading" />
</PanelCard>
<PanelCard title="도시 상태">
<CityBasicCard :city="data?.city ?? null" :loading="loading" />
</PanelCard>
<PanelCard title="세력 상태">
<NationBasicCard :nation="data?.nation ?? null" :loading="loading" />
</PanelCard>
</div>
<div class="stack">
<PanelCard title="장수 상태 변경" subtitle="중요 액션은 확인 후 실행됩니다.">
<div class="action-grid">
<button
v-if="actionAvailability.canVacation"
class="action-btn"
type="button"
@click="handleVacation"
>
휴가 신청
</button>
<button
v-if="actionAvailability.canDieOnPrestart"
class="action-btn"
type="button"
@click="handleDieOnPrestart"
>
장수 삭제
</button>
<button
v-if="actionAvailability.canBuildNationCandidate"
class="action-btn"
type="button"
@click="handleBuildNationCandidate"
>
사전 거병
</button>
<button
v-if="actionAvailability.canInstantRetreat"
class="action-btn"
type="button"
@click="handleInstantRetreat"
>
접경 귀환
</button>
<RouterLink
v-if="actionAvailability.canSelectOtherGeneral"
class="action-btn link"
to="/join"
>
다른 장수 선택
</RouterLink>
<section class="top-grid">
<div class="general-column">
<div class="section-title sky">장수 정보</div>
<div v-if="loading || !data" class="loading">불러오는 중...</div>
<div v-else class="general-table">
<div class="portrait-cell">
<img
:src="
data.general.picture ? `/image/game/${data.general.picture}` : '/image/game/default.jpg'
"
alt=""
/>
<strong>{{ data.general.name }}</strong>
</div>
</PanelCard>
<PanelCard title="아이템 파기" subtitle="소지 중인 장비를 선택합니다.">
<div class="item-grid">
<button
v-for="slot in itemSlots"
:key="slot.key"
class="item-btn"
type="button"
:disabled="!slot.code"
@click="handleDropItem(slot)"
>
{{ slot.label }}: {{ slot.code ?? '-' }}
</button>
</div>
</PanelCard>
<PanelCard v-if="isMobile" title="장수 기록">
<div class="log-tabs">
<button
v-for="type in logTypes"
:key="type"
:class="{ active: activeLogTab === type }"
@click="activeLogTab = type"
>
{{ logLabels[type] }}
</button>
</div>
<div class="log-block">
<div class="log-title">{{ logLabels[activeLogTab] }}</div>
<SkeletonLines v-if="loading || logLoading[activeLogTab]" :lines="4" />
<!-- eslint-disable vue/no-v-html -->
<template v-else>
<div v-if="logs[activeLogTab].length === 0" class="empty">기록이 없습니다.</div>
<div
v-for="entry in logs[activeLogTab]"
:key="entry.id"
class="log-line"
v-html="entry.html"
/>
<button
v-if="logHasMore[activeLogTab]"
class="ghost log-more"
@click="loadLog(activeLogTab, logs[activeLogTab].at(-1)?.id)"
>
이전 로그 불러오기
</button>
</template>
<!-- eslint-enable vue/no-v-html -->
</div>
</PanelCard>
<PanelCard v-else title="장수 기록" subtitle="개인 기록 및 전투 로그">
<div class="log-grid">
<div v-for="type in logTypes" :key="type" class="log-block">
<div class="log-title">{{ logLabels[type] }}</div>
<SkeletonLines v-if="loading || logLoading[type]" :lines="3" />
<!-- eslint-disable vue/no-v-html -->
<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" />
<button
v-if="logHasMore[type]"
class="ghost log-more"
@click="loadLog(type, logs[type].at(-1)?.id)"
>
이전 로그 불러오기
</button>
</template>
<!-- eslint-enable vue/no-v-html -->
<dl>
<div>
<dt>통솔</dt>
<dd>{{ data.general.stats.leadership }}</dd>
</div>
</div>
</PanelCard>
<div>
<dt>무력</dt>
<dd>{{ data.general.stats.strength }}</dd>
</div>
<div>
<dt>지력</dt>
<dd>{{ data.general.stats.intelligence }}</dd>
</div>
<div>
<dt>소속</dt>
<dd>{{ data.nation?.name ?? '재야' }}</dd>
</div>
<div>
<dt>도시</dt>
<dd>{{ data.city?.name ?? '-' }}</dd>
</div>
<div>
<dt>/</dt>
<dd>{{ data.general.gold }} / {{ data.general.rice }}</dd>
</div>
<div>
<dt>병력</dt>
<dd>{{ data.general.crew }}</dd>
</div>
<div>
<dt>훈련/사기</dt>
<dd>{{ data.general.train }} / {{ data.general.atmos }}</dd>
</div>
<div>
<dt>경험/공헌</dt>
<dd>{{ data.general.experience }} / {{ data.general.dedication }}</dd>
</div>
</dl>
</div>
</div>
<div class="settings-column">
<div class="setting-line">
토너먼트
<label><input v-model.number="form.tnmt" type="radio" :value="0" />수동참여</label>
<label><input v-model.number="form.tnmt" type="radio" :value="1" />자동참여</label>
</div>
<div class="hint"> 개막직전 남는자리가 있을경우 랜덤하게 참여합니다.</div>
<label class="setting-line">
환약 사용
<select v-model.number="form.use_treatment">
<option :value="10">경상</option>
<option :value="21">중상</option>
<option :value="41">심각</option>
<option :value="61">위독</option>
<option :value="100">사용안함</option>
</select>
</label>
<div class="hint"> 부상을 입었을 환약을 사용하는 기준입니다.</div>
<label v-if="showAutoNationTurn" class="setting-line">
자동 사령턴 허용
<select v-model.number="form.use_auto_nation_turn">
<option :value="1">허용</option>
<option :value="0">허용 안함</option>
</select>
</label>
<label class="setting-line">
수비
<select v-model.number="form.defence_train">
<option :value="90">수비 (훈사90)</option>
<option :value="80">수비 (훈사80)</option>
<option :value="60">수비 (훈사60)</option>
<option :value="40">수비 (훈사40)</option>
<option :value="999">수비 안함 [훈련 -3, 사기 -6]</option>
</select>
</label>
<button
id="set_my_setting"
class="action-button"
type="button"
:hidden="!canSave"
@click="saveSettings"
>
설정저장
</button>
<div class="hint"> 설정저장은 이달중 {{ data?.settings.myset ?? 0 }} 남았습니다.</div>
<div v-if="penalties.length" class="penalties">
징계 목록(저장 갱신)
<div v-for="[key, value] in penalties" :key="key">{{ key }} : {{ value }}</div>
</div>
<div v-if="showVacation" class="action-line">
<br />
<button
class="action-button"
type="button"
@click="confirmMutation('휴가 기능을 신청할까요?', () => trpc.general.vacation.mutate())"
>
휴가 신청
</button>
</div>
<div v-if="actionAvailability.dieOnPrestart" class="action-line">
가오픈 기간 장수 삭제<br />
<button
class="action-button"
@click="confirmMutation('정말로 삭제하시겠습니까?', () => trpc.general.dieOnPrestart.mutate())"
>
장수 삭제
</button>
</div>
<div v-if="actionAvailability.buildNationCandidate" class="action-line">
서버 개시 이전 거병(2턴부터 건국 가능)<br />
<button
class="action-button"
@click="
confirmMutation('거병 이후 장수를 삭제할 수 없게됩니다. 거병하시겠습니까?', () =>
trpc.general.buildNationCandidate.mutate()
)
"
>
사전 거병
</button>
</div>
<div v-if="actionAvailability.instantRetreat" class="action-line">
거리 3칸 이내 아국 도시로 즉시 이동<br />
<button
class="action-button"
@click="
confirmMutation('아군 접경으로 이동할까요?', () => trpc.general.instantRetreat.mutate())
"
>
접경 귀환
</button>
</div>
<div class="screen-mode-row">
<span>500px/1000px 모드<br />(모바일 전용, 즉시 설정)</span>
<div class="button-group">
<label><input v-model="screenMode" type="radio" value="auto" />자동</label>
<label><input v-model="screenMode" type="radio" value="500px" />500px</label>
<label><input v-model="screenMode" type="radio" value="1000px" />1000px</label>
</div>
</div>
<div class="item-title">아이템 파기</div>
<div class="item-group">
<button
v-for="item in items"
:key="item.key"
type="button"
:disabled="!item.code"
@click="dropItem(item)"
>
{{ item.code ?? '-' }}
</button>
</div>
<label class="custom-css">
개인용 CSS <span>{{ cssSaving ? '(저장 중)' : '' }}</span>
<textarea id="custom_css" v-model="customCss" />
</label>
</div>
</section>
<section class="log-grid">
<article v-for="type in logTypes" :key="type" class="log-panel">
<h2 :style="{ color: logColors[type] }">{{ logLabels[type] }}</h2>
<div v-if="logLoading[type]" class="loading">불러오는 중...</div>
<div v-else>
<div v-for="entry in logs[type]" :key="entry.id" class="log-line" v-html="entry.html" />
<div v-if="logs[type].length === 0" class="empty">기록이 없습니다.</div>
<button
v-if="logHasMore[type]"
class="load-old"
type="button"
@click="loadLog(type, logs[type].at(-1)?.id)"
>
이전 로그 불러오기
</button>
</div>
</article>
</section>
</main>
</template>
<style scoped>
.my-page {
.legacy-page {
width: 100%;
max-width: 1000px;
min-width: 500px;
min-height: 100vh;
padding: 24px;
display: flex;
flex-direction: column;
gap: 24px;
transition: width 0.2s ease;
margin: 0 auto;
padding: 0;
color: #fff;
background-color: #111;
background-image: url('/image/game/back_walnut.jpg');
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-size: 14px;
line-height: 1.3;
}
.my-page.screen-500px {
.legacy-page.screen-500px {
max-width: 500px;
}
.my-page.screen-1000px {
.legacy-page.screen-1000px {
max-width: 1000px;
}
.page-header {
.title-row {
height: 54px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
align-content: flex-start;
align-items: flex-start;
justify-content: flex-start;
flex-wrap: wrap;
gap: 0 4px;
border: 1px solid #666;
background: transparent;
font-size: 14px;
}
.page-title {
font-size: 1.6rem;
.title-row > span {
flex-basis: 100%;
height: 18px;
letter-spacing: 0;
}
.legacy-button,
button,
select,
textarea {
border: 1px solid #777;
border-radius: 0;
color: #fff;
background: #6b6b6b;
font: inherit;
}
.legacy-button {
min-height: 34px;
padding: 5px 10px;
border-color: #2d5d7f;
border-radius: 4px;
background: #315f86;
color: #fff;
font-weight: 700;
text-decoration: none;
letter-spacing: 0;
}
.page-subtitle {
color: rgba(232, 221, 196, 0.7);
margin-top: 6px;
}
.header-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.layout-grid {
display: grid;
grid-template-columns: minmax(0, 320px) minmax(0, 1fr);
gap: 18px;
}
.stack {
display: flex;
flex-direction: column;
gap: 18px;
}
.action-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
}
.action-btn {
border: 1px solid rgba(201, 164, 90, 0.5);
background: rgba(12, 12, 12, 0.6);
color: inherit;
padding: 8px 12px;
font-size: 0.85rem;
button {
cursor: pointer;
}
button:disabled {
cursor: default;
opacity: 0.45;
}
.status-row,
.error-row {
padding: 4px 8px;
text-align: center;
}
.action-btn.link {
display: inline-flex;
align-items: center;
justify-content: center;
.error-row {
color: #ff7777;
border: 1px solid #a33;
}
.item-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 8px;
}
.item-btn {
border: 1px solid rgba(201, 164, 90, 0.4);
background: rgba(12, 12, 12, 0.6);
color: inherit;
padding: 8px 10px;
font-size: 0.82rem;
cursor: pointer;
text-align: left;
}
.item-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.top-grid,
.log-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.log-block {
border: 1px solid rgba(201, 164, 90, 0.3);
padding: 8px;
background: rgba(12, 12, 12, 0.6);
min-height: 160px;
.general-column,
.settings-column,
.log-panel {
border: 1px solid #666;
background-image: url('/image/game/back_walnut.jpg');
}
.log-title {
font-weight: 600;
margin-bottom: 6px;
font-size: 0.9rem;
.section-title,
.log-panel h2 {
min-height: 34px;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid #666;
background-color: #14241b;
background-image: url('/image/game/back_green.jpg');
font-size: 1.25em;
font-weight: 500;
}
.log-line {
padding: 4px 0;
border-bottom: 1px dashed rgba(201, 164, 90, 0.2);
.sky {
color: skyblue;
}
.log-line:last-child {
border-bottom: none;
.general-table {
display: grid;
grid-template-columns: 150px 1fr;
padding: 0;
background-color: #172a52;
background-image: url('/image/game/back_blue.jpg');
}
.log-more {
.portrait-cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 10px;
border-right: 1px solid #777;
}
.portrait-cell img {
width: 64px;
height: 64px;
object-fit: cover;
}
dl {
margin: 0;
}
dl > div {
display: grid;
grid-template-columns: 80px 1fr;
border-bottom: 1px solid #777;
}
dt,
dd {
margin: 0;
padding: 2px 5px;
border-right: 1px solid #777;
}
dt {
color: #aaa;
}
.settings-column {
padding: 10px 18px;
}
.setting-line {
display: block;
margin-top: 5px;
}
.hint {
margin: 0 0 13px;
color: orange;
}
.action-button {
width: 160px;
height: 30px;
margin: 4px 0;
background: #225500;
}
.action-line {
margin: 12px 0;
}
.penalties {
margin: 12px 0;
color: #f66;
}
.screen-mode-row {
display: grid;
grid-template-columns: 160px 1fr;
align-items: center;
margin: 14px 0;
}
.button-group {
display: flex;
}
.button-group label {
padding: 5px 8px;
border: 1px solid #666;
background: #26384d;
}
.button-group input {
margin-right: 4px;
}
.item-title {
margin-top: 12px;
}
.item-group {
display: grid;
grid-template-columns: repeat(4, 1fr);
margin: 5px 0 14px;
}
.item-group button {
min-height: 30px;
}
.custom-css {
display: block;
}
.custom-css textarea {
display: block;
width: 420px;
max-width: 100%;
height: 150px;
color: #fff;
background: #000;
}
.log-panel {
min-height: 180px;
}
.log-panel h2 {
color: orange;
}
.log-line,
.empty,
.loading {
padding: 2px 8px;
}
.load-old {
width: 100%;
min-height: 32px;
margin-top: 8px;
}
.log-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.log-tabs button {
border: 1px solid rgba(201, 164, 90, 0.4);
background: transparent;
color: inherit;
padding: 6px 10px;
font-size: 0.8rem;
cursor: pointer;
}
.log-tabs button.active {
background: rgba(201, 164, 90, 0.2);
}
.ghost {
border: 1px solid rgba(201, 164, 90, 0.5);
background: transparent;
color: inherit;
padding: 6px 10px;
font-size: 0.8rem;
cursor: pointer;
}
.empty {
color: rgba(232, 221, 196, 0.6);
font-size: 0.85rem;
}
.error {
color: #f08a5d;
font-size: 0.9rem;
}
@media (max-width: 1024px) {
.layout-grid {
grid-template-columns: 1fr;
@media (max-width: 991px) {
.legacy-page {
width: 500px;
}
.top-grid,
.log-grid {
grid-template-columns: 1fr;
}
+225 -313
View File
@@ -1,349 +1,261 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import { trpc } from '../utils/trpc';
import { formatOfficerLevelText } from '../utils/nationFormat';
import { trpc } from '../utils/trpc';
type GeneralListResponse = Awaited<ReturnType<typeof trpc.nation.getGeneralList.query>>;
type Result = Awaited<ReturnType<typeof trpc.nation.getGeneralList.query>>;
type General = Result['generals'][number];
type Sort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15;
type GeneralEntry = GeneralListResponse['generals'][number];
type SortKey =
| 1
| 2
| 3
| 4
| 5
| 6
| 7
| 8
| 9
| 10
| 11
| 12
| 13
| 14
| 15;
const sortOptions: Array<{ key: SortKey; label: string }> = [
{ key: 1, label: '관직' },
{ key: 2, label: '공헌' },
{ key: 3, label: '경험' },
{ key: 4, label: '통솔' },
{ key: 5, label: '무력' },
{ key: 6, label: '지력' },
{ key: 7, label: '자금' },
{ key: 8, label: '군량' },
{ key: 9, label: '병사' },
{ key: 10, label: '벌점' },
{ key: 11, label: '성격' },
{ key: 12, label: '내특' },
{ key: 13, label: '전특' },
{ key: 14, label: '사관' },
{ key: 15, label: 'NPC' },
const data = ref<Result | null>(null);
const error = ref('');
const loading = ref(false);
const sort = ref<Sort>(1);
const sortOptions = [
'관직',
'계급',
'명성',
'통솔',
'무력',
'지력',
'자금',
'군량',
'병사',
'벌점',
'성격',
'내특',
'전특',
'사관',
'NPC',
];
const loading = ref(false);
const error = ref<string | null>(null);
const data = ref<GeneralListResponse | null>(null);
const sortKey = ref<SortKey>(1);
const filterText = ref('');
const resolveErrorMessage = (value: unknown): string => {
if (value instanceof Error) {
return value.message;
}
if (typeof value === 'string') {
return value;
}
return 'unknown_error';
};
const loadGenerals = async () => {
if (loading.value) {
return;
}
const load = async () => {
if (loading.value) return;
loading.value = true;
error.value = null;
error.value = '';
try {
data.value = await trpc.nation.getGeneralList.query();
} catch (err) {
error.value = resolveErrorMessage(err);
} catch (cause) {
error.value = cause instanceof Error ? cause.message : '세력 장수를 불러오지 못했습니다.';
} finally {
loading.value = false;
}
};
const sortGenerals = (list: GeneralEntry[]): GeneralEntry[] => {
const key = sortKey.value;
const sorted = [...list].sort((lhs, rhs) => {
switch (key) {
case 1:
return rhs.officerLevel - lhs.officerLevel;
case 2:
return rhs.dedication - lhs.dedication;
case 3:
return rhs.experience - lhs.experience;
case 4:
return rhs.stats.leadership - lhs.stats.leadership;
case 5:
return rhs.stats.strength - lhs.stats.strength;
case 6:
return rhs.stats.intelligence - lhs.stats.intelligence;
case 7:
return rhs.gold - lhs.gold;
case 8:
return rhs.rice - lhs.rice;
case 9:
return rhs.crew - lhs.crew;
case 10:
return 0;
case 11:
return (lhs.personality?.name ?? '').localeCompare(rhs.personality?.name ?? '');
case 12:
return (lhs.specialDomestic?.name ?? '').localeCompare(rhs.specialDomestic?.name ?? '');
case 13:
return (lhs.specialWar?.name ?? '').localeCompare(rhs.specialWar?.name ?? '');
case 14:
return rhs.belong - lhs.belong;
case 15:
return rhs.npcState - lhs.npcState;
default:
return 0;
}
});
const sortedGenerals = computed(() =>
[...(data.value?.generals ?? [])].sort((left, right) => {
const key = sort.value;
if (key === 1) return right.officerLevel - left.officerLevel || left.id - right.id;
if (key === 2) return right.dedicationLevel - left.dedicationLevel || left.id - right.id;
if (key === 3) return right.experienceLevel - left.experienceLevel || left.id - right.id;
if (key === 4) return right.stats.leadership - left.stats.leadership || left.id - right.id;
if (key === 5) return right.stats.strength - left.stats.strength || left.id - right.id;
if (key === 6) return right.stats.intelligence - left.stats.intelligence || left.id - right.id;
if (key === 7) return right.gold - left.gold || left.id - right.id;
if (key === 8) return right.rice - left.rice || left.id - right.id;
if (key === 9) return (right.detail?.crew ?? -1) - (left.detail?.crew ?? -1) || left.id - right.id;
if (key === 10) return right.refreshScoreTotal - left.refreshScoreTotal || left.id - right.id;
if (key === 11) return (left.personality?.name ?? '').localeCompare(right.personality?.name ?? '');
if (key === 12) return (left.specialDomestic?.name ?? '').localeCompare(right.specialDomestic?.name ?? '');
if (key === 13) return (left.specialWar?.name ?? '').localeCompare(right.specialWar?.name ?? '');
if (key === 14) return right.belong - left.belong || left.id - right.id;
return right.npcState - left.npcState || left.id - right.id;
})
);
if (key === 11 || key === 12 || key === 13) {
return sorted;
}
return sorted;
const imageUrl = (general: General): string => {
const picture = general.picture || 'default.jpg';
return general.imageServer ? `${import.meta.env.BASE_URL}d_pic/${picture}` : `/image/icons/${picture}`;
};
const specialText = (general: General): string =>
`${general.specialDomestic?.name ?? '-'} / ${general.specialWar?.name ?? '-'}`;
const filteredGenerals = computed(() => {
const list = data.value?.generals ?? [];
const keyword = filterText.value.trim().toLowerCase();
const filtered = keyword
? list.filter((general) => {
return (
general.name.toLowerCase().includes(keyword) ||
(general.cityName ?? '').toLowerCase().includes(keyword) ||
(general.officerCityName ?? '').toLowerCase().includes(keyword)
);
})
: list;
return sortGenerals(filtered);
});
const nationLevel = computed(() => data.value?.nation.level ?? 0);
const formatSpecial = (general: GeneralEntry): string => {
const domestic = general.specialDomestic?.name ?? '-';
const war = general.specialWar?.name ?? '-';
return `${domestic} / ${war}`;
};
onMounted(() => {
void loadGenerals();
});
onMounted(load);
</script>
<template>
<main class="nation-page">
<header class="page-header">
<div>
<h1 class="page-title">세력 장수</h1>
<p class="page-subtitle">세력 장수 현황 정렬</p>
</div>
<div class="header-actions">
<RouterLink class="ghost" to="/">메인</RouterLink>
<RouterLink class="ghost" to="/nation/cities">세력 도시</RouterLink>
<RouterLink class="ghost" to="/nation/personnel">인사부</RouterLink>
<button class="ghost" @click="loadGenerals">새로고침</button>
</div>
<main class="general-page legacy-bg0">
<header class="top-bar">
<strong>세력 장수</strong>
<span class="toolbar">
<RouterLink to="/">돌아가기</RouterLink>
<button type="button" :disabled="loading" @click="load">새로고침</button>
</span>
</header>
<div v-if="error" class="error">{{ error }}</div>
<PanelCard title="세력 장수 목록" subtitle="국가 소속 장수들을 확인합니다.">
<template #actions>
<div class="toolbar-actions">
<select v-model.number="sortKey" class="select-input">
<option v-for="option in sortOptions" :key="option.key" :value="option.key">
{{ option.label }}
</option>
</select>
<input v-model="filterText" class="filter-input" placeholder="이름/도시 검색" />
</div>
</template>
<div class="list-meta"> {{ filteredGenerals.length }}</div>
<SkeletonLines v-if="loading" :lines="6" />
<div v-else class="table-scroll">
<table class="nation-table">
<thead>
<tr>
<th>이름</th>
<th>관직</th>
<th>공헌</th>
<th>경험</th>
<th>통솔</th>
<th>무력</th>
<th>지력</th>
<th>자금</th>
<th>군량</th>
<th>병사</th>
<th>성격</th>
<th>특기</th>
<th>사관</th>
<th>현재 도시</th>
<th>관직 도시</th>
</tr>
</thead>
<tbody>
<tr v-for="general in filteredGenerals" :key="general.id">
<td>
<span v-if="general.npcState > 0" class="npc-tag">NPC</span>
{{ general.name }}
</td>
<td>{{ formatOfficerLevelText(general.officerLevel, nationLevel) }}</td>
<td>{{ general.dedication }}</td>
<td>{{ general.experience }}</td>
<td>{{ general.stats.leadership }}</td>
<td>{{ general.stats.strength }}</td>
<td>{{ general.stats.intelligence }}</td>
<td>{{ general.gold }}</td>
<td>{{ general.rice }}</td>
<td>{{ general.crew }}</td>
<td>{{ general.personality?.name ?? '-' }}</td>
<td>{{ formatSpecial(general) }}</td>
<td>{{ general.belong > 0 ? general.belong : '-' }}</td>
<td>{{ general.cityName ?? '-' }}</td>
<td>{{ general.officerCityName ?? '-' }}</td>
</tr>
</tbody>
</table>
</div>
</PanelCard>
<section class="sort-bar">
정렬순서 :
<select v-model.number="sort" aria-label="세력 장수 정렬">
<option v-for="(label, index) in sortOptions" :key="label" :value="index + 1">{{ label }}</option>
</select>
<button type="button">정렬하기</button>
<span v-if="data" class="permission">열람 등급 {{ data.viewer.permission }}</span>
</section>
<p v-if="error" class="error" role="alert">{{ error }}</p>
<p v-else-if="loading" class="state">불러오는 중...</p>
<div v-else class="grid-scroll">
<table id="nation-general-list" class="general-table">
<thead>
<tr>
<th class="icon-column">아이콘</th>
<th class="name-column">장수</th>
<th>관직</th>
<th>||</th>
<th>명성/계급</th>
<th>/</th>
<th>도시</th>
<th>부대</th>
<th>병사</th>
<th>성격</th>
<th>특기</th>
<th>사관</th>
<th>벌점</th>
</tr>
</thead>
<tbody>
<tr v-for="general in sortedGenerals" :key="general.id">
<td class="icon"><img :src="imageUrl(general)" width="64" height="64" alt="" /></td>
<td class="name" :class="`npc-${general.npcState}`">
<RouterLink :to="`/battle-center?generalId=${general.id}`">{{ general.name }}</RouterLink>
</td>
<td>{{ formatOfficerLevelText(general.officerLevel, data?.nation.level) }}</td>
<td>
<span :class="{ wounded: general.injury > 0 }">{{ general.stats.leadership }}</span
><span v-if="general.leadershipBonus" class="bonus">+{{ general.leadershipBonus }}</span>
| <span :class="{ wounded: general.injury > 0 }">{{ general.stats.strength }}</span> |
<span :class="{ wounded: general.injury > 0 }">{{ general.stats.intelligence }}</span>
</td>
<td>Lv {{ general.experienceLevel }}<br />{{ general.dedicationLevelText }}</td>
<td>{{ general.gold.toLocaleString() }}<br />{{ general.rice.toLocaleString() }}</td>
<td>{{ general.detail?.cityName ?? '?' }}</td>
<td>{{ general.detail?.troopName ?? (general.detail ? '-' : '?') }}</td>
<td>{{ general.detail?.crew.toLocaleString() ?? '?' }}</td>
<td :title="general.personality?.info ?? ''">{{ general.personality?.name ?? '-' }}</td>
<td
:title="[general.specialDomestic?.info, general.specialWar?.info].filter(Boolean).join('\\n')"
>
{{ specialText(general) }}
</td>
<td>{{ general.belong }}</td>
<td>{{ general.refreshScoreTotal }}</td>
</tr>
</tbody>
</table>
</div>
<footer><RouterLink to="/">돌아가기</RouterLink></footer>
</main>
</template>
<style scoped>
.nation-page {
.general-page {
width: 1000px;
min-height: 100vh;
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
margin: 0 auto;
color: #fff;
font-size: 14px;
}
.page-header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid rgba(201, 164, 90, 0.4);
padding-bottom: 12px;
}
.page-title {
font-size: 1.6rem;
font-weight: 600;
}
.page-subtitle {
font-size: 0.85rem;
color: rgba(232, 221, 196, 0.7);
}
.header-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.ghost {
border: 1px solid rgba(201, 164, 90, 0.4);
padding: 6px 12px;
font-size: 0.8rem;
cursor: pointer;
text-decoration: none;
color: inherit;
background: rgba(16, 16, 16, 0.6);
}
.top-bar,
.sort-bar,
footer,
.state,
.error {
color: #f5b7b1;
font-size: 0.85rem;
border: 1px solid #777;
padding: 4px;
text-align: center;
}
.toolbar-actions {
.top-bar {
position: relative;
min-height: 39px;
display: flex;
align-items: center;
gap: 8px;
}
.select-input {
border: 1px solid rgba(201, 164, 90, 0.4);
background: rgba(16, 16, 16, 0.8);
color: rgba(232, 221, 196, 0.9);
padding: 6px 8px;
font-size: 0.75rem;
}
.filter-input {
border: 1px solid rgba(201, 164, 90, 0.4);
background: rgba(16, 16, 16, 0.8);
color: rgba(232, 221, 196, 0.9);
padding: 6px 8px;
font-size: 0.75rem;
}
.list-meta {
margin-bottom: 8px;
font-size: 0.75rem;
color: rgba(232, 221, 196, 0.6);
}
.table-scroll {
overflow-x: auto;
max-height: 70vh;
overflow-y: auto;
}
.nation-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
.nation-table th,
.nation-table td {
padding: 6px 8px;
border-bottom: 1px solid rgba(201, 164, 90, 0.2);
text-align: left;
white-space: nowrap;
}
.nation-table thead th {
font-size: 0.7rem;
color: rgba(232, 221, 196, 0.6);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.npc-tag {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.6rem;
padding: 2px 4px;
}
.toolbar {
position: absolute;
right: 6px;
display: flex;
gap: 8px;
}
button,
select {
border: 1px solid #888;
border-radius: 2px;
background: #222;
color: #fff;
padding: 1px 6px;
}
.permission {
float: right;
margin-right: 6px;
border: 1px solid rgba(201, 164, 90, 0.4);
color: rgba(232, 221, 196, 0.8);
color: #ccc;
}
.grid-scroll {
width: 100%;
min-height: calc(100vh - 116px);
overflow: auto;
}
.general-table {
width: 100%;
min-width: 1000px;
border-collapse: collapse;
table-layout: fixed;
}
.general-table th,
.general-table td {
border: 1px solid #777;
padding: 2px 3px;
text-align: center;
overflow-wrap: anywhere;
}
.general-table th {
height: 30px;
background: #14241b url('/image/game/back_green.jpg');
font-weight: 400;
}
.general-table tbody tr {
height: 68px;
background: rgba(0, 0, 0, 0.18);
}
.icon-column {
width: 70px;
}
.name-column {
width: 88px;
}
.icon {
padding: 1px !important;
}
.icon img {
display: block;
margin: auto;
object-fit: cover;
}
.name a {
color: inherit;
}
.npc-1 {
color: #0ff;
}
.npc-2,
.npc-3,
.npc-4,
.npc-5 {
color: #aaa;
}
.wounded {
color: red;
}
.bonus {
color: cyan;
}
.error {
color: #ff7373;
}
footer {
min-height: 25px;
}
@media (max-width: 1000px) {
.general-page {
margin: 0;
}
}
</style>
@@ -0,0 +1,250 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { trpc } from '../utils/trpc';
type Result = Awaited<ReturnType<typeof trpc.nation.getSecretGeneralList.query>>;
type General = Result['generals'][number];
type Sort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
const data = ref<Result | null>(null);
const error = ref('');
const loading = ref(false);
const sort = ref<Sort>(7);
const sortOptions = ['자금', '군량', '도시', '병종', '병사', '삭제턴', '턴', '부대'];
const load = async () => {
loading.value = true;
error.value = '';
try {
data.value = await trpc.nation.getSecretGeneralList.query();
} catch (cause) {
error.value = cause instanceof Error ? cause.message : '암행부를 불러오지 못했습니다.';
} finally {
loading.value = false;
}
};
const generals = computed(() =>
[...(data.value?.generals ?? [])].sort((left, right) => {
const leftDetail = left.detail;
const rightDetail = right.detail;
if (!leftDetail || !rightDetail) return left.id - right.id;
if (sort.value === 1) return right.gold - left.gold || left.id - right.id;
if (sort.value === 2) return right.rice - left.rice || left.id - right.id;
if (sort.value === 3) return leftDetail.cityId - rightDetail.cityId || left.id - right.id;
if (sort.value === 4) return rightDetail.crewTypeId - leftDetail.crewTypeId || left.id - right.id;
if (sort.value === 5) return rightDetail.crew - leftDetail.crew || left.id - right.id;
if (sort.value === 6) return leftDetail.killTurn - rightDetail.killTurn || left.id - right.id;
if (sort.value === 7) return leftDetail.turnTime.localeCompare(rightDetail.turnTime) || left.id - right.id;
return rightDetail.troopId - leftDetail.troopId || left.id - right.id;
})
);
const turnTime = (general: General): string => {
const value = general.detail?.turnTime;
return value ? value.slice(11, 16) : '-';
};
onMounted(load);
</script>
<template>
<main class="secret-page">
<table class="layout-table legacy-bg0 title">
<tbody>
<tr>
<td> <br /><RouterLink to="/"> 닫기</RouterLink></td>
</tr>
<tr>
<td>
정렬순서 :
<select v-model.number="sort" aria-label="암행부 정렬">
<option v-for="(label, index) in sortOptions" :key="label" :value="index + 1">
{{ label }}
</option>
</select>
<button type="button">정렬하기</button>
<button type="button" :disabled="loading" @click="load">새로고침</button>
</td>
</tr>
</tbody>
</table>
<p v-if="error" class="error legacy-bg0" role="alert">{{ error }}</p>
<p v-else-if="loading" class="state legacy-bg0">불러오는 중...</p>
<template v-else-if="data">
<table class="layout-table summary legacy-bg0">
<tbody>
<tr>
<th>전체 </th>
<td>{{ data.summary.gold.toLocaleString() }}</td>
<th>전체 </th>
<td>{{ data.summary.rice.toLocaleString() }}</td>
<th>평균 </th>
<td>{{ data.summary.averageGold.toLocaleString(undefined, { maximumFractionDigits: 2 }) }}</td>
<th>평균 </th>
<td>{{ data.summary.averageRice.toLocaleString(undefined, { maximumFractionDigits: 2 }) }}</td>
</tr>
<tr>
<th>전체 병력/장수</th>
<td>{{ data.summary.crew.toLocaleString() }}/{{ data.summary.generalCount }}</td>
<th>훈사 90 병력/장수</th>
<td>
{{ data.summary.readiness[90].crew.toLocaleString() }}/{{
data.summary.readiness[90].generals
}}
</td>
<th>훈사 80 병력/장수</th>
<td>
{{ data.summary.readiness[80].crew.toLocaleString() }}/{{
data.summary.readiness[80].generals
}}
</td>
<th>훈사 60 병력/장수</th>
<td>
{{ data.summary.readiness[60].crew.toLocaleString() }}/{{
data.summary.readiness[60].generals
}}
</td>
</tr>
</tbody>
</table>
<table id="secret-general-list" class="layout-table general-list legacy-bg0">
<thead>
<tr>
<th class="name"> </th>
<th class="stat">통무지</th>
<th class="troop"> </th>
<th> </th>
<th> </th>
<th>도시</th>
<th class="mode"></th>
<th> </th>
<th> </th>
<th>훈련</th>
<th>사기</th>
<th class="turns"> </th>
<th>삭턴</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="general in generals" :key="general.id">
<td>{{ general.name }}<br />Lv {{ general.experienceLevel }}</td>
<td>
<span :class="{ wounded: general.injury > 0 }">{{ general.stats.leadership }}</span
><span v-if="general.leadershipBonus" class="bonus">+{{ general.leadershipBonus }}</span
><span :class="{ wounded: general.injury > 0 }">{{ general.stats.strength }}</span
><span :class="{ wounded: general.injury > 0 }">{{ general.stats.intelligence }}</span>
</td>
<td>{{ general.detail?.troopName ?? '-' }}</td>
<td>{{ general.gold }}</td>
<td>{{ general.rice }}</td>
<td>{{ general.detail?.cityName ?? '-' }}</td>
<td>{{ general.defenceTrainText }}</td>
<td>{{ general.detail?.crewTypeId ?? '-' }}</td>
<td>{{ general.detail?.crew ?? '-' }}</td>
<td>{{ general.detail?.train ?? '-' }}</td>
<td>{{ general.detail?.atmos ?? '-' }}</td>
<td class="turn-list">
<template v-if="general.npcState >= 2">NPC 장수</template>
<template v-else>
<div
v-for="(command, index) in general.detail?.reservedCommands ?? []"
:key="`${general.id}-${index}`"
>
{{ index + 1 }} : {{ command }}
</div>
</template>
</td>
<td>{{ general.detail?.killTurn ?? '-' }}</td>
<td>{{ turnTime(general) }}</td>
</tr>
</tbody>
</table>
</template>
<table class="layout-table legacy-bg0 footer">
<tbody>
<tr>
<td><RouterLink to="/"> 닫기</RouterLink></td>
</tr>
</tbody>
</table>
</main>
</template>
<style scoped>
.secret-page {
width: 1000px;
margin: 0 auto;
font-size: 14px;
color: #fff;
}
.layout-table {
width: 1000px;
border-collapse: collapse;
}
.layout-table td,
.layout-table th,
.state,
.error {
border: 1px solid #777;
padding: 3px;
font-weight: 400;
text-align: center;
}
.title {
text-align: center;
}
.title button,
.title select {
border: 1px solid #888;
border-radius: 2px;
background: #222;
color: #fff;
padding: 1px 6px;
}
.summary {
margin: 5px auto;
}
.summary th,
.general-list th {
background: #14241b url('/image/game/back_green.jpg');
}
.summary th {
width: 120px;
}
.general-list {
table-layout: fixed;
}
.general-list .name,
.general-list .stat,
.general-list .troop {
width: 98px;
}
.general-list .mode {
width: 28px;
}
.general-list .turns {
width: 213px;
}
.general-list tbody tr {
height: 50px;
}
.turn-list {
text-align: left !important;
font-size: 11px;
}
.wounded {
color: red;
}
.bonus {
color: cyan;
}
.error {
color: #ff7373;
}
.footer {
margin-top: 5px;
}
@media (max-width: 1000px) {
.secret-page {
margin: 0;
}
}
</style>
@@ -108,6 +108,7 @@ onMounted(() => {
<RouterLink v-else-if="session.needsGeneral" class="ghost" to="/join">장수 생성/빙의</RouterLink>
<RouterLink v-else class="ghost" to="/">메인으로</RouterLink>
<RouterLink class="ghost" to="/npc-list">빙의일람</RouterLink>
<RouterLink class="ghost" to="/traffic">접속량정보</RouterLink>
<button class="ghost" @click="refreshPublicData">새로고침</button>
</div>
</header>
+343
View File
@@ -0,0 +1,343 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { trpc } from '../utils/trpc';
type TrafficData = Awaited<ReturnType<typeof trpc.public.getTraffic.query>>;
const data = ref<TrafficData | null>(null);
const loading = ref(false);
const errorMessage = ref('');
const getErrorMessage = (error: unknown): string => {
if (error instanceof Error) {
return error.message;
}
return typeof error === 'string' ? error : '요청을 처리하지 못했습니다.';
};
const load = async () => {
if (loading.value) {
return;
}
loading.value = true;
errorMessage.value = '';
try {
data.value = await trpc.public.getTraffic.query();
} catch (error) {
// Preserve the last successful graph if a later refresh fails.
errorMessage.value = getErrorMessage(error);
} finally {
loading.value = false;
}
};
const refreshRows = computed(() =>
(data.value?.history ?? []).map((entry) => ({
...entry,
value: entry.refresh,
width: Math.round((entry.refresh / Math.max(1, data.value?.maxRefresh ?? 1)) * 1_000) / 10,
}))
);
const onlineRows = computed(() =>
(data.value?.history ?? []).map((entry) => ({
...entry,
value: entry.online,
width: Math.round((entry.online / Math.max(1, data.value?.maxOnline ?? 1)) * 1_000) / 10,
}))
);
const timeLabel = (value: string): string => {
const timePart = value.includes('T') ? value.split('T')[1] : value.slice(11);
return (timePart ?? '').slice(0, 5);
};
const trafficColor = (percentage: number): string => {
const channel = (value: number): string =>
Math.floor((Math.max(0, Math.min(100, value)) * 255) / 100)
.toString(16)
.padStart(2, '0');
return `#${channel(percentage)}00${channel(100 - percentage)}`;
};
onMounted(() => {
void load();
});
</script>
<template>
<main id="traffic-container" class="traffic-page">
<table class="legacy-table title-table legacy-bg0">
<tbody>
<tr>
<td>
<br />
<RouterLink class="legacy-close" to="/">돌아가기</RouterLink>
</td>
</tr>
</tbody>
</table>
<div v-if="errorMessage" class="traffic-error" role="alert">{{ errorMessage }}</div>
<div v-if="loading && !data" class="traffic-loading">불러오는 중...</div>
<section v-if="data" class="chart-layout">
<table class="legacy-table chart-table legacy-bg0">
<thead>
<tr>
<th colspan="4" class="legacy-bg2 chart-title"> </th>
</tr>
</thead>
<tbody>
<tr v-for="(entry, index) in refreshRows" :key="`${entry.date}-${index}`" class="chart-row">
<td class="period">{{ entry.year }} {{ entry.month }}</td>
<td class="time legacy-bg2">{{ timeLabel(entry.date) }}</td>
<td class="separator legacy-bg1"></td>
<td class="bar-cell">
<div
v-if="entry.width > 0"
class="big-bar"
:style="{ width: `${entry.width}%`, backgroundColor: trafficColor(entry.width) }"
>
<span v-if="entry.width >= 10">{{ entry.value }}</span>
</div>
<span v-if="entry.width < 10" class="out-bar">{{ entry.value }}</span>
</td>
</tr>
<tr><td colspan="4" class="legacy-bg1 spacer"></td></tr>
<tr><td colspan="4" class="record">최고기록: {{ data.maxRefresh }}</td></tr>
</tbody>
</table>
<table class="legacy-table chart-table legacy-bg0">
<thead>
<tr>
<th colspan="4" class="legacy-bg2 chart-title"> </th>
</tr>
</thead>
<tbody>
<tr v-for="(entry, index) in onlineRows" :key="`${entry.date}-${index}`" class="chart-row">
<td class="period">{{ entry.year }} {{ entry.month }}</td>
<td class="time legacy-bg2">{{ timeLabel(entry.date) }}</td>
<td class="separator legacy-bg1"></td>
<td class="bar-cell">
<div
v-if="entry.width > 0"
class="big-bar"
:style="{ width: `${entry.width}%`, backgroundColor: trafficColor(entry.width) }"
>
<span v-if="entry.width >= 10">{{ entry.value }}</span>
</div>
<span v-if="entry.width < 10" class="out-bar">{{ entry.value }}</span>
</td>
</tr>
<tr><td colspan="4" class="legacy-bg1 spacer"></td></tr>
<tr><td colspan="4" class="record">최고기록: {{ data.maxOnline }}</td></tr>
</tbody>
</table>
</section>
<table v-if="data" class="legacy-table suspect-table legacy-bg0">
<thead>
<tr>
<th colspan="3" class="legacy-bg2 chart-title"> (순간과도갱신)</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in data.suspects" :key="entry.generalId ?? 'total'">
<td class="suspect-name">{{ entry.name }}</td>
<td class="suspect-score">{{ entry.refreshScoreTotal }}({{ entry.refresh }})</td>
<td class="little-bar-cell">
<div
v-if="entry.refresh > 0"
class="little-bar"
:style="{
width: `${Math.round((entry.refresh / Math.max(1, data.suspects[0]?.refresh ?? 1)) * 1_000) / 10}%`,
backgroundColor: trafficColor(
Math.round((entry.refresh / Math.max(1, data.suspects[0]?.refresh ?? 1)) * 1_000) / 10
),
}"
></div>
</td>
</tr>
</tbody>
</table>
<table class="legacy-table footer-table legacy-bg0">
<tbody>
<tr><td><RouterLink class="legacy-close" to="/">돌아가기</RouterLink></td></tr>
<tr><td class="banner">SAMMO</td></tr>
</tbody>
</table>
</main>
</template>
<style scoped>
.traffic-page {
width: 1016px;
min-width: 1016px;
min-height: 100vh;
margin: 0 auto;
padding: 0;
color: #fff;
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-size: 14px;
line-height: normal;
}
.legacy-table {
border-collapse: collapse;
padding: 0;
color: #fff;
font-size: 14px;
}
.legacy-table td,
.legacy-table th {
border: 1px solid gray;
padding: 0;
text-align: center;
font-weight: 400;
}
.legacy-bg0 {
background-color: #302016;
background-image: url('/image/game/back_walnut.jpg');
}
.legacy-bg1 {
background-color: #423226;
background-image: url('/image/game/back_sandal.jpg');
}
.legacy-bg2 {
background-color: #14241b;
background-image: url('/image/game/back_green.jpg');
}
.title-table,
.footer-table {
width: 1000px;
margin: 0 auto;
}
.title-table {
margin-bottom: 18px;
}
.title-table td {
height: 54px;
}
.chart-layout {
width: 1016px;
display: flex;
gap: 26px;
align-items: flex-start;
box-sizing: border-box;
padding: 0 12px;
}
.chart-table {
width: 483px;
table-layout: fixed;
}
.chart-title {
height: 34px;
font-size: 24px;
}
.chart-row {
height: 30px;
}
.period {
width: 100px;
}
.time {
width: 60px;
}
.separator {
width: 2px;
}
.bar-cell {
width: 320px;
text-align: left !important;
white-space: nowrap;
}
.big-bar {
float: left;
position: relative;
height: 30px;
}
.big-bar span {
float: right;
padding-right: 1ch;
line-height: 30px;
}
.out-bar {
line-height: 30px;
margin-left: 1ch;
}
.spacer {
height: 5px;
}
.record {
height: 30px;
}
.suspect-table {
margin: 18px auto;
}
.suspect-name,
.suspect-score {
width: 98px;
}
.little-bar-cell {
width: 798px;
text-align: left !important;
}
.little-bar {
height: 17px;
}
.footer-table {
margin-top: 18px;
}
.banner {
height: 24px;
}
.legacy-close {
color: #fff;
text-decoration: underline;
}
.traffic-error,
.traffic-loading {
width: 1000px;
margin: -12px auto 12px;
border: 1px solid gray;
padding: 6px;
text-align: center;
background: #302016;
}
.traffic-error {
color: #ff8080;
}
</style>