Reapply "Exclude concurrent public traffic work"
This reverts commit fa87f5565c.
This commit is contained in:
@@ -42,14 +42,6 @@ type NationCountRow = {
|
|||||||
|
|
||||||
type NpcListSort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
|
type NpcListSort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
|
||||||
|
|
||||||
type TrafficHistoryItem = {
|
|
||||||
year: number;
|
|
||||||
month: number;
|
|
||||||
refresh: number;
|
|
||||||
online: number;
|
|
||||||
date: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const PUBLIC_CACHE_TTL_SECONDS = 600;
|
const PUBLIC_CACHE_TTL_SECONDS = 600;
|
||||||
|
|
||||||
const buildPublicCacheKey = (ctx: GameApiContext, key: string): string =>
|
const buildPublicCacheKey = (ctx: GameApiContext, key: string): string =>
|
||||||
@@ -171,26 +163,6 @@ const readFiniteMetaNumber = (meta: Record<string, unknown>, key: string): numbe
|
|||||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseTrafficHistory = (value: unknown): TrafficHistoryItem[] => {
|
|
||||||
if (!Array.isArray(value)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: TrafficHistoryItem[] = [];
|
|
||||||
for (const item of value) {
|
|
||||||
const row = asRecord(item);
|
|
||||||
const year = readFiniteMetaNumber(row, 'year');
|
|
||||||
const month = readFiniteMetaNumber(row, 'month');
|
|
||||||
const refresh = readFiniteMetaNumber(row, 'refresh');
|
|
||||||
const online = readFiniteMetaNumber(row, 'online');
|
|
||||||
const date = typeof row.date === 'string' ? row.date : '';
|
|
||||||
if (year > 0 && month > 0 && date) {
|
|
||||||
result.push({ year, month, refresh, online, date });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
const compareString = (left: string, right: string): number => {
|
const compareString = (left: string, right: string): number => {
|
||||||
if (left === right) {
|
if (left === right) {
|
||||||
return 0;
|
return 0;
|
||||||
@@ -250,95 +222,6 @@ export const publicRouter = router({
|
|||||||
getNationList: procedure.query(async ({ ctx }) => {
|
getNationList: procedure.query(async ({ ctx }) => {
|
||||||
return loadCachedNationList(ctx);
|
return loadCachedNationList(ctx);
|
||||||
}),
|
}),
|
||||||
getTraffic: procedure.query(async ({ ctx }) => {
|
|
||||||
const worldState = await ctx.db.worldState.findFirst();
|
|
||||||
if (!worldState) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: 'PRECONDITION_FAILED',
|
|
||||||
message: 'World state is not initialized.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const meta = asRecord(worldState.meta);
|
|
||||||
const rawOnlineSince = meta.lastTurnTime ?? meta.turntime;
|
|
||||||
const parsedOnlineSince =
|
|
||||||
typeof rawOnlineSince === 'string' || rawOnlineSince instanceof Date
|
|
||||||
? new Date(rawOnlineSince)
|
|
||||||
: null;
|
|
||||||
const onlineSince =
|
|
||||||
parsedOnlineSince && Number.isFinite(parsedOnlineSince.getTime())
|
|
||||||
? parsedOnlineSince
|
|
||||||
: new Date(Date.now() - worldState.tickSeconds * 1_000);
|
|
||||||
const [accessTotal, currentOnline, topAccess] = await Promise.all([
|
|
||||||
ctx.db.generalAccessLog.aggregate({
|
|
||||||
_sum: {
|
|
||||||
refresh: true,
|
|
||||||
refreshScoreTotal: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
ctx.db.generalAccessLog.count({
|
|
||||||
where: {
|
|
||||||
lastRefresh: {
|
|
||||||
gte: onlineSince,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
ctx.db.generalAccessLog.findMany({
|
|
||||||
orderBy: [{ refresh: 'desc' }, { generalId: 'asc' }],
|
|
||||||
take: 5,
|
|
||||||
select: {
|
|
||||||
generalId: true,
|
|
||||||
refresh: true,
|
|
||||||
refreshScoreTotal: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const generalIds = topAccess.map((entry) => entry.generalId);
|
|
||||||
const generalRows =
|
|
||||||
generalIds.length > 0
|
|
||||||
? await ctx.db.general.findMany({
|
|
||||||
where: { id: { in: generalIds } },
|
|
||||||
select: { id: true, name: true },
|
|
||||||
})
|
|
||||||
: [];
|
|
||||||
const generalName = new Map(generalRows.map((general) => [general.id, general.name]));
|
|
||||||
const totalRefresh = accessTotal._sum.refresh ?? 0;
|
|
||||||
const totalRefreshScore = accessTotal._sum.refreshScoreTotal ?? 0;
|
|
||||||
const currentRefresh = Math.max(readFiniteMetaNumber(meta, 'refresh'), totalRefresh);
|
|
||||||
const history = parseTrafficHistory(meta.recentTraffic);
|
|
||||||
history.push({
|
|
||||||
year: worldState.currentYear,
|
|
||||||
month: worldState.currentMonth,
|
|
||||||
refresh: currentRefresh,
|
|
||||||
online: currentOnline,
|
|
||||||
date: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
history,
|
|
||||||
maxRefresh: Math.max(
|
|
||||||
1,
|
|
||||||
readFiniteMetaNumber(meta, 'maxrefresh'),
|
|
||||||
...history.map((entry) => entry.refresh)
|
|
||||||
),
|
|
||||||
maxOnline: Math.max(1, readFiniteMetaNumber(meta, 'maxonline'), ...history.map((entry) => entry.online)),
|
|
||||||
suspects: [
|
|
||||||
{
|
|
||||||
generalId: null,
|
|
||||||
name: '접속자 총합',
|
|
||||||
refresh: totalRefresh,
|
|
||||||
refreshScoreTotal: totalRefreshScore,
|
|
||||||
},
|
|
||||||
...topAccess.map((entry) => ({
|
|
||||||
generalId: entry.generalId,
|
|
||||||
name: generalName.get(entry.generalId) ?? `장수 ${entry.generalId}`,
|
|
||||||
refresh: entry.refresh,
|
|
||||||
refreshScoreTotal: entry.refreshScoreTotal,
|
|
||||||
})),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
getGeneralList: procedure.query(async ({ ctx }) => {
|
getGeneralList: procedure.query(async ({ ctx }) => {
|
||||||
const [generals, nations] = await Promise.all([
|
const [generals, nations] = await Promise.all([
|
||||||
ctx.db.general.findMany({
|
ctx.db.general.findMany({
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import type { RedisConnector } from '@sammo-ts/infra';
|
|
||||||
|
|
||||||
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
|
||||||
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
|
||||||
import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
|
|
||||||
import type { DatabaseClient, GameApiContext, GameProfile } from '../src/context.js';
|
|
||||||
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
|
|
||||||
import { appRouter } from '../src/router.js';
|
|
||||||
|
|
||||||
const profile: GameProfile = {
|
|
||||||
id: 'che',
|
|
||||||
scenario: 'default',
|
|
||||||
name: 'che:default',
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildContext = (): GameApiContext => {
|
|
||||||
const db = {
|
|
||||||
worldState: {
|
|
||||||
findFirst: async () => ({
|
|
||||||
id: 1,
|
|
||||||
currentYear: 185,
|
|
||||||
currentMonth: 3,
|
|
||||||
tickSeconds: 600,
|
|
||||||
config: {},
|
|
||||||
meta: {
|
|
||||||
lastTurnTime: '2026-07-26T03:00:00.000Z',
|
|
||||||
refresh: 12,
|
|
||||||
maxrefresh: 30,
|
|
||||||
maxonline: 5,
|
|
||||||
recentTraffic: [
|
|
||||||
{
|
|
||||||
year: 185,
|
|
||||||
month: 2,
|
|
||||||
refresh: 30,
|
|
||||||
online: 5,
|
|
||||||
date: '2026-07-26 02:50:00',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
generalAccessLog: {
|
|
||||||
aggregate: async () => ({
|
|
||||||
_sum: {
|
|
||||||
refresh: 12,
|
|
||||||
refreshScoreTotal: 21,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
count: async (args: { where: { lastRefresh: { gte: Date } } }) => {
|
|
||||||
expect(args.where.lastRefresh.gte).toEqual(new Date('2026-07-26T03:00:00.000Z'));
|
|
||||||
return 2;
|
|
||||||
},
|
|
||||||
findMany: async () => [
|
|
||||||
{ generalId: 7, refresh: 9, refreshScoreTotal: 15 },
|
|
||||||
{ generalId: 8, refresh: 3, refreshScoreTotal: 6 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
general: {
|
|
||||||
findMany: async () => [
|
|
||||||
{ id: 7, name: '갑' },
|
|
||||||
{ id: 8, name: '을' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const redis = {
|
|
||||||
get: async () => null,
|
|
||||||
set: async () => null,
|
|
||||||
} as unknown as RedisConnector['client'];
|
|
||||||
|
|
||||||
return {
|
|
||||||
db: db as unknown as DatabaseClient,
|
|
||||||
turnDaemon: new InMemoryTurnDaemonTransport(),
|
|
||||||
battleSim: new InMemoryBattleSimTransport(),
|
|
||||||
profile,
|
|
||||||
auth: null,
|
|
||||||
uploadDir: 'uploads',
|
|
||||||
uploadPath: '/uploads',
|
|
||||||
uploadPublicUrl: null,
|
|
||||||
redis,
|
|
||||||
accessTokenStore: new RedisAccessTokenStore(redis, profile.name),
|
|
||||||
flushStore: new InMemoryFlushStore(),
|
|
||||||
gameTokenSecret: 'test-secret',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('public.getTraffic', () => {
|
|
||||||
it('is public and returns only aggregate traffic plus allowlisted general names', async () => {
|
|
||||||
const result = await appRouter.createCaller(buildContext()).public.getTraffic();
|
|
||||||
|
|
||||||
expect(result.history).toHaveLength(2);
|
|
||||||
expect(result.history[0]).toEqual({
|
|
||||||
year: 185,
|
|
||||||
month: 2,
|
|
||||||
refresh: 30,
|
|
||||||
online: 5,
|
|
||||||
date: '2026-07-26 02:50:00',
|
|
||||||
});
|
|
||||||
expect(result.history[1]).toMatchObject({
|
|
||||||
year: 185,
|
|
||||||
month: 3,
|
|
||||||
refresh: 12,
|
|
||||||
online: 2,
|
|
||||||
});
|
|
||||||
expect(result.maxRefresh).toBe(30);
|
|
||||||
expect(result.maxOnline).toBe(5);
|
|
||||||
expect(result.suspects).toEqual([
|
|
||||||
{ generalId: null, name: '접속자 총합', refresh: 12, refreshScoreTotal: 21 },
|
|
||||||
{ generalId: 7, name: '갑', refresh: 9, refreshScoreTotal: 15 },
|
|
||||||
{ generalId: 8, name: '을', refresh: 3, refreshScoreTotal: 6 },
|
|
||||||
]);
|
|
||||||
expect(JSON.stringify(result)).not.toContain('userId');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -32,7 +32,6 @@ import TroopView from '../views/TroopView.vue';
|
|||||||
import YearbookView from '../views/YearbookView.vue';
|
import YearbookView from '../views/YearbookView.vue';
|
||||||
import NationBettingView from '../views/NationBettingView.vue';
|
import NationBettingView from '../views/NationBettingView.vue';
|
||||||
import NpcListView from '../views/NpcListView.vue';
|
import NpcListView from '../views/NpcListView.vue';
|
||||||
import TrafficView from '../views/TrafficView.vue';
|
|
||||||
import { useSessionStore } from '../stores/session';
|
import { useSessionStore } from '../stores/session';
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
@@ -258,11 +257,6 @@ const routes = [
|
|||||||
requiresGeneral: true,
|
requiresGeneral: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/traffic',
|
|
||||||
name: 'traffic',
|
|
||||||
component: TrafficView,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/npc-list',
|
path: '/npc-list',
|
||||||
name: 'npc-list',
|
name: 'npc-list',
|
||||||
|
|||||||
@@ -119,7 +119,6 @@ watch(
|
|||||||
<RouterLink class="ghost" to="/dynasty">왕조일람</RouterLink>
|
<RouterLink class="ghost" to="/dynasty">왕조일람</RouterLink>
|
||||||
<RouterLink class="ghost" to="/yearbook">연감</RouterLink>
|
<RouterLink class="ghost" to="/yearbook">연감</RouterLink>
|
||||||
<RouterLink class="ghost" to="/nation-betting">천통국 베팅</RouterLink>
|
<RouterLink class="ghost" to="/nation-betting">천통국 베팅</RouterLink>
|
||||||
<RouterLink class="ghost" to="/traffic">접속량정보</RouterLink>
|
|
||||||
<RouterLink class="ghost" to="/npc-list">빙의일람</RouterLink>
|
<RouterLink class="ghost" to="/npc-list">빙의일람</RouterLink>
|
||||||
<a class="ghost" href="/xe/community" target="_blank" rel="noopener">게시판</a>
|
<a class="ghost" href="/xe/community" target="_blank" rel="noopener">게시판</a>
|
||||||
<RouterLink class="ghost" to="/battle-simulator">전투 시뮬레이터</RouterLink>
|
<RouterLink class="ghost" to="/battle-simulator">전투 시뮬레이터</RouterLink>
|
||||||
|
|||||||
@@ -108,7 +108,6 @@ onMounted(() => {
|
|||||||
<RouterLink v-else-if="session.needsGeneral" class="ghost" to="/join">장수 생성/빙의</RouterLink>
|
<RouterLink v-else-if="session.needsGeneral" class="ghost" to="/join">장수 생성/빙의</RouterLink>
|
||||||
<RouterLink v-else class="ghost" to="/">메인으로</RouterLink>
|
<RouterLink v-else class="ghost" to="/">메인으로</RouterLink>
|
||||||
<RouterLink class="ghost" to="/npc-list">빙의일람</RouterLink>
|
<RouterLink class="ghost" to="/npc-list">빙의일람</RouterLink>
|
||||||
<RouterLink class="ghost" to="/traffic">접속량정보</RouterLink>
|
|
||||||
<button class="ghost" @click="refreshPublicData">새로고침</button>
|
<button class="ghost" @click="refreshPublicData">새로고침</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,343 +0,0 @@
|
|||||||
<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>
|
|
||||||
Reference in New Issue
Block a user