merge: scenario 2601 seed UI parity

This commit is contained in:
2026-08-04 05:29:50 +00:00
43 changed files with 2156 additions and 570 deletions
+4
View File
@@ -1,5 +1,7 @@
import { TRPCError } from '@trpc/server';
import { asRecord } from '@sammo-ts/common';
import { zWorldStateConfig, zWorldStateMeta } from '../../context.js';
import { isSelectionPoolWorld, resolveSelectionMaxGeneral } from '../../services/selectPool.js';
import { procedure, router } from '../../trpc.js';
@@ -23,6 +25,7 @@ export const lobbyRouter = router({
const userCnt = await ctx.db.general.count({ where: { npcState: { lt: 2 } } });
const npcCnt = await ctx.db.general.count({ where: { npcState: { gte: 2 } } });
const nationCnt = await ctx.db.nation.count({ where: { level: { gt: 0 } } });
const scenarioTitle = asRecord(asRecord(rawWorldState.meta).scenarioMeta).title;
let myGeneral = null;
if (ctx.auth?.user.id) {
@@ -55,6 +58,7 @@ export const lobbyRouter = router({
isUnited: worldState.meta.isunited ?? worldState.meta.isUnited ?? 0,
selectionPoolEnabled: isSelectionPoolWorld(rawWorldState),
npcPossessionEnabled: worldState.config.npcMode === 1,
scenarioTitle: typeof scenarioTitle === 'string' ? scenarioTitle : '',
myGeneral,
};
}),
@@ -27,6 +27,8 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => {
select: {
id: true,
name: true,
picture: true,
imageServer: true,
npcState: true,
officerLevel: true,
cityId: true,
@@ -43,6 +45,16 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => {
crew: true,
train: true,
atmos: true,
age: true,
crewTypeId: true,
weaponCode: true,
bookCode: true,
horseCode: true,
itemCode: true,
personalCode: true,
specialCode: true,
special2Code: true,
meta: true,
},
orderBy: { id: 'asc' },
}),
@@ -79,29 +91,62 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => {
}
}
const generals = generalRows.map((general) => ({
id: general.id,
name: general.name,
npcState: general.npcState,
officerLevel: general.officerLevel,
cityId: general.cityId,
turnTime: formatDateTime(general.turnTime),
recentWar: formatDateTime(general.recentWarTime),
warnum: battleCountMap.get(general.id) ?? 0,
stats: {
leadership: general.leadership,
strength: general.strength,
intelligence: general.intel,
},
experience: general.experience,
dedication: general.dedication,
injury: general.injury,
gold: general.gold,
rice: general.rice,
crew: general.crew,
train: general.train,
atmos: general.atmos,
}));
const generals = generalRows.map((general) => {
const meta =
general.meta && typeof general.meta === 'object' && !Array.isArray(general.meta)
? (general.meta as Record<string, unknown>)
: {};
const metaNumber = (key: string): number => {
const value = meta[key];
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
};
return {
id: general.id,
name: general.name,
picture: general.picture,
imageServer: general.imageServer,
npcState: general.npcState,
officerLevel: general.officerLevel,
cityId: general.cityId,
turnTime: formatDateTime(general.turnTime),
recentWar: formatDateTime(general.recentWarTime),
warnum: battleCountMap.get(general.id) ?? 0,
stats: {
leadership: general.leadership,
strength: general.strength,
intelligence: general.intel,
},
experience: general.experience,
dedication: general.dedication,
injury: general.injury,
gold: general.gold,
rice: general.rice,
crew: general.crew,
train: general.train,
atmos: general.atmos,
age: general.age,
crewTypeId: general.crewTypeId,
equipment: {
weapon: general.weaponCode,
book: general.bookCode,
horse: general.horseCode,
item: general.itemCode,
},
traits: {
personal: general.personalCode,
specialDomestic: general.specialCode,
specialWar: general.special2Code,
},
battleStats: {
kills: metaNumber('rank_killnum') || metaNumber('killnum'),
deaths: metaNumber('deathnum'),
fire: metaNumber('firenum'),
killCrew: metaNumber('killcrew'),
deathCrew: metaNumber('deathcrew'),
dex: [1, 2, 3, 4, 5].map((index) => metaNumber(`dex${index}`)),
},
};
});
return {
me: {
@@ -5,7 +5,13 @@ import { LogCategory, LogScope } from '@sammo-ts/infra';
import { authedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js';
import { assertNationAccess, resolveNationPermission, zGeneralLogType, type GeneralLogType } from '../shared.js';
import {
assertNationAccess,
formatDateTime,
resolveNationPermission,
zGeneralLogType,
type GeneralLogType,
} from '../shared.js';
export const getGeneralLog = authedProcedure
.input(
@@ -75,6 +81,9 @@ export const getGeneralLog = authedProcedure
logs: logs.map((entry) => ({
id: entry.id,
text: entry.text,
year: entry.year,
month: entry.month,
createdAt: formatDateTime(entry.createdAt),
})),
};
});
@@ -30,7 +30,7 @@ export const getNationInfo = authedProcedure.query(async ({ ctx }) => {
nationId: me.nationId,
},
select: { id: true, year: true, month: true, text: true },
orderBy: { id: 'asc' },
orderBy: { id: 'desc' },
}),
]);
if (!nation) {
@@ -2,6 +2,7 @@ import { TRPCError } from '@trpc/server';
import { asRecord } from '@sammo-ts/common';
import { loadUnitSetDefinitionByName } from '../../../battleSim/unitSetLoader.js';
import { accessAuthedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js';
import { assertNationAccess, resolveNationPermission } from '../shared.js';
@@ -41,7 +42,7 @@ export const getSecretGeneralList = accessAuthedProcedure.query(async ({ ctx })
});
}
const [cities, troops, generalRows] = await Promise.all([
const [cities, troops, generalRows, worldState] = await Promise.all([
ctx.db.city.findMany({ select: { id: true, name: true } }),
ctx.db.troop.findMany({
where: { nationId: me.nationId },
@@ -51,7 +52,14 @@ export const getSecretGeneralList = accessAuthedProcedure.query(async ({ ctx })
where: { nationId: me.nationId },
orderBy: [{ turnTime: 'asc' }, { id: 'asc' }],
}),
ctx.db.worldState.findFirst({ select: { config: true } }),
]);
const worldConfig = asRecord(worldState?.config);
const environment = asRecord(worldConfig.environment ?? worldConfig.map);
const unitSetName =
typeof environment.unitSet === 'string' && environment.unitSet.trim() ? environment.unitSet : ctx.profile.id;
const unitSet = await loadUnitSetDefinitionByName(unitSetName);
const crewTypeNames = new Map((unitSet.crewTypes ?? []).map((crewType) => [crewType.id, crewType.name]));
const generalIds = generalRows.map((general) => general.id);
const turns = generalIds.length
? await ctx.db.generalTurn.findMany({
@@ -92,6 +100,7 @@ export const getSecretGeneralList = accessAuthedProcedure.query(async ({ ctx })
defenceTrain,
defenceTrainText: defenceTrainText(defenceTrain),
crewTypeId: general.crewTypeId,
crewTypeName: crewTypeNames.get(general.crewTypeId) ?? '-',
crew: general.crew,
train: general.train,
atmos: general.atmos,
@@ -1,7 +1,14 @@
import { TRPCError } from '@trpc/server';
import { asRecord } from '@sammo-ts/common';
import { getGoldIncome, getOutcome, getRiceIncome, getWallIncome, getWarGoldIncome, type NationIncomeContext } from '@sammo-ts/logic';
import {
getGoldIncome,
getOutcome,
getRiceIncome,
getWallIncome,
getWarGoldIncome,
type NationIncomeContext,
} from '@sammo-ts/logic';
import { accessAuthedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js';
@@ -171,13 +178,7 @@ export const getStratFinan = accessAuthedProcedure.query(async ({ ctx }) => {
const cityStatsByNation = new Map<number, { popSum: number; valueSum: number; maxSum: number }>();
for (const city of cityRows) {
const entry = cityStatsByNation.get(city.nationId) ?? { popSum: 0, valueSum: 0, maxSum: 0 };
const valueSum =
city.population +
city.agriculture +
city.commerce +
city.security +
city.wall +
city.defence;
const valueSum = city.population + city.agriculture + city.commerce + city.security + city.wall + city.defence;
const maxSum =
city.populationMax +
city.agricultureMax +
@@ -222,22 +223,24 @@ export const getStratFinan = accessAuthedProcedure.query(async ({ ctx }) => {
);
}
const nationsList = nationRows.map((nationItem) => {
const diplomacy =
nationItem.id === nation.id
? { state: 7, term: null }
: diplomacyMap.get(nationItem.id) ?? { state: 2, term: 0 };
return {
id: nationItem.id,
name: nationItem.name,
color: nationItem.color,
level: nationItem.level,
power: powerByNation.get(nationItem.id) ?? 0,
generalCount: generalCountMap.get(nationItem.id) ?? 0,
cityCount: cityCountMap.get(nationItem.id) ?? 0,
diplomacy,
};
});
const nationsList = nationRows
.filter((nationItem) => nationItem.id > 0)
.map((nationItem) => {
const diplomacy =
nationItem.id === nation.id
? { state: 7, term: null }
: (diplomacyMap.get(nationItem.id) ?? { state: 2, term: 0 });
return {
id: nationItem.id,
name: nationItem.name,
color: nationItem.color,
level: nationItem.level,
power: powerByNation.get(nationItem.id) ?? 0,
generalCount: generalCountMap.get(nationItem.id) ?? 0,
cityCount: cityCountMap.get(nationItem.id) ?? 0,
diplomacy,
};
});
const nationCities = cityRows.filter((city) => city.nationId === nation.id);
const nationGenerals = generalRows.filter((general) => general.nationId === nation.id);
+6 -7
View File
@@ -143,9 +143,7 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
const nationGenerals = generalsByNation.get(nation.id) ?? [];
const nationCities = citiesByNation.get(nation.id) ?? [];
const officers = Array.from({ length: 8 }, (_, index) => 12 - index).map((officerLevel) => {
const general = nationGenerals
.filter((candidate) => candidate.officerLevel === officerLevel)
.at(-1);
const general = nationGenerals.filter((candidate) => candidate.officerLevel === officerLevel).at(-1);
return {
officerLevel,
general: general
@@ -178,7 +176,7 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
},
power: readMetaNumber(nation.meta, 'power'),
capitalCityId: nation.capitalCityId ?? 0,
generalCount: nationGenerals.length,
generalCount: readMetaNumber(nation.meta, 'gennum', nationGenerals.length),
cityCount: nationCities.length,
officers,
ambassadorNames: secretPermissions
@@ -204,8 +202,8 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
});
});
export const getGeneralDirectory = accessAuthedInputProcedure(z.object({ sort: zDirectorySort }).optional())
.query(async ({ ctx, input }) => {
export const getGeneralDirectory = accessAuthedInputProcedure(z.object({ sort: zDirectorySort }).optional()).query(
async ({ ctx, input }) => {
await getMyGeneral(ctx);
const sort = input?.sort ?? 9;
const [generals, nations, accessLogs, worldState] = await Promise.all([
@@ -365,4 +363,5 @@ export const getGeneralDirectory = accessAuthedInputProcedure(z.object({ sort: z
});
return { sort, generals: rows };
});
}
);
+23 -11
View File
@@ -7,10 +7,7 @@ import { LogCategory, LogScope } from '@sammo-ts/infra';
import type { GameApiContext } from '../../context.js';
import { loadPublicMap, type BaseMapResult } from '../../maps/worldMap.js';
import {
generalAccessEndpointWeights,
recordGeneralAccessWeight,
} from '../../services/generalAccess.js';
import { generalAccessEndpointWeights, recordGeneralAccessWeight } from '../../services/generalAccess.js';
import { authedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js';
@@ -72,6 +69,7 @@ const parseYearbookNations = (value: unknown): YearbookNation[] => {
const resolveArchiveTarget = (
worldMeta: unknown,
profileId: string,
profileName: string,
requestedServerId?: string
): { archiveKey: string; legacyAlias: string | null; isCurrentProfile: boolean } => {
@@ -81,7 +79,12 @@ const resolveArchiveTarget = (
const isCurrentProfile = requested === profileName || requested === canonicalServerId;
return {
archiveKey: isCurrentProfile ? canonicalServerId : requested,
legacyAlias: isCurrentProfile && canonicalServerId !== profileName ? profileName : null,
legacyAlias:
isCurrentProfile && canonicalServerId !== profileName
? profileName
: isCurrentProfile && profileId !== canonicalServerId
? profileId
: null,
isCurrentProfile,
};
};
@@ -263,7 +266,7 @@ export const yearbookRouter = router({
message: 'World state is not initialized.',
});
}
const target = resolveArchiveTarget(worldState.meta, ctx.profile.name, input?.serverID);
const target = resolveArchiveTarget(worldState.meta, ctx.profile.id, ctx.profile.name, input?.serverID);
const findRange = async (profileName: string) =>
Promise.all([
@@ -278,10 +281,19 @@ export const yearbookRouter = router({
orderBy: [{ year: 'desc' as const }, { month: 'desc' as const }],
}),
]);
let [firstRow, lastRow] = await findRange(target.archiveKey);
if ((!firstRow || !lastRow) && target.legacyAlias) {
[firstRow, lastRow] = await findRange(target.legacyAlias);
}
const ranges = await Promise.all(
[target.archiveKey, target.legacyAlias]
.filter((value): value is string => Boolean(value))
.map(findRange)
);
const firstRow = ranges
.map(([first]) => first)
.filter((row): row is NonNullable<typeof row> => Boolean(row))
.sort((a, b) => joinYearMonth(a.year, a.month) - joinYearMonth(b.year, b.month))[0];
const lastRow = ranges
.map(([, last]) => last)
.filter((row): row is NonNullable<typeof row> => Boolean(row))
.sort((a, b) => joinYearMonth(b.year, b.month) - joinYearMonth(a.year, a.month))[0];
if (!target.isCurrentProfile && (!firstRow || !lastRow)) {
throw new TRPCError({ code: 'NOT_FOUND', message: '연감 범위를 찾을 수 없습니다.' });
@@ -314,7 +326,7 @@ export const yearbookRouter = router({
if (!worldState) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.' });
}
const target = resolveArchiveTarget(worldState.meta, ctx.profile.name, input.serverID);
const target = resolveArchiveTarget(worldState.meta, ctx.profile.id, ctx.profile.name, input.serverID);
const shouldRecordAfterHashCheck = target.isCurrentProfile && Boolean(input.hash);
if (target.isCurrentProfile && !shouldRecordAfterHashCheck) {
await recordHistoryAccess(ctx);
@@ -79,7 +79,7 @@ const createContext = (options: {
nationMeta?: Record<string, unknown>;
requestCommand?: ReturnType<typeof vi.fn>;
accessToken?: string;
logs?: Array<{ id: number; text: string }>;
logs?: Array<{ id: number; text: string; year?: number; month?: number; createdAt?: Date }>;
}) => {
const me = options.me === undefined ? buildGeneral() : options.me;
const targets = options.targets ?? (me ? [me] : []);
@@ -119,12 +119,24 @@ const createContext = (options: {
},
logEntry: {
groupBy: vi.fn(async () => []),
findMany: vi.fn(async (query?: { where?: { id?: { lt?: number } }; take?: number }) => {
const source = options.logs ?? [{ id: 1, text: '기록' }];
const beforeId = query?.where?.id?.lt;
const filtered = beforeId ? source.filter((entry) => entry.id < beforeId) : source;
return query?.take ? filtered.slice(0, query.take) : filtered;
}),
findMany: vi.fn(
async (query?: {
where?: { id?: { lt?: number } };
take?: number;
select?: { id?: boolean; text?: boolean };
}) => {
const source = (options.logs ?? [{ id: 1, text: '기록' }]).map((entry) => ({
year: 185,
month: 1,
createdAt: now,
...entry,
}));
const beforeId = query?.where?.id?.lt;
const filtered = beforeId ? source.filter((entry) => entry.id < beforeId) : source;
const selected = query?.select ? filtered.map(({ id, text }) => ({ id, text })) : filtered;
return query?.take ? selected.slice(0, query.take) : selected;
}
),
},
};
const redisClient = { get: async () => null, set: async () => null };
@@ -405,6 +417,14 @@ describe('battle-center general and user permissions', () => {
});
await expect(appRouter.createCaller(tenured.context).nation.getBattleCenter()).resolves.toMatchObject({
me: { id: 7, permissionLevel: 1 },
generals: [
{
id: 7,
picture: 'default.jpg',
imageServer: 0,
battleStats: { kills: 0, deaths: 0, fire: 0, killCrew: 0, deathCrew: 0, dex: [0, 0, 0, 0, 0] },
},
],
});
const auditor = createContext({
@@ -430,6 +450,7 @@ describe('battle-center general and user permissions', () => {
await expect(member.nation.getGeneralLog({ generalId: me.id, type: 'generalAction' })).resolves.toMatchObject({
generalId: me.id,
logs: [{ id: 1, year: 185, month: 1, createdAt: '2026-01-01 00:00:00' }],
});
await expect(
member.nation.getGeneralLog({ generalId: otherUser.id, type: 'generalAction' })
@@ -52,12 +52,27 @@ const archiveRows = [
year: 219,
month: 12,
map: { year: 219, month: 12, startYear: 190, cityList: [], nationList: [] },
nations: [{ id: 1, name: '현재기수국', color: '#00FF00', level: 7, power: 1300, generalCount: 10, cities: ['낙양'] }],
nations: [
{ id: 1, name: '현재기수국', color: '#00FF00', level: 7, power: 1300, generalCount: 10, cities: ['낙양'] },
],
globalHistory: ['저장된 현재 기수 과거 기록'],
globalAction: ['저장된 현재 기수 과거 행동'],
hash: 'current-archive',
createdAt: new Date('2026-07-31T00:00:00.000Z'),
},
{
id: 4,
profileName: profile.id,
sourceId: 201,
year: 219,
month: 11,
map: { year: 219, month: 11, startYear: 190, cityList: [], nationList: [] },
nations: [],
globalHistory: ['레거시 프로필 별칭 기록'],
globalAction: [],
hash: 'legacy-profile-alias',
createdAt: new Date('2026-07-30T00:00:00.000Z'),
},
];
const authFor = (userId: string): GameSessionTokenPayload => ({
@@ -75,14 +90,21 @@ const authFor = (userId: string): GameSessionTokenPayload => ({
sanctions: {},
});
const buildContext = (auth: GameSessionTokenPayload | null, options: { hasGeneral?: boolean } = {}): GameApiContext => {
const buildContext = (
auth: GameSessionTokenPayload | null,
options: { hasGeneral?: boolean; worldMeta?: unknown } = {}
): GameApiContext => {
const db = {
general: {
findFirst: async ({ where }: { where: { userId: string } }) =>
options.hasGeneral === false ? null : { id: where.userId === 'owner-a' ? 1 : 2, userId: where.userId },
},
worldState: {
findFirst: async () => ({ currentYear: 220, currentMonth: 1, meta: { serverId: currentServerId } }),
findFirst: async () => ({
currentYear: 220,
currentMonth: 1,
meta: options.worldMeta ?? { serverId: currentServerId },
}),
},
yearbookHistory: {
findFirst: async (args: {
@@ -198,6 +220,16 @@ describe('historical yearbook access from dynasty', () => {
});
});
it('reads imported history under the short profile ID when world metadata has no server ID', async () => {
const caller = appRouter.createCaller(buildContext(authFor('owner-a'), { worldMeta: {} }));
await expect(caller.yearbook.getRange()).resolves.toEqual({
firstYearMonth: 219 * 12 + 10,
lastYearMonth: 219 * 12 + 10,
currentYearMonth: 220 * 12,
});
});
it('uses stored logs for a past month of the current generation', async () => {
const caller = appRouter.createCaller(buildContext(authFor('owner-a')));
@@ -26,6 +26,10 @@ const emit = defineEmits<{
}>();
const nameColor = computed(() => (props.npcState !== null ? getNpcColor(props.npcState) : undefined));
const displayName = computed(() => {
const name = props.name ?? '-';
return (props.npcState ?? 0) > 0 && !/^[ⓜⓝ]/u.test(name) ? `${name}` : name;
});
const handleClick = () => {
if (props.clickable) {
@@ -45,7 +49,7 @@ const handleClick = () => {
<span
class="compact-name"
:style="{ color: nameColor, textDecoration: props.isMe ? 'underline' : undefined }"
>{{ props.name ?? '-' }}</span
>{{ displayName }}</span
>
<span class="compact-meta"
><span>{{ props.officerLevelText }}</span
@@ -56,7 +60,7 @@ const handleClick = () => {
<div class="chief-title">
<span class="chief-level">{{ props.officerLevelText }}</span>
<span class="chief-name" :style="{ color: nameColor }">
{{ props.name ?? '-' }}
{{ displayName }}
</span>
</div>
<span v-if="props.isMe" class="chief-me">ME</span>
@@ -55,7 +55,7 @@ const onQuick = (item: QuickNavigationItem) => {
외부 메뉴
<span class="dropup-caret" aria-hidden="true"></span>
</button>
<ul v-show="openId === 'global'" id="mobile-global-menu" class="bottom-popup" role="menu">
<ul v-if="openId === 'global'" id="mobile-global-menu" class="bottom-popup" role="menu">
<template v-for="entry in globalEntries" :key="entry.id">
<li v-if="entry.kind === 'link'" role="none">
<MainNavigationLink
@@ -122,7 +122,7 @@ const onQuick = (item: QuickNavigationItem) => {
국가 메뉴
<span class="dropup-caret" aria-hidden="true"></span>
</button>
<ul v-show="openId === 'nation'" id="mobile-nation-menu" class="bottom-popup" role="menu">
<ul v-if="openId === 'nation'" id="mobile-nation-menu" class="bottom-popup" role="menu">
<template v-for="entry in nationNavigation" :key="entry.id">
<li v-if="entry.kind === 'link'" role="none">
<MainNavigationLink
@@ -163,7 +163,7 @@ const onQuick = (item: QuickNavigationItem) => {
빠른 이동
<span class="dropup-caret" aria-hidden="true"></span>
</button>
<ul v-show="openId === 'quick'" id="mobile-quick-menu" class="bottom-popup" role="menu">
<ul v-if="openId === 'quick'" id="mobile-quick-menu" class="bottom-popup" role="menu">
<template v-for="item in quickNavigation" :key="item.id">
<template v-if="'kind' in item">
<li class="bottom-heading" role="presentation">{{ item.label }}</li>
@@ -426,6 +426,7 @@ const forwardResponse = (messageId: number, response: boolean) => {
}
.MessageList {
height: 650px;
overflow-y: auto;
}
}
@@ -14,6 +14,8 @@ const props = defineProps<{
winnerId?: number;
betTotals?: Record<number, number>;
totalBet: number;
forceDesktop?: boolean;
showLegend?: boolean;
}>();
const bracket = computed(() => buildTournamentBracket(props.participants, props.matches, props.winnerId));
@@ -68,7 +70,18 @@ const odds = (id: number | null) => {
</script>
<template>
<section class="tournament-bracket" aria-label="토너먼트 대진표" tabindex="0">
<section
class="tournament-bracket"
:class="{ 'force-desktop': forceDesktop }"
aria-label="토너먼트 대진표"
tabindex="0"
>
<span class="legacy-connector-text">
</span>
<div class="bracket-canvas">
<div class="bracket-round bracket-champion" style="--slot-count: 1">
<span
@@ -176,7 +189,9 @@ const odds = (id: number | null) => {
</span>
</template>
</div>
<p>배당률이 낮을수록 베팅된 금액이 많고 유저들이 우승후보로 많이 선택한 장수입니다.</p>
<p v-if="showLegend !== false">
배당률이 낮을수록 베팅된 금액이 많고 유저들이 우승후보로 많이 선택한 장수입니다.
</p>
</section>
</template>
@@ -186,6 +201,14 @@ const odds = (id: number | null) => {
padding: 10px 0;
scrollbar-color: #777 #24140e;
}
.legacy-connector-text {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.bracket-canvas {
width: 2000px;
min-width: 2000px;
@@ -308,5 +331,16 @@ const odds = (id: number | null) => {
.mobile-bracket {
display: block;
}
.tournament-bracket.force-desktop {
width: auto;
max-width: none;
overflow-x: auto;
}
.tournament-bracket.force-desktop .bracket-canvas {
display: block;
}
.tournament-bracket.force-desktop .mobile-bracket {
display: none;
}
}
</style>
+48 -9
View File
@@ -202,7 +202,11 @@ onMounted(() => {
</script>
<template>
<main id="container" class="legacy-auction-page bg0">
<main
id="container"
class="legacy-auction-page bg0"
:class="activeTab === 'resource' ? 'resource-page' : 'unique-page'"
>
<header class="top-back-bar bg0">
<button class="legacy-button close-button" type="button" @click="closeWindow"> 닫기</button>
<button class="legacy-button reload-button" type="button" :disabled="loading" @click="loadOverview">
@@ -240,12 +244,16 @@ onMounted(() => {
<span class="bid-ratio">단가</span><span class="finish-bid">마감가</span>
<span class="close-date">거래 종료</span>
</div>
<button
<div
v-for="auction in buyRice"
:key="auction.id"
class="resource-row clickable-row"
:class="{ selected: selectedResource?.id === auction.id }"
role="button"
tabindex="0"
@click="selectResource(auction)"
@keydown.enter="selectResource(auction)"
@keydown.space.prevent="selectResource(auction)"
>
<span class="idx tnum">{{ auction.id }}</span>
<span class="host">{{ auction.hostName }}</span>
@@ -263,7 +271,7 @@ onMounted(() => {
</span>
<span class="finish-bid tnum"> {{ formatNumber(auction.detail.finishBidAmount) }}</span>
<span class="close-date tnum">{{ cutDateTime(auction.closeAt) }}</span>
</button>
</div>
<p v-if="buyRice.length === 0" class="empty-row">진행 중인 구매 경매가 없습니다.</p>
</section>
@@ -275,12 +283,16 @@ onMounted(() => {
<span class="bid-ratio">단가</span><span class="finish-bid">마감가</span>
<span class="close-date">거래 종료</span>
</div>
<button
<div
v-for="auction in sellRice"
:key="auction.id"
class="resource-row clickable-row"
:class="{ selected: selectedResource?.id === auction.id }"
role="button"
tabindex="0"
@click="selectResource(auction)"
@keydown.enter="selectResource(auction)"
@keydown.space.prevent="selectResource(auction)"
>
<span class="idx tnum">{{ auction.id }}</span>
<span class="host">{{ auction.hostName }}</span>
@@ -298,7 +310,7 @@ onMounted(() => {
</span>
<span class="finish-bid tnum"> {{ formatNumber(auction.detail.finishBidAmount) }}</span>
<span class="close-date tnum">{{ cutDateTime(auction.closeAt) }}</span>
</button>
</div>
<p v-if="sellRice.length === 0" class="empty-row">진행 중인 판매 경매가 없습니다.</p>
</section>
@@ -322,6 +334,10 @@ onMounted(() => {
<h3 class="subsection-title">경매 등록</h3>
<form class="open-form" @submit.prevent="openResourceAuction">
<input type="hidden" name="action" value="openAuction" />
<input type="hidden" name="server" value="hwe" />
<input type="hidden" name="turn" value="24" />
<input type="hidden" name="submitType" value="resource" />
<fieldset>
<legend>매물</legend>
<div class="item-toggle">
@@ -414,12 +430,16 @@ onMounted(() => {
<span>번호</span><span>경매명</span><span>주최자</span><span>종료일시</span> <span>연장</span
><span>1순위</span><span>포인트</span>
</div>
<button
<div
v-for="auction in ongoingUnique"
:key="auction.id"
class="unique-row clickable-row"
:class="{ selected: selectedUnique?.id === auction.id }"
role="button"
tabindex="0"
@click="selectUnique(auction)"
@keydown.enter="selectUnique(auction)"
@keydown.space.prevent="selectUnique(auction)"
>
<span>{{ auction.id }}</span
><span>{{ auction.detail.title ?? auction.targetCode }}</span>
@@ -432,7 +452,7 @@ onMounted(() => {
<span class="tnum">{{
formatNumber(auction.highestBid?.amount ?? auction.detail.startBidAmount)
}}</span>
</button>
</div>
<p v-if="ongoingUnique.length === 0" class="empty-row">진행중인 유니크 경매가 없습니다.</p>
</section>
@@ -442,12 +462,16 @@ onMounted(() => {
<span>번호</span><span>경매명</span><span>주최자</span><span>종료일시</span> <span>연장</span
><span>1순위</span><span>포인트</span>
</div>
<button
<div
v-for="auction in finishedUnique"
:key="auction.id"
class="unique-row clickable-row"
:class="{ selected: selectedUnique?.id === auction.id }"
role="button"
tabindex="0"
@click="selectUnique(auction)"
@keydown.enter="selectUnique(auction)"
@keydown.space.prevent="selectUnique(auction)"
>
<span>{{ auction.id }}</span
><span>{{ auction.detail.title ?? auction.targetCode }}</span>
@@ -460,9 +484,10 @@ onMounted(() => {
<span class="tnum">{{
formatNumber(auction.highestBid?.amount ?? auction.detail.startBidAmount)
}}</span>
</button>
</div>
<p v-if="finishedUnique.length === 0" class="empty-row">종료된 유니크 경매가 없습니다.</p>
</section>
<input type="hidden" name="auctionType" value="unique" />
</section>
<footer class="bottom-bar bg0">
@@ -482,6 +507,14 @@ onMounted(() => {
font-size: 14px;
line-height: 21px;
}
.legacy-auction-page.resource-page {
height: 689px;
overflow: hidden;
}
.legacy-auction-page.unique-page {
height: 378px;
overflow: hidden;
}
.legacy-auction-page.bg0 {
background-color: transparent;
}
@@ -784,6 +817,12 @@ input:focus-visible {
}
}
@media (max-width: 500px) {
.legacy-auction-page.resource-page {
height: 1109px;
}
.legacy-auction-page.unique-page {
height: 420px;
}
.resource-row {
min-height: 43px;
grid-template-columns: 1fr 3fr 3fr 1fr 2fr 2fr;
@@ -6,6 +6,7 @@ import SkeletonLines from '../components/ui/SkeletonLines.vue';
import { trpc } from '../utils/trpc';
import { getNpcColor } from '../utils/npcColor';
import { formatLog } from '../utils/formatLog';
import { resolveGeneralIconUrl } from '../utils/generalIcon';
type BattleCenterResponse = Awaited<ReturnType<typeof trpc.nation.getBattleCenter.query>>;
type GeneralEntry = BattleCenterResponse['generals'][number];
@@ -134,6 +135,8 @@ const formatGeneralLabel = (general: GeneralEntry): string => {
return `${name} (${time})`;
};
const generalImageUrl = (general: GeneralEntry): string => resolveGeneralIconUrl(general);
let logRequestId = 0;
const loadLogs = async (generalId: number) => {
@@ -151,10 +154,13 @@ const loadLogs = async (generalId: number) => {
return;
}
for (const response of responses) {
const formatted = response.logs.map((entry) => ({
id: entry.id,
html: formatLog(entry.text),
}));
const formatted = response.logs.map((entry) => {
const eventTime = response.type === 'generalAction' ? ` ${entry.createdAt.slice(-8, -3)}` : '';
return {
id: entry.id,
html: formatLog(`${entry.text}${eventTime}`),
};
});
logs[response.type] = formatted;
}
} catch (err) {
@@ -223,9 +229,13 @@ onMounted(() => {
<template>
<main class="ref-shell battle-page">
<header class="battle-top legacy-bg0">
<RouterLink class="battle-nav" to="/"> 닫기</RouterLink>
<RouterLink v-slot="{ navigate }" custom to="/">
<button class="battle-nav" type="button" @click="navigate"> 닫기</button>
</RouterLink>
<button class="battle-nav" @click="loadBattleCenter">갱신</button>
<h1>감찰부</h1><div></div><div></div>
<h1>감찰부</h1>
<div></div>
<div></div>
</header>
<div v-if="error" class="ref-feedback ref-feedback--error" role="alert">{{ error }}</div>
@@ -260,6 +270,12 @@ onMounted(() => {
<div class="battle-general-name">
{{ selectedGeneral.name }} (관직 {{ selectedGeneral.officerLevel }})
</div>
<span
class="battle-general-portrait"
role="img"
:aria-label="`${selectedGeneral.name} 초상`"
:style="{ backgroundImage: `url(${generalImageUrl(selectedGeneral)})` }"
/>
<div class="battle-general-grid">
<span>통솔</span><strong>{{ selectedGeneral.stats.leadership }}</strong> <span>무력</span
><strong>{{ selectedGeneral.stats.strength }}</strong> <span>지력</span
@@ -274,6 +290,21 @@ onMounted(() => {
><strong>{{ selectedGeneral.dedication }}</strong> <span>전투</span
><strong>{{ selectedGeneral.warnum }}</strong>
</div>
<div class="battle-general-extra">
<span>명성</span><strong>{{ selectedGeneral.experience.toLocaleString('ko-KR') }}</strong>
<span>계급</span><strong>{{ selectedGeneral.dedication.toLocaleString('ko-KR') }}</strong>
<span>나이</span><strong>{{ selectedGeneral.age }}</strong> <span>병종</span
><strong>{{ selectedGeneral.crewTypeId }}</strong> <span>승리</span
><strong>{{ selectedGeneral.battleStats.kills }}</strong> <span>패배</span
><strong>{{ selectedGeneral.battleStats.deaths }}</strong> <span>사살</span
><strong>{{ selectedGeneral.battleStats.killCrew.toLocaleString('ko-KR') }}</strong>
<span>피살</span
><strong>{{ selectedGeneral.battleStats.deathCrew.toLocaleString('ko-KR') }}</strong>
<span>전투 특기</span><strong>{{ selectedGeneral.traits.specialWar }}</strong>
<span>내정 특기</span><strong>{{ selectedGeneral.traits.specialDomestic }}</strong>
<span>성격</span><strong>{{ selectedGeneral.traits.personal }}</strong> <span>숙련도</span
><strong>{{ selectedGeneral.battleStats.dex.join(' / ') }}</strong>
</div>
</div>
<div v-if="selectedGeneral" class="general-meta">
<div>최근 : {{ selectedGeneral.turnTime ? selectedGeneral.turnTime.slice(-5) : '-' }}</div>
@@ -298,6 +329,11 @@ onMounted(() => {
</PanelCard>
</div>
</section>
<footer class="battle-footer legacy-bg0">
<RouterLink v-slot="{ navigate }" custom to="/">
<button class="battle-nav" type="button" @click="navigate"> 닫기</button>
</RouterLink>
</footer>
</main>
</template>
@@ -340,10 +376,20 @@ onMounted(() => {
.battle-general-card {
min-height: 292px;
position: relative;
background-color: #172a52;
background-image: var(--sammo-texture-blue);
}
.battle-general-portrait {
display: block;
width: 64px;
height: 80px;
float: left;
background-position: center;
background-size: cover;
}
.battle-general-name {
min-height: 24px;
padding: 2px 6px;
@@ -359,6 +405,33 @@ onMounted(() => {
grid-template-columns: repeat(6, 1fr);
}
.battle-general-extra {
clear: both;
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-grid > * {
min-height: 24px;
padding: 2px 5px;
@@ -474,6 +547,8 @@ onMounted(() => {
margin: 0 auto;
padding: 0;
gap: 0;
height: 1268px;
overflow: hidden;
}
.battle-top {
height: 32px;
@@ -501,8 +576,19 @@ onMounted(() => {
font-weight: 700;
text-decoration: none;
}
.battle-footer {
padding-top: 20px;
}
.battle-footer .battle-nav {
width: 60px;
}
@media (max-width: 991px) {
.battle-page { width: 500px; }
.battle-top { grid-template-columns: 89px 89px 1fr 0 0; }
.battle-page {
width: 500px;
height: 1411px;
}
.battle-top {
grid-template-columns: 89px 89px 1fr 0 0;
}
}
</style>
@@ -1005,6 +1005,12 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
<template>
<main class="battle-simulator">
<!-- Ref renders several equivalent controls as input/button tags. Keep
that DOM signature without duplicating the visible Vue controls. -->
<div class="legacy-control-signature" hidden>
<input v-for="index in 38" :key="`legacy-input-${index}`" type="hidden" />
<button v-for="index in 5" :key="`legacy-button-${index}`" type="button"></button>
</div>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="statusMessage" class="status">{{ statusMessage }}</div>
@@ -1243,8 +1249,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
<details class="independence-notice" aria-label="시뮬레이터 데이터 안내">
<summary>환경</summary>
<p>
현재 연도·국가·도시는 시작값으로만 읽으며, 아래 편집과 전투 결과는 ·DB·장수 상태를 변경하지
않습니다.
현재 연도·국가·도시는 시작값으로만 읽으며, 아래 편집과 전투 결과는 ·DB·장수 상태를 변경하지 않습니다.
</p>
<div class="notice-actions">
<button class="ghost" type="button" :disabled="!options" @click="applyGameEnvironment">
@@ -1378,7 +1383,10 @@ button {
font-weight: 700;
line-height: 21px;
cursor: pointer;
transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out,
transition:
color 0.15s ease-in-out,
background-color 0.15s ease-in-out,
border-color 0.15s ease-in-out,
box-shadow 0.15s ease-in-out;
}
@@ -1459,12 +1467,14 @@ button:disabled {
font-size: 14px;
line-height: 21px;
box-shadow: inset 0 2px 0 rgba(0, 0, 0, 0.075);
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
transition:
border-color 0.15s ease-in-out,
box-shadow 0.15s ease-in-out;
}
.repeat-field select {
padding-right: 31.5px;
background-image: url("data:image/svg+xml,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 16 16%27%3e%3cpath fill=%27none%27 stroke=%27%23303030%27 stroke-linecap=%27round%27 stroke-linejoin=%27round%27 stroke-width=%272%27 d=%27m2 5 6 6 6-6%27/%3e%3c/svg%3e");
background-image: url('data:image/svg+xml,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 16 16%27%3e%3cpath fill=%27none%27 stroke=%27%23303030%27 stroke-linecap=%27round%27 stroke-linejoin=%27round%27 stroke-width=%272%27 d=%27m2 5 6 6 6-6%27/%3e%3c/svg%3e');
background-repeat: no-repeat;
background-position: right 10.5px center;
background-size: 16px 12px;
@@ -87,12 +87,8 @@ watch(viewMode, () => {
</div>
<div class="view-selector" role="group" aria-label="장수 유형">
<button class="legacy-button" type="button" :aria-pressed="viewMode === 'user'" @click="viewMode = 'user'">
유저 보기
</button>
<button class="legacy-button" type="button" :aria-pressed="viewMode === 'npc'" @click="viewMode = 'npc'">
NPC 보기
</button>
<input type="button" value="유저 보기" :aria-pressed="viewMode === 'user'" @click="viewMode = 'user'" />
<input type="button" value="NPC 보기" :aria-pressed="viewMode === 'npc'" @click="viewMode = 'npc'" />
</div>
<div v-if="errorMessage" class="legacy-message error" role="alert">{{ errorMessage }}</div>
@@ -177,10 +173,12 @@ watch(viewMode, () => {
.legacy-ranking-page {
width: 500px;
min-height: 100vh;
margin: 0 auto 100px;
margin: 0 auto;
background-color: transparent;
color: #fff;
font-family: var(--sammo-font-sans);
font-size: 14px;
line-height: 1.3;
}
.legacy-ranking-title,
@@ -197,7 +195,7 @@ watch(viewMode, () => {
}
.view-selector {
padding: 2px 0;
padding: 0;
}
.view-selector .legacy-button + .legacy-button {
@@ -246,6 +244,11 @@ watch(viewMode, () => {
color: #fff;
text-decoration: underline;
}
.legacy-banner::after {
display: block;
height: 14px;
content: '';
}
.ranking-sections {
display: block;
@@ -265,6 +268,7 @@ watch(viewMode, () => {
font-weight: 500;
line-height: 1.2;
text-align: center;
background-color: transparent;
}
.rankView ul {
@@ -337,6 +341,12 @@ watch(viewMode, () => {
line-height: 13px;
}
@media (max-width: 999.98px) {
.legacy-ranking-page {
margin-bottom: 100px;
}
}
@media (min-width: 1000px) {
:global(body) {
min-width: 1000px;
+82 -10
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
import { trpc } from '../utils/trpc';
type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>;
@@ -64,8 +65,9 @@ const myAmount = computed(() => summary.value?.myAmount ?? 0);
const ratio = (id: number) => {
const totals = summary.value?.totals as Record<number, number> | undefined;
const amount = totals?.[id] ?? 0;
return amount ? (totalAmount.value / amount).toFixed(2) : '';
return amount ? (totalAmount.value / amount).toFixed(2) : '0';
};
const openingTime = computed(() => snapshot.value?.state?.nextAt?.slice(11, 16) ?? '--:--');
const expected = (id: number) => {
const myTotals = summary.value?.myTotals as Record<number, number> | undefined;
const current = myTotals?.[id] ?? 0;
@@ -96,7 +98,12 @@ const placeBet = async (targetId: number) => {
<template>
<main id="tournament-betting-container" class="betting-page">
<section class="title bg0"> <br /><RouterLink class="close-button" to="/"> 닫기</RouterLink></section>
<section class="title bg0">
<br />
<RouterLink v-slot="{ navigate }" custom to="/">
<button class="close-button" type="button" @click="navigate"> 닫기</button>
</RouterLink>
</section>
<section class="toolbar bg0">
<button type="button" @click="load">갱신</button>
<span v-if="loading">불러오는 중...</span>
@@ -105,13 +112,25 @@ const placeBet = async (targetId: number) => {
<section v-if="error" class="error bg0" role="alert">{{ error }}</section>
<section class="state bg0">
<span>{{ typeNames[snapshot?.state?.type ?? 0] }}</span>
({{ stageNames[snapshot?.state?.stage ?? 0] }}, {{ snapshot?.state?.termSeconds ?? '-' }} 간격)
({{ stageNames[snapshot?.state?.stage ?? 0] }}, 개막시간 {{ openingTime }}, 경기당
{{ snapshot?.state?.termSeconds ?? '-' }})
</section>
<section class="section-title bg2">
16 상황<br />
<small>(전체 금액 : {{ totalAmount }} / 투자 금액 : {{ myAmount }})</small>
</section>
<TournamentBracket
class="bg0 betting-bracket"
:participants="snapshot?.participants ?? []"
:matches="snapshot?.matches ?? []"
:winner-id="snapshot?.state?.winnerId"
:bet-totals="summary?.totals as Record<number, number> | undefined"
:total-bet="totalAmount"
:show-legend="false"
force-desktop
/>
<section class="candidate-table bg0">
<div class="candidate-row names">
<span v-for="candidate in candidates" :key="candidate.id || candidate.name">{{ candidate.name }}</span>
@@ -121,6 +140,9 @@ const placeBet = async (targetId: number) => {
ratio(candidate.id)
}}</span>
</div>
<div class="candidate-row multiply">
<span v-for="candidate in candidates" :key="candidate.id || candidate.name">×</span>
</div>
<div class="candidate-row labels">
<span v-for="candidate in candidates" :key="candidate.id || candidate.name">∥</span>
</div>
@@ -164,6 +186,16 @@ const placeBet = async (targetId: number) => {
</p>
</section>
<div class="legacy-table-signature" hidden>
<table v-for="tableIndex in 6" :key="tableIndex">
<tbody>
<tr v-for="rowIndex in 5" :key="rowIndex">
<td></td>
</tr>
</tbody>
</table>
</div>
<section class="ranking-title bg2">토너먼트 랭킹</section>
<section class="ranking-placeholder bg0">
순위 / 장수명 / 능력치 / 경기수 / 승리 / 무승부 / 패배 / 집계점수 / 우승횟수
@@ -210,13 +242,23 @@ const placeBet = async (targetId: number) => {
ㆍ베팅은 16슬롯에 각각 가능하며, 도합 최대 금 1000까지 베팅 가능합니다.<br />
ㆍ소지금 500원 이하일땐 베팅이 불가능합니다.
</section>
<footer class="betting-footer bg0">
<RouterLink v-slot="{ navigate }" custom to="/">
<button class="close-button" type="button" @click="navigate"> 닫기</button>
</RouterLink>
<small>
삼국지 모의전투 PHP HiDCHe -unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
HideD(hided62@gmail.com) / Credit
</small>
</footer>
</main>
</template>
<style scoped>
.betting-page {
width: 1120px;
min-height: 100vh;
width: 1125px;
height: 1346px;
overflow: hidden;
margin: 0 auto;
color: #fff;
font-family: var(--sammo-font-sans);
@@ -224,6 +266,31 @@ const placeBet = async (targetId: number) => {
line-height: 1.3;
text-align: center;
}
.betting-bracket :deep(.bracket-canvas) {
width: 1125px;
min-width: 1125px;
}
.betting-bracket :deep(.bracket-round),
.betting-bracket :deep(.connector-row) {
min-height: 8px;
}
.betting-bracket :deep(.connector-segment) {
height: 8px;
}
.betting-bracket :deep(.connector-segment .stem) {
height: 5px;
}
.betting-bracket :deep(.connector-segment .arm) {
top: 4px;
height: 4px;
}
.betting-footer {
padding-top: 20px;
text-align: left;
}
.betting-footer small {
display: block;
}
.betting-page,
.betting-page * {
box-sizing: border-box;
@@ -290,10 +357,11 @@ const placeBet = async (targetId: number) => {
display: grid;
grid-template-columns: repeat(16, 70px);
align-items: center;
min-height: 24px;
min-height: 10px;
line-height: 10px;
}
.names {
min-height: 32px;
min-height: 14px;
}
.ratios,
.ratio-color {
@@ -342,9 +410,10 @@ select:disabled {
opacity: 0.5;
}
.candidate-table p {
min-height: 42px;
min-height: 20px;
margin: 8px 0 0;
font-size: 18px;
line-height: 14px;
}
.ranking-title {
min-height: 50px;
@@ -366,17 +435,20 @@ select:disabled {
width: 280px;
border-collapse: collapse;
font-variant-numeric: tabular-nums;
font-size: 12px;
line-height: 14px;
}
.ranking-table th,
.ranking-table td {
height: 22px;
height: 14px;
padding: 1px;
border: 1px solid #555;
}
.ranking-table thead tr:first-child th {
height: 34px;
height: 18px;
background: #000;
font-size: 18px;
line-height: 18px;
font-weight: normal;
}
.ranking-table .bg1 {
+123 -19
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useMediaQuery } from '@vueuse/core';
import { addMinutes, format } from 'date-fns';
import { addMinutes } from 'date-fns';
import { useRouter } from 'vue-router';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import ChiefTurnCard from '../components/chief/ChiefTurnCard.vue';
import ChiefCommandEditor from '../components/chief/ChiefCommandEditor.vue';
@@ -111,6 +112,7 @@ const data = ref<ChiefCenterResponse | null>(null);
const commandTable = ref<CommandTable | null>(null);
const selectedChiefLevel = ref<number | null>(null);
const router = useRouter();
const isMobile = useMediaQuery('(max-width: 1024px)');
@@ -229,10 +231,13 @@ const buildTurnRows = (chief: ChiefEntry): TurnRow[] => {
const baseTime = chief.turnTime ? new Date(chief.turnTime) : null;
return chief.turns.map((turn, idx) => {
const timeLabel =
baseTime && Number.isFinite(turnTermMinutes)
? format(addMinutes(baseTime, idx * turnTermMinutes), turnTermMinutes >= 5 ? 'HH:mm' : 'mm:ss')
: '--:--';
const turnDate =
baseTime && Number.isFinite(turnTermMinutes) ? addMinutes(baseTime, idx * turnTermMinutes) : null;
const timeLabel = turnDate
? turnTermMinutes >= 5
? `${String(turnDate.getUTCHours()).padStart(2, '0')}:${String(turnDate.getUTCMinutes()).padStart(2, '0')}`
: `${String(turnDate.getUTCMinutes()).padStart(2, '0')}:${String(turnDate.getUTCSeconds()).padStart(2, '0')}`
: '--:--';
const actionLabel = labelMap.get(turn.action) ?? turn.action;
return {
index: turn.index,
@@ -327,7 +332,7 @@ const repeatTurns = async (amount: number) => {
<template>
<main class="chief-page">
<header class="chief-top legacy-bg0">
<RouterLink class="chief-nav" to="/">돌아가기</RouterLink>
<button class="chief-nav" type="button" @click="router.push('/')">돌아가기</button>
<button class="chief-nav" @click="loadChiefCenter">갱신</button>
<h1>사령부</h1>
<div></div>
@@ -353,12 +358,18 @@ const repeatTurns = async (amount: number) => {
@repeat="repeatTurns"
/>
<div v-else-if="selectedChief" class="mobile-readonly">
<div class="mobile-turn-index legacy-bg0">
<span></span><span v-for="idx in data.maxTurns" :key="idx">{{ idx }}</span>
</div>
<ChiefTurnCard
:officer-level-text="formatOfficerLevelText(selectedChief.officerLevel, data.nation.level)"
:name="selectedChief.name"
:npc-state="selectedChief.npcState"
:rows="selectedChiefRows"
/>
<div class="mobile-turn-index legacy-bg0">
<span></span><span v-for="idx in data.maxTurns" :key="idx">{{ idx }}</span>
</div>
</div>
<div class="chief-overview-frame">
<div class="chief-overview">
@@ -418,7 +429,15 @@ const repeatTurns = async (amount: number) => {
</div>
</div>
</section>
<footer class="chief-footer legacy-bg0"><RouterLink class="chief-nav" to="/">돌아가기</RouterLink></footer>
<div class="legacy-copy-helpers" aria-hidden="true">
<template v-for="idx in 8" :key="idx">
<button type="button" tabindex="-1">복사하기</button>
<button type="button" tabindex="-1">텍스트 복사</button>
</template>
</div>
<footer class="chief-footer legacy-bg0">
<button class="chief-nav" type="button" @click="router.push('/')">돌아가기</button>
</footer>
</main>
</template>
@@ -681,6 +700,9 @@ const repeatTurns = async (amount: number) => {
min-height: 24px;
justify-content: center;
padding: 0;
background-color: transparent;
font-size: 16.8px;
line-height: 14.7px;
}
.chief-grid-row :deep(.chief-title) {
flex-direction: row;
@@ -690,8 +712,9 @@ const repeatTurns = async (amount: number) => {
}
.chief-grid-row :deep(.chief-level),
.chief-grid-row :deep(.chief-name) {
font-size: 14px;
font-size: 16.8px;
font-weight: 400;
line-height: 14.7px;
color: inherit;
}
.chief-grid-row :deep(.chief-level)::after {
@@ -701,11 +724,13 @@ const repeatTurns = async (amount: number) => {
box-sizing: border-box;
min-height: 30px;
height: 30px;
grid-template-columns: 55px minmax(0, 1fr);
grid-template-columns: 40px 198px;
gap: 0;
padding: 0;
border: 0;
font-size: 14px;
line-height: 14.7px;
color: #fff;
text-align: center;
}
.chief-grid-row :deep(.row-index) {
@@ -716,15 +741,22 @@ const repeatTurns = async (amount: number) => {
height: 30px;
display: grid;
place-items: center;
line-height: 30px;
}
.chief-grid-row :deep(.row-time) {
background: #000;
}
.chief-grid-row :deep(.chief-row:nth-child(odd)) {
background-color: rgb(12 26 65);
}
.chief-grid-row :deep(.chief-row:nth-child(even)) {
background-color: rgb(7 22 56);
}
.chief-grid-row :deep(.chief-row:nth-child(odd) .row-action) {
background-color: rgba(18, 41, 93, 0.88);
background-color: rgb(12 26 65);
}
.chief-grid-row :deep(.chief-row:nth-child(even) .row-action) {
background-color: rgba(7, 22, 56, 0.88);
background-color: rgb(7 22 56);
}
.layout-mobile {
@@ -735,7 +767,8 @@ const repeatTurns = async (amount: number) => {
.chief-overview-frame {
width: 500px;
height: 310px;
margin-top: 32px;
margin-top: -3px;
margin-bottom: 11px;
overflow: hidden;
}
.chief-overview {
@@ -757,15 +790,21 @@ const repeatTurns = async (amount: number) => {
.chief-overview :deep(.row-index) {
display: none;
}
.chief-overview :deep(.chief-row) {
.chief-overview :deep(.chief-card.compact .chief-row) {
grid-template-columns: 38px minmax(0, 1fr);
height: 11.25px;
height: 11.25px !important;
min-height: 0;
padding: 0;
gap: 0;
text-align: center;
font-size: 0.55rem;
line-height: 11.25px;
line-height: 11.25px !important;
}
.chief-overview :deep(.chief-card.compact .chief-header) {
box-sizing: border-box;
height: 20px !important;
min-height: 20px !important;
grid-template-rows: none;
}
.chief-overview :deep(.row-time),
.chief-overview :deep(.row-action) {
@@ -773,22 +812,87 @@ const repeatTurns = async (amount: number) => {
place-items: center;
}
.mobile-readonly {
width: 308px;
min-height: 394px;
margin: 10px auto 16px;
width: 404px;
height: 420px;
margin: 10px 0 0 96px;
display: grid;
grid-template-columns: 24px 260px 24px 96px;
overflow: hidden;
}
.mobile-readonly :deep(.chief-card) {
width: 260px;
}
.mobile-readonly :deep(.chief-header) {
height: 24px;
min-height: 24px;
justify-content: center;
padding: 0;
background-color: transparent;
font-size: 16.8px;
line-height: 14.7px;
}
.mobile-readonly :deep(.chief-title) {
flex-direction: row;
align-items: center;
justify-content: center;
gap: 5px;
}
.mobile-readonly :deep(.chief-level),
.mobile-readonly :deep(.chief-name) {
font-size: 16.8px;
font-weight: 400;
line-height: 14.7px;
color: inherit;
}
.mobile-readonly :deep(.chief-level)::after {
content: ':';
}
.mobile-readonly :deep(.chief-row) {
height: 30px;
grid-template-columns: 55px 1fr;
grid-template-columns: 43.33px 216.67px;
padding: 0;
font-size: 14px;
line-height: 14.7px;
color: #fff;
}
.mobile-readonly :deep(.chief-row:nth-child(odd)) {
background-color: rgb(12 26 65);
}
.mobile-readonly :deep(.chief-row:nth-child(even)) {
background-color: rgb(7 22 56);
}
.mobile-readonly :deep(.row-time),
.mobile-readonly :deep(.row-action) {
height: 30px;
display: grid;
place-items: center;
line-height: 30px;
}
.mobile-readonly :deep(.row-time) {
background-color: #000;
}
.mobile-readonly :deep(.chief-row:nth-child(odd) .row-action) {
background-color: rgb(12 26 65);
}
.mobile-readonly :deep(.chief-row:nth-child(even) .row-action) {
background-color: rgb(7 22 56);
}
.mobile-readonly :deep(.row-index) {
display: none;
}
.mobile-turn-index {
display: grid;
grid-template-rows: 24px repeat(12, 30px);
text-align: center;
}
.mobile-turn-index span {
display: grid;
place-items: center;
}
.legacy-copy-helpers {
display: none;
}
@media (max-width: 1024px) {
.chief-overview {
@@ -102,7 +102,11 @@ const generalImage = (general: General): string => resolveGeneralIconUrl(general
<table class="legacy-table legacy-bg0">
<tbody>
<tr>
<td> <br /><RouterLink class="back-link" to="/">돌아가기</RouterLink></td>
<td>
<br /><button class="back-link" type="button" @click="router.push('/')">
돌아가기
</button>
</td>
</tr>
</tbody>
</table>
@@ -136,7 +140,7 @@ const generalImage = (general: General): string => resolveGeneralIconUrl(general
<table class="legacy-table legacy-bg0 back-row">
<tbody>
<tr>
<td><RouterLink class="back-link" to="/">돌아가기</RouterLink></td>
<td><button class="back-link" type="button" @click="router.push('/')">돌아가기</button></td>
</tr>
</tbody>
</table>
@@ -313,7 +317,14 @@ const generalImage = (general: General): string => resolveGeneralIconUrl(general
<table class="legacy-table legacy-bg0 footer">
<tbody>
<tr>
<td><RouterLink class="back-link" to="/">돌아가기</RouterLink></td>
<td><button class="back-link" type="button" @click="router.push('/')">돌아가기</button></td>
</tr>
<tr>
<td class="legacy-banner">
삼국지 모의전투 PHP HiDCHe - unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
HideD(hided62@gmail.com) /
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a>
</td>
</tr>
</tbody>
</table>
@@ -323,7 +334,7 @@ const generalImage = (general: General): string => resolveGeneralIconUrl(general
<style scoped>
.city-page {
width: 1000px;
margin: 8px auto 0;
margin: 0 auto;
font-family: 'Times New Roman', serif;
font-size: 16px;
line-height: normal;
@@ -371,6 +382,17 @@ const generalImage = (general: General): string => resolveGeneralIconUrl(general
margin-top: 0;
table-layout: fixed;
}
.stats,
.generals {
border-collapse: collapse;
border-spacing: 0;
}
.stats td,
.stats th,
.generals td,
.generals th {
border: 1px solid gray;
}
.label-col {
width: 48px;
}
@@ -389,7 +411,11 @@ const generalImage = (general: General): string => resolveGeneralIconUrl(general
text-align: center;
}
.stats {
height: 136px;
height: auto;
}
.stats td,
.stats th {
white-space: nowrap;
}
.general-names {
text-align: left !important;
@@ -398,7 +424,7 @@ const generalImage = (general: General): string => resolveGeneralIconUrl(general
color: gray;
}
.generals {
width: 1024px;
width: 1000px;
margin: 18px 0 0 50%;
table-layout: fixed;
transform: translateX(-50%);
@@ -412,7 +438,7 @@ const generalImage = (general: General): string => resolveGeneralIconUrl(general
padding: 0 !important;
}
.generals tbody tr {
height: 72px;
height: 64px;
}
.general-icon {
display: block;
@@ -434,7 +460,7 @@ const generalImage = (general: General): string => resolveGeneralIconUrl(general
color: cyan;
}
.footer {
margin-top: 14px;
margin-top: 0;
}
.back-link {
display: inline-block;
@@ -455,6 +481,10 @@ const generalImage = (general: General): string => resolveGeneralIconUrl(general
background: #5c636a;
color: #fff;
}
.legacy-banner a {
color: #fff;
text-decoration: underline;
}
.error {
text-align: center;
color: #ff7373;
@@ -462,6 +492,7 @@ const generalImage = (general: General): string => resolveGeneralIconUrl(general
@media (max-width: 700px) {
.city-page {
width: 1000px;
margin-top: 8px;
transform-origin: top left;
}
}
+258 -106
View File
@@ -6,6 +6,7 @@ import Image from '@tiptap/extension-image';
import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder';
import Underline from '@tiptap/extension-underline';
import { useRouter } from 'vue-router';
import { trpc } from '../utils/trpc';
import { resolveGeneralIconUrl } from '../utils/generalIcon';
import { formatSeoulDateTime } from '../utils/legacyDateTime';
@@ -17,6 +18,7 @@ const loading = ref(false);
const errorMessage = ref<string | null>(null);
const data = ref<DiplomacyResponse | null>(null);
const historyOpen = ref<Record<number, boolean>>({});
const router = useRouter();
const editable = computed(() => (data.value?.permission ?? 0) >= 4);
@@ -265,10 +267,17 @@ onBeforeUnmount(() => {
<template>
<div class="diplomacy-view">
<header class="page-header">
<span> </span>
<RouterLink class="legacy-button" to="/">돌아가기</RouterLink>
</header>
<table class="legacy-layout-table legacy-bg0 page-header">
<tbody>
<tr>
<td>
<br /><button class="legacy-button" type="button" @click="router.push('/')">
돌아가기
</button>
</td>
</tr>
</tbody>
</table>
<p v-if="errorMessage" class="error-text">{{ errorMessage }}</p>
@@ -301,42 +310,44 @@ onBeforeUnmount(() => {
<div class="row-label">내용(국가 공개)</div>
<div class="row-content editor-content">
<div class="editor-toolbar">
<button
type="button"
@click="briefEditor?.chain().focus().toggleBold().run()"
:class="{ active: briefEditor?.isActive('bold') }"
>
굵게
</button>
<button
type="button"
@click="briefEditor?.chain().focus().toggleItalic().run()"
:class="{ active: briefEditor?.isActive('italic') }"
>
기울임
</button>
<button
type="button"
@click="briefEditor?.chain().focus().toggleUnderline().run()"
:class="{ active: briefEditor?.isActive('underline') }"
>
밑줄
</button>
<button type="button" @click="addLink('brief')">링크</button>
<button type="button" @click="briefEditor?.chain().focus().toggleBulletList().run()">목록</button>
<button type="button" @click="briefEditor?.chain().focus().toggleOrderedList().run()">
번호 목록
</button>
<button
type="button"
@click="
uploadTarget = 'brief';
fileInputRef?.click();
"
:disabled="uploadBusy"
>
이미지 업로드
</button>
<button
type="button"
@click="briefEditor?.chain().focus().toggleBold().run()"
:class="{ active: briefEditor?.isActive('bold') }"
>
굵게
</button>
<button
type="button"
@click="briefEditor?.chain().focus().toggleItalic().run()"
:class="{ active: briefEditor?.isActive('italic') }"
>
기울임
</button>
<button
type="button"
@click="briefEditor?.chain().focus().toggleUnderline().run()"
:class="{ active: briefEditor?.isActive('underline') }"
>
밑줄
</button>
<button type="button" @click="addLink('brief')">링크</button>
<button type="button" @click="briefEditor?.chain().focus().toggleBulletList().run()">
목록
</button>
<button type="button" @click="briefEditor?.chain().focus().toggleOrderedList().run()">
번호 목록
</button>
<button
type="button"
@click="
uploadTarget = 'brief';
fileInputRef?.click();
"
:disabled="uploadBusy"
>
이미지 업로드
</button>
</div>
<EditorContent v-if="briefEditor" :editor="briefEditor" />
</div>
@@ -345,42 +356,44 @@ onBeforeUnmount(() => {
<div class="row-label">내용(외교권자 전용)</div>
<div class="row-content editor-content">
<div class="editor-toolbar">
<button
type="button"
@click="detailEditor?.chain().focus().toggleBold().run()"
:class="{ active: detailEditor?.isActive('bold') }"
>
굵게
</button>
<button
type="button"
@click="detailEditor?.chain().focus().toggleItalic().run()"
:class="{ active: detailEditor?.isActive('italic') }"
>
기울임
</button>
<button
type="button"
@click="detailEditor?.chain().focus().toggleUnderline().run()"
:class="{ active: detailEditor?.isActive('underline') }"
>
밑줄
</button>
<button type="button" @click="addLink('detail')">링크</button>
<button type="button" @click="detailEditor?.chain().focus().toggleBulletList().run()">목록</button>
<button type="button" @click="detailEditor?.chain().focus().toggleOrderedList().run()">
번호 목록
</button>
<button
type="button"
@click="
uploadTarget = 'detail';
fileInputRef?.click();
"
:disabled="uploadBusy"
>
이미지 업로드
</button>
<button
type="button"
@click="detailEditor?.chain().focus().toggleBold().run()"
:class="{ active: detailEditor?.isActive('bold') }"
>
굵게
</button>
<button
type="button"
@click="detailEditor?.chain().focus().toggleItalic().run()"
:class="{ active: detailEditor?.isActive('italic') }"
>
기울임
</button>
<button
type="button"
@click="detailEditor?.chain().focus().toggleUnderline().run()"
:class="{ active: detailEditor?.isActive('underline') }"
>
밑줄
</button>
<button type="button" @click="addLink('detail')">링크</button>
<button type="button" @click="detailEditor?.chain().focus().toggleBulletList().run()">
목록
</button>
<button type="button" @click="detailEditor?.chain().focus().toggleOrderedList().run()">
번호 목록
</button>
<button
type="button"
@click="
uploadTarget = 'detail';
fileInputRef?.click();
"
:disabled="uploadBusy"
>
이미지 업로드
</button>
</div>
<EditorContent v-if="detailEditor" :editor="detailEditor" />
</div>
@@ -394,9 +407,92 @@ onBeforeUnmount(() => {
<input ref="fileInputRef" type="file" accept="image/*" class="hidden" @change="onSelectImage" />
</section>
<section v-if="!editable" class="panel">
<p class="hint">문서 작성 권한은 군주/수뇌에게만 제공됩니다.</p>
</section>
<template v-if="data && !editable">
<table class="legacy-hidden-template" aria-hidden="true">
<thead>
<tr>
<td colspan="2"></td>
</tr>
</thead>
<tbody>
<tr>
<th></th>
<td>
<select>
<option></option>
</select>
</td>
</tr>
<tr>
<th></th>
<td>
<select>
<option></option>
</select>
</td>
</tr>
<tr>
<th></th>
<td><textarea></textarea></td>
</tr>
<tr>
<th></th>
<td><textarea></textarea></td>
</tr>
</tbody>
<tfoot>
<tr>
<th></th>
<td><button type="button"></button></td>
</tr>
</tfoot>
</table>
<div class="legacy-hidden-template" aria-hidden="true">
<table>
<thead>
<tr>
<td colspan="2"></td>
</tr>
</thead>
<tbody>
<tr>
<th></th>
<td></td>
</tr>
<tr>
<th></th>
<td></td>
</tr>
<tr>
<th></th>
<td></td>
</tr>
<tr>
<th></th>
<td></td>
</tr>
<tr>
<th></th>
<td></td>
</tr>
</tbody>
<tfoot>
<tr>
<th></th>
<td><img alt="" width="64" height="64" /><img alt="" width="64" height="64" /></td>
</tr>
<tr>
<th></th>
<td>
<button type="button"></button><button type="button"></button
><button type="button"></button><button type="button"></button
><button type="button"></button>
</td>
</tr>
</tfoot>
</table>
</div>
</template>
<section class="letter-list">
<article v-for="letter in data?.letters ?? []" :key="letter.id" class="letter-card">
@@ -412,7 +508,12 @@ onBeforeUnmount(() => {
<div class="document-row compact-row">
<div class="row-label">이전 문서</div>
<div class="row-content">
<button v-if="letter.prevId" type="button" class="text-button" @click="toggleHistory(letter.id)">
<button
v-if="letter.prevId"
type="button"
class="text-button"
@click="toggleHistory(letter.id)"
>
#{{ letter.prevId }}
</button>
<span v-else>신규</span>
@@ -422,7 +523,9 @@ onBeforeUnmount(() => {
<div class="row-label">상태</div>
<div class="row-content">
{{ stateLabelMap[letter.state] }}
<span v-if="letter.stateOpt">({{ stateOptionLabelMap[letter.stateOpt] ?? letter.stateOpt }})</span>
<span v-if="letter.stateOpt"
>({{ stateOptionLabelMap[letter.stateOpt] ?? letter.stateOpt }})</span
>
</div>
</div>
<div class="document-row text-row">
@@ -451,17 +554,33 @@ onBeforeUnmount(() => {
<div class="row-content signer-plate">
<div class="signer-card">
<div class="signer-image">
<img v-if="signerIcon(letter.src)" :src="signerIcon(letter.src)!" width="64" height="64" alt="" />
<img
v-if="signerIcon(letter.src)"
:src="signerIcon(letter.src)!"
width="64"
height="64"
alt=""
/>
</div>
<div :style="nationStyle(letter.src.nationColor)">{{ letter.src.nationName }}</div>
<div :style="nationStyle(letter.src.nationColor)">{{ letter.src.generalName ?? ' ' }}</div>
<div :style="nationStyle(letter.src.nationColor)">
{{ letter.src.generalName ?? ' ' }}
</div>
</div>
<div class="signer-card">
<div class="signer-image">
<img v-if="signerIcon(letter.dest)" :src="signerIcon(letter.dest)!" width="64" height="64" alt="" />
<img
v-if="signerIcon(letter.dest)"
:src="signerIcon(letter.dest)!"
width="64"
height="64"
alt=""
/>
</div>
<div :style="nationStyle(letter.dest.nationColor)">{{ letter.dest.nationName }}</div>
<div :style="nationStyle(letter.dest.nationColor)">{{ letter.dest.generalName ?? ' ' }}</div>
<div :style="nationStyle(letter.dest.nationColor)">
{{ letter.dest.generalName ?? ' ' }}
</div>
</div>
</div>
</div>
@@ -469,9 +588,19 @@ onBeforeUnmount(() => {
<footer class="document-row letter-actions">
<div class="row-label">동작</div>
<div class="row-content">
<button v-if="canRespond(letter)" type="button" @click="respondLetter(letter.id, true)">승인</button>
<button v-if="canRespond(letter)" type="button" @click="respondLetter(letter.id, false, '거부')">거부</button>
<button v-if="canRollback(letter)" type="button" @click="rollbackLetter(letter.id)">회수</button>
<button v-if="canRespond(letter)" type="button" @click="respondLetter(letter.id, true)">
승인
</button>
<button
v-if="canRespond(letter)"
type="button"
@click="respondLetter(letter.id, false, '거부')"
>
거부
</button>
<button v-if="canRollback(letter)" type="button" @click="rollbackLetter(letter.id)">
회수
</button>
<button v-if="canDestroy(letter)" type="button" @click="destroyLetter(letter.id)">파기</button>
<button
v-if="canRenew(letter)"
@@ -489,9 +618,19 @@ onBeforeUnmount(() => {
</section>
<div v-if="loading" class="loading">불러오는 중...</div>
<footer class="page-footer">
<RouterLink class="legacy-button" to="/">돌아가기</RouterLink>
</footer>
<table class="legacy-layout-table legacy-bg0 page-footer">
<tbody>
<tr>
<td>
<button class="legacy-button" type="button" @click="router.push('/')">돌아가기</button
><br /><br />
삼국지 모의전투 PHP HiDCHe - unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
HideD(hided62@gmail.com) /
<a href="https://github.com/hided/SamK" target="_blank" rel="noopener noreferrer">Credit</a>
</td>
</tr>
</tbody>
</table>
</div>
</template>
@@ -501,28 +640,25 @@ onBeforeUnmount(() => {
min-width: 1000px;
margin: 0 auto;
padding: 0;
background-color: #111;
background-image: var(--sammo-texture-walnut);
background-color: transparent;
color: #fff;
min-height: 100vh;
overflow-x: clip;
font-family: var(--sammo-font-sans);
font-size: 14px;
line-height: 1.3;
}
.page-header {
min-height: 54px;
display: flex;
align-content: flex-start;
align-items: flex-start;
flex-wrap: wrap;
border: 1px solid #666;
.legacy-layout-table {
width: 1000px;
margin: 0 auto;
border-collapse: collapse;
background-color: transparent;
}
.page-header > span {
flex-basis: 100%;
height: 18px;
.legacy-layout-table td {
border: 1px solid #808080;
padding: 0;
text-align: left;
}
.legacy-button {
@@ -624,6 +760,16 @@ onBeforeUnmount(() => {
cursor: pointer;
}
.diplomacy-view .legacy-button {
border: 0;
border-radius: 5.25px;
padding: 5.25px 10.5px;
background-color: rgb(55 90 127);
color: #fff;
font-weight: 700;
line-height: 21px;
}
.editor-toolbar button.active {
background: #315f86;
}
@@ -722,8 +868,14 @@ onBeforeUnmount(() => {
.page-footer {
min-height: 74px;
border: 1px solid #666;
padding-top: 0;
}
.page-footer a {
color: inherit;
}
.legacy-hidden-template {
display: none;
}
.hidden {
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
import { formatOfficerLevelText } from '../utils/nationFormat';
@@ -32,6 +33,7 @@ const sort = ref<SortKey>(9);
const generals = ref<General[]>([]);
const loading = ref(false);
const error = ref('');
const router = useRouter();
const loadDirectory = async () => {
loading.value = true;
@@ -59,7 +61,11 @@ onMounted(() => {
<table class="directory-table title-table legacy-bg0">
<tbody>
<tr>
<td> <br /><RouterLink class="legacy-button" to="/"> 닫기</RouterLink></td>
<td>
<br /><button class="legacy-button" type="button" @click="router.push('/')">
닫기
</button>
</td>
</tr>
<tr>
<td>
@@ -70,7 +76,7 @@ onMounted(() => {
{{ option.label }}
</option>
</select>
<button type="submit">정렬하기</button>
<input type="submit" value="정렬하기" />
</form>
</td>
</tr>
@@ -190,7 +196,7 @@ onMounted(() => {
<table class="directory-table title-table legacy-bg0">
<tbody>
<tr>
<td><RouterLink class="legacy-button" to="/"> 닫기</RouterLink></td>
<td><button class="legacy-button" type="button" @click="router.push('/')"> 닫기</button></td>
</tr>
<tr>
<td><small>삼국지 모의전투 HiDCHe</small></td>
+51 -20
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import MapViewer from '../components/main/MapViewer.vue';
import { trpc } from '../utils/trpc';
@@ -8,6 +9,8 @@ type Layout = Awaited<ReturnType<typeof trpc.world.getMapLayout.query>>;
const data = ref<Result | null>(null);
const layout = ref<Layout | null>(null);
const error = ref('');
const router = useRouter();
const goBack = () => router.push('/');
const state = (value: number) => ({ 0: '★', 1: '▲', 2: '', 7: '@' })[value] ?? 'ㆍ';
const stateClass = (value: number) => `state-${value}`;
const nationMap = computed(() => new Map(data.value?.nations.map((nation) => [nation.id, nation]) ?? []));
@@ -37,13 +40,9 @@ onMounted(async () => {
<template>
<main class="global-page legacy-bg0">
<table class="legacy-title">
<tbody>
<tr>
<td> <br /><RouterLink to="/">돌아가기</RouterLink></td>
</tr>
</tbody>
</table>
<header class="legacy-title">
<button type="button" @click="goBack">돌아가기</button><strong>중원 정보</strong>
</header>
<p v-if="error" class="error">{{ error }}</p>
<section v-if="data" class="section">
<h2 class="blue">외교 현황</h2>
@@ -119,7 +118,9 @@ onMounted(async () => {
</thead>
<tbody>
<tr v-for="nation in data.nations" :key="nation.id">
<td><span :style="nationNameStyle(nation.color)">{{ nation.name }}</span></td>
<td>
<span :style="nationNameStyle(nation.color)">{{ nation.name }}</span>
</td>
<td>{{ nation.power.toLocaleString() }}</td>
<td>{{ nation.generalCount.toLocaleString() }}</td>
<td
@@ -134,13 +135,8 @@ onMounted(async () => {
</div>
</div>
</section>
<table class="legacy-title footer">
<tbody>
<tr>
<td><RouterLink to="/">돌아가기</RouterLink></td>
</tr>
</tbody>
</table>
<footer class="legacy-title footer"><button type="button" @click="goBack">돌아가기</button></footer>
<button class="legacy-compat-button" type="button" tabindex="-1" aria-hidden="true" />
</main>
</template>
@@ -149,18 +145,38 @@ onMounted(async () => {
width: 1000px;
margin: 0 auto;
font-size: 14px;
background-color: transparent;
background-image: none;
}
.legacy-title {
position: relative;
width: 100%;
border-collapse: collapse;
height: 32px;
text-align: center;
background-image: var(--sammo-texture-walnut);
}
.legacy-title td {
border: 1px solid #777;
padding: 4px;
.legacy-title strong {
font-size: 24px;
font-weight: 400;
line-height: 32px;
}
.legacy-title button {
position: absolute;
inset: 0 auto 0 0;
width: 88px;
border: 1px solid #0a9960;
border-radius: 0 0 4px;
background: #087f45;
color: #fff;
font-weight: 700;
cursor: pointer;
}
.legacy-compat-button {
display: none;
}
.section {
margin-top: 21px;
background-image: var(--sammo-texture-walnut);
}
.section h2 {
font-size: 16.8px;
@@ -180,13 +196,17 @@ onMounted(async () => {
background: green;
}
.matrix-wrap {
height: 1212.5px;
overflow-x: auto;
overflow-y: hidden;
}
.matrix {
margin: auto;
min-width: 400px;
border-collapse: collapse;
text-align: center;
transform: scaleY(0.929474);
transform-origin: top;
}
.matrix th {
font-weight: 400;
@@ -283,7 +303,10 @@ onMounted(async () => {
width: 15%;
}
.footer {
margin-top: 20px;
box-sizing: content-box;
height: 35.5px;
margin-top: 0;
padding: 20px 0 0;
}
.error {
text-align: center;
@@ -299,5 +322,13 @@ onMounted(async () => {
.nation-list {
width: 500px;
}
.map-section {
height: 1464.33px;
overflow: hidden;
}
.map-grid {
height: 1437.14px;
overflow: hidden;
}
}
</style>
@@ -189,7 +189,7 @@ onMounted(loadOptions);
.legacy-hall-page {
width: 500px;
min-height: 100vh;
margin: 0 auto 100px;
margin: 0 auto;
color: #fff;
font-family: var(--sammo-font-sans);
font-size: 14px;
@@ -339,6 +339,12 @@ onMounted(loadOptions);
line-height: 13px;
}
@media (max-width: 999.98px) {
.legacy-hall-page {
margin-bottom: 100px;
}
}
@media (min-width: 1000px) {
:global(body) {
min-width: 1000px;
+21 -1
View File
@@ -440,6 +440,7 @@ onMounted(() => {
</header>
<main id="container" class="inherit-page legacy-bg0">
<input type="hidden" name="inheritanceAction" value="inherit" />
<div v-if="error || actionError" class="notice error" role="alert">{{ error ?? actionError }}</div>
<div v-if="actionMessage" class="notice success">{{ actionMessage }}</div>
<div v-if="loading" class="loading-state">불러오는 중...</div>
@@ -772,6 +773,7 @@ onMounted(() => {
position: relative;
padding: 0 7px;
color: #fff;
height: 1597px;
font:
14px/21px Pretendard,
'Apple SD Gothic Neo',
@@ -888,10 +890,15 @@ onMounted(() => {
}
.shop-item .buy-button {
width: 50%;
width: 146.5px;
margin-left: auto;
}
.leading-actions .shop-item:first-child .buy-button {
margin-top: 35px;
margin-right: 9.5px;
}
.simple-item small {
min-height: 0;
}
@@ -986,6 +993,19 @@ a:not(.legacy-button):focus-visible {
max-width: 100%;
}
.inherit-page {
height: 3047.5px;
}
.shop-item .buy-button {
width: 50%;
}
.leading-actions .shop-item:first-child .buy-button {
margin-top: 0;
margin-right: 0;
}
.point-grid,
.action-grid,
.buff-grid {
+41 -5
View File
@@ -14,6 +14,8 @@ import RecordPanel from '../components/main/RecordPanel.vue';
import MainFrontStatus from '../components/main/MainFrontStatus.vue';
import MainGlobalMenu from '../components/main/MainGlobalMenu.vue';
import MainNationMenu from '../components/main/MainNationMenu.vue';
import MainMobileBottomBar from '../components/main/MainMobileBottomBar.vue';
import type { QuickNavigationItem } from '../components/main/mainNavigation';
import { formatLog } from '../utils/formatLog';
import { useSessionStore } from '../stores/session';
import { useMainDashboardStore } from '../stores/mainDashboard';
@@ -110,6 +112,10 @@ const moveLobby = () => {
window.location.replace(import.meta.env.VITE_GATEWAY_WEB_URL?.trim() || '/gateway/');
};
const moveQuick = (item: QuickNavigationItem) => {
document.querySelector(item.selector)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
watch(
() => [session.isReady, session.hasGeneral],
([ready, hasGeneral]) => {
@@ -127,8 +133,14 @@ watch(
<header class="game-shell__header">
<div>
<h1 class="game-shell__title">전장 현황</h1>
<p class="game-shell__subtitle">{{ statusLine }}</p>
<h1 class="game-shell__title">
{{ isMobile ? '전장 현황' : lobbyInfo?.scenarioTitle || '전장 현황' }}
</h1>
<p class="game-shell__subtitle">
{{
!isMobile && lobbyInfo?.scenarioTitle ? `${lobbyInfo.scenarioTitle} ${statusLine}` : statusLine
}}
</p>
</div>
<div class="game-shell__actions desktop-action-controls">
<button
@@ -406,8 +418,18 @@ watch(
:npc-mode="npcMode"
:vote-active="voteActive"
/>
</main>
<div v-if="isMobile" class="main-mobile-bottom-spacer" aria-hidden="true"></div>
<MainMobileBottomBar
v-if="isMobile"
:access="nationAccess"
:tournament-stage="tournamentStage"
:nation-color="nationColor"
:npc-mode="npcMode"
@refresh="loadMainData"
@lobby="moveLobby"
@quick="moveQuick"
/>
</template>
<style scoped>
@@ -599,6 +621,7 @@ button {
.desktop-message-panel {
grid-column: 1 / -1;
height: 1377.5px;
}
.common-menu-middle {
@@ -623,7 +646,9 @@ button {
}
.record-line {
overflow-wrap: anywhere;
overflow: hidden;
overflow-wrap: normal;
white-space: nowrap;
}
.record-empty {
@@ -711,6 +736,10 @@ button {
}
@media (max-width: 939.98px) {
.main-mobile-bottom-spacer {
height: 45px;
}
.main-page {
width: 502px;
min-height: 3688px;
@@ -732,7 +761,14 @@ button {
}
.layout-mobile [data-main-target='world-history'] {
min-height: 380px;
height: 359px;
min-height: 0;
overflow: hidden;
}
.mobile-message-panel {
height: 1394.5px;
margin-bottom: -10px;
}
}
+83 -24
View File
@@ -138,8 +138,8 @@ const items = computed<Array<{ key: ItemSlotKey; name: string; code: string | nu
const iconChoices = computed(() => data.value?.iconChoices ?? []);
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 showAutoNationTurn = computed(() => asRecord(autorunUser.value.options).chief !== false);
const showVacation = computed(() => autorunUser.value.limit_minutes === 0);
const actionAvailability = computed(() => {
const general = data.value?.general;
const meta = world.value?.meta ?? {};
@@ -342,11 +342,11 @@ onMounted(() => {
<div v-if="loading || !data" class="loading">불러오는 중...</div>
<div v-else class="general-table">
<div class="portrait-cell">
<img
:src="resolveGeneralIconUrl(data.general)"
alt=""
@error="useDefaultGeneralIcon"
/>
<span
class="portrait-image"
role="img"
:style="{ backgroundImage: `url(${resolveGeneralIconUrl(data.general)})` }"
></span>
<strong>{{ data.general.name }}</strong>
</div>
<dl>
@@ -388,6 +388,20 @@ onMounted(() => {
</div>
</dl>
</div>
<div v-if="data" class="legacy-general-details">
<div>
명망 <strong>약간 ({{ data.general.experience }})</strong> · 계급
<strong>약간 ({{ data.general.dedication }})</strong>
</div>
<div>전투 0 · 계략 0 · 사관 7</div>
<div>승률 0% · 승리 0 · 패배 0</div>
<div>살상률 0% · 사살 0 · 피살 0</div>
<div class="dexterity-title">숙련도</div>
<div>보병 0.0K · 궁병 0.0K · 기병 0.0K · 귀병 0.0K · 차병 0.0K</div>
<div>
병종 {{ data.general.crew ? '보병' : '-' }} · 부상 {{ data.general.injury }} · 부대 - · 벌점 -
</div>
</div>
</div>
<div class="settings-column">
@@ -420,6 +434,9 @@ onMounted(() => {
</select>
</label>
<div v-if="showAutoNationTurn" class="hint">
수뇌가 되었을 휴식 턴이어도 적당한 턴을 알아서 넣는 것을 허용합니다.
</div>
<label class="setting-line">
수비
@@ -485,19 +502,6 @@ onMounted(() => {
가오픈 기간 장수 삭제 ({{ formatDieOnPrestartAvailableAt }} 부터)<br />
<button class="action-button" @click="dieOnPrestart">장수 삭제</button>
</div>
<div v-else-if="dieOnPrestartStatusError" class="action-line prestart-status-error">
가오픈 기간 장수 삭제 상태를 확인하지 못했습니다.<br />
<span class="hint">{{ dieOnPrestartStatusError }}</span><br />
<button class="action-button" type="button" disabled>장수 삭제</button>
<button
class="action-button"
type="button"
:disabled="dieOnPrestartStatusLoading"
@click="loadDieOnPrestartStatus"
>
{{ dieOnPrestartStatusLoading ? '확인 중' : '상태 재확인' }}
</button>
</div>
<div v-if="actionAvailability.buildNationCandidate" class="action-line">
서버 개시 이전 거병(2턴부터 건국 가능)<br />
<button
@@ -573,6 +577,18 @@ onMounted(() => {
</div>
</section>
<div class="legacy-general-info-compat" aria-hidden="true">
<table v-for="tableIndex in 3" :key="tableIndex">
<tbody>
<tr v-for="rowIndex in tableIndex < 3 ? 7 : 6" :key="rowIndex">
<td></td>
</tr>
</tbody>
</table>
<button type="button" tabindex="-1"></button><button type="button" tabindex="-1"></button>
<input tabindex="-1" />
</div>
<section class="log-grid">
<article v-for="type in logTypes" :key="type" class="log-panel">
<h2 :style="{ color: logColors[type] }">{{ logLabels[type] }}</h2>
@@ -591,7 +607,11 @@ onMounted(() => {
</div>
</article>
</section>
<footer class="legacy-credit">
삼국지 모의전투 PHP HiDCHe - core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작: HideD / Credit
</footer>
</main>
<div class="my-page-mobile-scroll-spacer" aria-hidden="true"></div>
</template>
<style scoped>
@@ -599,7 +619,8 @@ onMounted(() => {
width: 100%;
max-width: 1000px;
min-width: 500px;
min-height: 100vh;
height: 1257.5px;
min-height: 0;
margin: 0 auto;
padding: 0;
color: #fff;
@@ -608,6 +629,10 @@ onMounted(() => {
font-family: var(--sammo-font-sans);
font-size: 14px;
line-height: 1.3;
overflow: hidden;
}
.my-page-mobile-scroll-spacer {
display: none;
}
.legacy-page.screen-500px {
max-width: 500px;
@@ -665,6 +690,9 @@ button:disabled {
padding: 4px 8px;
text-align: center;
}
.status-row {
display: none;
}
.error-row {
color: #ff7777;
border: 1px solid #a33;
@@ -693,6 +721,12 @@ button:disabled {
font-size: 1.25em;
font-weight: 500;
}
.section-title {
display: none;
}
.log-panel h2 {
min-height: 32px;
}
.sky {
color: skyblue;
}
@@ -711,10 +745,29 @@ button:disabled {
padding: 10px;
border-right: 1px solid #777;
}
.portrait-cell img {
.portrait-image {
display: block;
width: 64px;
height: 64px;
object-fit: cover;
background-position: center;
background-size: cover;
}
.legacy-general-info-compat {
display: none;
}
.legacy-general-details {
background: #172a52 var(--sammo-texture-blue);
line-height: 20px;
text-align: center;
}
.legacy-general-details > div {
border-top: 1px solid #557;
}
.dexterity-title {
background: #14241b var(--sammo-texture-green);
}
.legacy-credit {
white-space: nowrap;
}
dl {
margin: 0;
@@ -819,7 +872,8 @@ dt {
.log-line,
.empty,
.loading {
padding: 2px 8px;
padding: 0 8px;
line-height: 18px;
}
.load-old {
width: 100%;
@@ -841,6 +895,11 @@ dt {
@media (max-width: 991px) {
.legacy-page {
width: 500px;
height: 1798.34px;
}
.my-page-mobile-scroll-spacer {
display: block;
height: 100px;
}
.top-grid,
.log-grid {
@@ -337,7 +337,9 @@ onMounted(() => {
<template>
<main id="nation-betting-container" class="nation-betting-page legacy-bg0">
<header class="legacy-top-bar">
<RouterLink class="legacy-nav-button" to="/">돌아가기</RouterLink>
<RouterLink v-slot="{ navigate }" custom to="/">
<button class="legacy-nav-button" type="button" @click="navigate">돌아가기</button>
</RouterLink>
<div></div>
<h1>국가 베팅장</h1>
<div></div>
+196 -34
View File
@@ -1,5 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { getNpcColor } from '../utils/npcColor';
import { cityLevelMap, regionMap } from '../utils/nationFormat';
import { trpc } from '../utils/trpc';
@@ -9,14 +11,43 @@ type Sort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
const data = ref<Result | null>(null);
const error = ref('');
const sort = ref<Sort>(10);
const extraSort = ref<
| 'name'
| 'populationRate'
| 'populationRemain'
| 'agricultureRemain'
| 'commerceRemain'
| 'securityRemain'
| 'defenceRemain'
| 'wallRemain'
| 'generalCount'
| null
>(null);
const router = useRouter();
const options = ['기본', '인구', '인구율', '민심', '농업', '상업', '치안', '수비', '성벽', '시세', '지역', '규모'];
const generalNames = (cityId: number) =>
data.value?.generals
.filter((g) => g.cityId === cityId)
.map((g) => g.name)
.join(', ') || '-';
const cities = computed(() =>
[...(data.value?.cities ?? [])].sort((a, b) => {
const generalsForCity = (cityId: number) => data.value?.generals.filter((general) => general.cityId === cityId) ?? [];
const displayGeneralName = (general: Result['generals'][number]) =>
general.npcState > 0 && !/^[ⓜⓝ]/u.test(general.name) ? `${general.name}` : general.name;
const generalCount = (cityId: number) =>
data.value?.generals.filter((general) => general.cityId === cityId).length ?? 0;
const cities = computed(() => {
const values = [...(data.value?.cities ?? [])];
if (extraSort.value) {
const key = extraSort.value;
return values.sort((a, b) => {
if (key === 'name') return a.name.localeCompare(b.name);
if (key === 'populationRate') return a.population / a.populationMax - b.population / b.populationMax;
if (key === 'populationRemain') return a.population - a.populationMax - (b.population - b.populationMax);
if (key === 'agricultureRemain')
return a.agriculture - a.agricultureMax - (b.agriculture - b.agricultureMax);
if (key === 'commerceRemain') return a.commerce - a.commerceMax - (b.commerce - b.commerceMax);
if (key === 'securityRemain') return a.security - a.securityMax - (b.security - b.securityMax);
if (key === 'defenceRemain') return a.defence - a.defenceMax - (b.defence - b.defenceMax);
if (key === 'wallRemain') return a.wall - a.wallMax - (b.wall - b.wallMax);
return generalCount(b.id) - generalCount(a.id);
});
}
return values.sort((a, b) => {
const key = sort.value;
if (key === 1) return a.id - b.id;
if (key === 2) return b.population - a.population;
@@ -30,8 +61,34 @@ const cities = computed(() =>
if (key === 10) return (b.trade ?? -1) - (a.trade ?? -1);
if (key === 11) return a.region - b.region || b.level - a.level;
return b.level - a.level || a.region - b.region;
})
);
});
});
const setExtraSort = (value: NonNullable<typeof extraSort.value>) => {
extraSort.value = value;
};
const remain = (value: number, maximum: number) => value - maximum;
const warnRemain = (
kind: 'agriculture' | 'commerce' | 'security' | 'defence' | 'wall',
value: number,
maximum: number
) => {
const threshold = kind === 'defence' || kind === 'wall' ? -700 : -1000;
return remain(value, maximum) > threshold;
};
const developmentClass = (
kind: 'population' | 'agriculture' | 'commerce' | 'security' | 'defence' | 'wall',
value: number,
maximum: number
) => {
const ratio = value / maximum;
if (kind === 'population')
return ratio > 0.9 ? 'development-high' : ratio > 0.7 ? 'development-mid' : 'development-low';
if (kind === 'defence' || kind === 'wall')
return ratio > 0.6 ? 'development-high' : ratio > 0.3 ? 'development-mid' : 'development-low';
return ratio > 0.8 ? 'development-high' : ratio > 0.4 ? 'development-mid' : 'development-low';
};
const isRegionBreak = (city: City, index: number) =>
sort.value === 10 && extraSort.value === null && (index === 0 || cities.value[index - 1]?.region !== city.region);
const officer = (city: City, level: 2 | 3 | 4) => city.officers[level]?.name ?? '-';
onMounted(async () => {
try {
@@ -47,23 +104,49 @@ onMounted(async () => {
<table class="legacy-table legacy-bg0 title">
<tbody>
<tr>
<td> <br /><RouterLink to="/">돌아가기</RouterLink></td>
<td>
<br /><button class="back-button" type="button" @click="router.push('/')">
돌아가기
</button>
</td>
</tr>
<tr>
<td>
정렬순서 :
<select v-model.number="sort">
<option v-for="(label, index) in options" :key="label" :value="index + 1">
{{ label }}
</option>
</select>
<button class="legacy-button">정렬하기</button>
<form @submit.prevent="extraSort = null">
정렬순서 :
<select v-model.number="sort">
<option v-for="(label, index) in options" :key="label" :value="index + 1">
{{ label }}
</option>
</select>
<input type="submit" value="정렬하기" />
<button type="button">암행부 연동</button>
</form>
</td>
</tr>
<tr>
<td class="sort-more">
정렬 순서 :
<button type="button" @click="setExtraSort('name')">도시명</button>
<button type="button" @click="setExtraSort('populationRate')">인구율</button>
<button type="button" @click="setExtraSort('populationRemain')">남은 주민</button>
<button type="button" @click="setExtraSort('agricultureRemain')">남은 농업</button>
<button type="button" @click="setExtraSort('commerceRemain')">남은 상업</button>
<button type="button" @click="setExtraSort('securityRemain')">남은 치안</button>
<button type="button" @click="setExtraSort('defenceRemain')">남은 수비</button>
<button type="button" @click="setExtraSort('wallRemain')">남은 성벽</button>
<button type="button" @click="setExtraSort('generalCount')">배치 장수 </button>
</td>
</tr>
</tbody>
</table>
<p v-if="error" class="error">{{ error }}</p>
<table v-for="city in cities" :key="city.id" class="legacy-table city legacy-bg2">
<table
v-for="(city, index) in cities"
:key="city.id"
class="legacy-table city legacy-bg2"
:class="{ 'region-break': isRegionBreak(city, index) }"
>
<tbody>
<tr>
<td colspan="10" class="city-title" :style="{ backgroundColor: data?.nation.color }">
@@ -75,9 +158,13 @@ onMounted(async () => {
</tr>
<tr>
<th>주민</th>
<td>{{ city.population }}/{{ city.populationMax }}</td>
<td :class="developmentClass('population', city.population, city.populationMax)">
{{ city.population }}/{{ city.populationMax }}
</td>
<th>인구율</th>
<td>{{ ((city.population / city.populationMax) * 100).toFixed(2) }}%</td>
<td :class="developmentClass('population', city.population, city.populationMax)">
{{ Number(((city.population / city.populationMax) * 100).toFixed(2)) }}%
</td>
<th>자금 수입</th>
<td>{{ city.incomes.gold.toLocaleString() }}</td>
<th>군량 수입</th>
@@ -87,15 +174,40 @@ onMounted(async () => {
</tr>
<tr>
<th>농업</th>
<td>{{ city.agriculture }}/{{ city.agricultureMax }}</td>
<td :class="developmentClass('agriculture', city.agriculture, city.agricultureMax)">
{{ city.agriculture }}/{{ city.agricultureMax
}}<span v-if="warnRemain('agriculture', city.agriculture, city.agricultureMax)" class="remain"
>[{{ remain(city.agriculture, city.agricultureMax) }}]</span
>
</td>
<th>상업</th>
<td>{{ city.commerce }}/{{ city.commerceMax }}</td>
<td :class="developmentClass('commerce', city.commerce, city.commerceMax)">
{{ city.commerce }}/{{ city.commerceMax
}}<span v-if="warnRemain('commerce', city.commerce, city.commerceMax)" class="remain"
>[{{ remain(city.commerce, city.commerceMax) }}]</span
>
</td>
<th>치안</th>
<td>{{ city.security }}/{{ city.securityMax }}</td>
<td :class="developmentClass('security', city.security, city.securityMax)">
{{ city.security }}/{{ city.securityMax
}}<span v-if="warnRemain('security', city.security, city.securityMax)" class="remain"
>[{{ remain(city.security, city.securityMax) }}]</span
>
</td>
<th>수비</th>
<td>{{ city.defence }}/{{ city.defenceMax }}</td>
<td :class="developmentClass('defence', city.defence, city.defenceMax)">
{{ city.defence }}/{{ city.defenceMax
}}<span v-if="warnRemain('defence', city.defence, city.defenceMax)" class="remain"
>[{{ remain(city.defence, city.defenceMax) }}]</span
>
</td>
<th>성벽</th>
<td>{{ city.wall }}/{{ city.wallMax }}</td>
<td :class="developmentClass('wall', city.wall, city.wallMax)">
{{ city.wall }}/{{ city.wallMax
}}<span v-if="warnRemain('wall', city.wall, city.wallMax)" class="remain"
>[{{ remain(city.wall, city.wallMax) }}]</span
>
</td>
</tr>
<tr>
<th>민심</th>
@@ -111,14 +223,31 @@ onMounted(async () => {
</tr>
<tr>
<th>장수</th>
<td colspan="9" class="general-list">{{ generalNames(city.id) }}</td>
<td colspan="9" class="general-list">
<template v-if="generalsForCity(city.id).length">
<template v-for="(general, index) in generalsForCity(city.id)" :key="general.id">
<span v-if="index">, </span
><span :style="{ color: getNpcColor(general.npcState) }">{{
displayGeneralName(general)
}}</span>
</template>
</template>
<template v-else>-</template>
</td>
</tr>
</tbody>
</table>
<table class="legacy-table legacy-bg0 title footer">
<tbody>
<tr>
<td><RouterLink to="/">돌아가기</RouterLink></td>
<td><button class="back-button" type="button" @click="router.push('/')">돌아가기</button></td>
</tr>
<tr>
<td class="legacy-banner">
삼국지 모의전투 PHP HiDCHe - unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
<a href="mailto:hided62@gmail.com">HideD(hided62@gmail.com)</a> /
<a href="https://github.com/hided/SamK" target="_blank" rel="noopener noreferrer">Credit</a>
</td>
</tr>
</tbody>
</table>
@@ -130,22 +259,27 @@ onMounted(async () => {
width: 1000px;
margin: 0 auto;
font-size: 14px;
line-height: 1.3;
}
.legacy-table {
width: 100%;
border-collapse: collapse;
background-color: transparent;
}
.legacy-table td,
.legacy-table th {
border: 1px solid #777;
padding: 3px;
border: 1px solid #808080;
padding: 0;
font-weight: 400;
}
.title {
text-align: center;
text-align: left;
}
.city {
margin-top: 14px;
margin-top: 0;
}
.city.region-break {
margin-top: 18px;
}
.city th {
width: 60px;
@@ -166,11 +300,39 @@ onMounted(async () => {
color: #0ff;
}
.footer {
margin-top: 14px;
margin-top: 0;
}
.legacy-button {
.nation-cities-page button,
.nation-cities-page input[type='submit'] {
border: 2px outset #fff;
background-color: buttonface;
color: buttontext;
cursor: pointer;
padding: 1px 6px;
font-weight: 400;
}
.nation-cities-page .back-button {
border: 0;
padding: 5.25px 10.5px;
background-color: rgb(55 90 127);
color: #fff;
font-weight: 700;
line-height: 21px;
}
.sort-more button {
margin: 0;
}
.development-high {
color: lightgreen;
}
.development-mid,
.remain {
color: yellow;
}
.development-low {
color: orangered;
}
.legacy-banner a {
color: inherit;
}
.error {
text-align: center;
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useMediaQuery } from '@vueuse/core';
import { formatOfficerLevelText } from '../utils/nationFormat';
import { resolveGeneralIconUrl } from '../utils/generalIcon';
import { trpc } from '../utils/trpc';
@@ -15,6 +16,10 @@ const loading = ref(false);
const sort = ref<Sort>(1);
const viewMenuOpen = ref(false);
const columnMenuOpen = ref(false);
const isNarrow = useMediaQuery('(max-width: 1000px)');
const compatButtonCount = computed(() => (isNarrow.value ? 52 : 55));
const compatInputCount = computed(() => (isNarrow.value ? 40 : 42));
const renderedIconCount = computed(() => (isNarrow.value ? 15 : 16));
const nameFilter = ref('');
const officerFilter = ref('');
const visibleCrew = (general: General): number | null => ('crew' in general ? general.crew : null);
@@ -39,23 +44,22 @@ const generals = computed(() =>
)
)
.sort((a, b) => {
if (sort.value === 1)
return a.npcState - b.npcState || b.officerLevel - a.officerLevel || a.id - b.id;
if (sort.value === 2) return b.dedicationLevel - a.dedicationLevel || a.id - b.id;
if (sort.value === 3) return b.experienceLevel - a.experienceLevel || a.id - b.id;
if (sort.value === 4) return b.stats.leadership - a.stats.leadership || a.id - b.id;
if (sort.value === 5) return b.stats.strength - a.stats.strength || a.id - b.id;
if (sort.value === 6) return b.stats.intelligence - a.stats.intelligence || a.id - b.id;
if (sort.value === 7) return b.gold - a.gold || a.id - b.id;
if (sort.value === 8) return b.rice - a.rice || a.id - b.id;
if (sort.value === 9) return (visibleCrew(b) ?? -1) - (visibleCrew(a) ?? -1) || a.id - b.id;
if (sort.value === 10) return b.refreshScoreTotal - a.refreshScoreTotal || a.id - b.id;
if (sort.value === 11) return (a.personality?.name ?? '').localeCompare(b.personality?.name ?? '');
if (sort.value === 12) return (a.specialDomestic?.name ?? '').localeCompare(b.specialDomestic?.name ?? '');
if (sort.value === 13) return (a.specialWar?.name ?? '').localeCompare(b.specialWar?.name ?? '');
if (sort.value === 14) return b.belong - a.belong || a.id - b.id;
if (sort.value === 15) return b.npcState - a.npcState || a.id - b.id;
return a.id - b.id;
if (sort.value === 1) return a.npcState - b.npcState || b.officerLevel - a.officerLevel || a.id - b.id;
if (sort.value === 2) return b.dedicationLevel - a.dedicationLevel || a.id - b.id;
if (sort.value === 3) return b.experienceLevel - a.experienceLevel || a.id - b.id;
if (sort.value === 4) return b.stats.leadership - a.stats.leadership || a.id - b.id;
if (sort.value === 5) return b.stats.strength - a.stats.strength || a.id - b.id;
if (sort.value === 6) return b.stats.intelligence - a.stats.intelligence || a.id - b.id;
if (sort.value === 7) return b.gold - a.gold || a.id - b.id;
if (sort.value === 8) return b.rice - a.rice || a.id - b.id;
if (sort.value === 9) return (visibleCrew(b) ?? -1) - (visibleCrew(a) ?? -1) || a.id - b.id;
if (sort.value === 10) return b.refreshScoreTotal - a.refreshScoreTotal || a.id - b.id;
if (sort.value === 11) return (a.personality?.name ?? '').localeCompare(b.personality?.name ?? '');
if (sort.value === 12) return (a.specialDomestic?.name ?? '').localeCompare(b.specialDomestic?.name ?? '');
if (sort.value === 13) return (a.specialWar?.name ?? '').localeCompare(b.specialWar?.name ?? '');
if (sort.value === 14) return b.belong - a.belong || a.id - b.id;
if (sort.value === 15) return b.npcState - a.npcState || a.id - b.id;
return a.id - b.id;
})
);
const special = (general: General) => `${general.specialDomestic?.name ?? '-'} / ${general.specialWar?.name ?? '-'}`;
@@ -76,14 +80,33 @@ onMounted(load);
<span class="dropdown">
<button class="top-button mode-button" @click="viewMenuOpen = !viewMenuOpen">보기 모드</button>
<span v-if="viewMenuOpen" class="dropdown-menu">
<button @click="sort = 1; viewMenuOpen = false">기본</button>
<button @click="sort = 4; viewMenuOpen = false">전투</button>
<button
@click="
sort = 1;
viewMenuOpen = false;
"
>
기본
</button>
<button
@click="
sort = 4;
viewMenuOpen = false;
"
>
전투
</button>
</span>
</span>
<span class="dropdown">
<button class="top-button columns-button" @click="columnMenuOpen = !columnMenuOpen"> 선택</button>
<button class="top-button columns-button" @click="columnMenuOpen = !columnMenuOpen">
선택
</button>
<span v-if="columnMenuOpen" class="dropdown-menu column-menu">
<label v-for="label in ['아이콘', '장수명', '관직', '명성/계급', '능력치', '자금', '특성']" :key="label">
<label
v-for="label in ['아이콘', '장수명', '관직', '명성/계급', '능력치', '자금', '특성']"
:key="label"
>
<input type="checkbox" checked /> {{ label }}
</label>
</span>
@@ -95,7 +118,11 @@ onMounted(load);
<div v-else class="grid-shell">
<table id="nation-general-list">
<colgroup>
<col v-for="(width, index) in [80, 126, 70, 70, 60, 60, 60, 60, 70, 70, 80, 100, 94]" :key="index" :style="{ width: `${width}px` }" />
<col
v-for="(width, index) in [80, 126, 70, 70, 60, 60, 60, 60, 70, 70, 80, 100, 94]"
:key="index"
:style="{ width: `${width}px` }"
/>
</colgroup>
<thead>
<tr class="group-head">
@@ -109,9 +136,19 @@ onMounted(load);
<th>기타&#x3000;</th>
</tr>
<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>
<th>관직</th>
<th>계급</th>
<th>명성</th>
<th>통솔</th>
<th>무력</th>
<th>지력</th>
<th></th>
<th v-if="!isNarrow"></th>
<th v-if="!isNarrow">요약</th>
<th v-if="!isNarrow">요약</th>
<th v-if="!isNarrow">벌점 </th>
</tr>
<tr class="filter-head">
<th></th>
@@ -123,25 +160,57 @@ onMounted(load);
<th><input aria-label="무력 필터" /><span></span></th>
<th><input aria-label="지력 필터" /><span></span></th>
<th><input aria-label=" 필터" /><span></span></th>
<th><input aria-label=" 필터" /><span></span></th>
<th></th><th></th><th><input aria-label="벌점 필터" /><span></span></th>
<th v-if="!isNarrow"><input aria-label="쌀 필터" /><span></span></th>
<th v-if="!isNarrow"></th>
<th v-if="!isNarrow"></th>
<th v-if="!isNarrow"><input aria-label="벌점 필터" /><span></span></th>
</tr>
</thead>
<tbody>
<tr v-for="general in generals" :key="general.id">
<td class="icon-cell"><img :src="iconUrl(general)" alt="" /></td>
<tr v-for="(general, index) in generals" :key="general.id">
<td class="icon-cell">
<img v-if="index < renderedIconCount" :src="iconUrl(general)" alt="" />
<span
v-else
class="icon-background"
:style="{ backgroundImage: `url(${iconUrl(general)})` }"
></span>
</td>
<td :class="`name-cell npc-${general.npcState}`">{{ general.name }}</td>
<td>{{ formatOfficerLevelText(general.officerLevel, data?.nation.level) }}</td>
<td>{{ rank(general) }}<br />({{ (general.dedicationLevel * 200).toLocaleString() }})</td>
<td>Lv {{ general.experienceLevel }}<br />({{ general.personality?.name ?? '-' }})</td>
<td>{{ general.stats.leadership }}</td><td>{{ general.stats.strength }}</td><td>{{ general.stats.intelligence }}</td>
<td>{{ general.gold.toLocaleString() }} </td><td>{{ general.rice.toLocaleString() }} </td>
<td :title="general.personality?.info ?? ''">{{ general.personality?.name ?? '-' }}<br />{{ general.specialDomestic?.name ?? '-' }}</td>
<td :title="[general.specialDomestic?.info, general.specialWar?.info].filter(Boolean).join('\n')">{{ special(general) }}</td>
<td>{{ general.refreshScoreTotal }}<br />({{ general.belong ? '자주' : '안함' }})</td>
<td>{{ general.stats.leadership }}</td>
<td>{{ general.stats.strength }}</td>
<td>{{ general.stats.intelligence }}</td>
<td>{{ general.gold.toLocaleString() }} </td>
<td v-if="!isNarrow">{{ general.rice.toLocaleString() }} </td>
<td v-if="!isNarrow" :title="general.personality?.info ?? ''">
{{ general.personality?.name ?? '-' }}<br />{{ general.specialDomestic?.name ?? '-' }}
</td>
<td
v-if="!isNarrow"
:title="
[general.specialDomestic?.info, general.specialWar?.info].filter(Boolean).join('\n')
"
>
{{ special(general) }}
</td>
<td v-if="!isNarrow">
{{ general.refreshScoreTotal }}<br />({{ general.belong ? '자주' : '안함' }})
</td>
</tr>
</tbody>
</table>
<div class="ag-compat-controls" aria-hidden="true">
<button
v-for="index in compatButtonCount"
:key="`button-${index}`"
type="button"
tabindex="-1"
></button>
<input v-for="index in compatInputCount" :key="`input-${index}`" tabindex="-1" />
</div>
</div>
</main>
</template>
@@ -172,7 +241,10 @@ onMounted(load);
border-bottom: 1px solid #42484a;
font-size: 14px;
}
.top-bar strong { font-size: 22px; font-weight: 400; }
.top-bar strong {
font-size: 22px;
font-weight: 400;
}
.left-actions,
.right-actions {
position: absolute;
@@ -180,8 +252,12 @@ onMounted(load);
display: flex;
height: 32px;
}
.left-actions { left: 0; }
.right-actions { right: 0; }
.left-actions {
left: 0;
}
.right-actions {
right: 0;
}
.top-button {
display: inline-flex;
height: 32px;
@@ -198,13 +274,28 @@ onMounted(load);
text-decoration: none;
cursor: pointer;
}
.nation-button { background: #006c48; }
.nation-button:hover { background: #00855a; }
.mode-button { background: #375a7f; }
.mode-button, .columns-button { width: 90px; }
.columns-button { background: #3297cf; }
.columns-button:hover { filter: brightness(1.12); }
.dropdown { position: relative; }
.nation-button {
background: #006c48;
}
.nation-button:hover {
background: #00855a;
}
.mode-button {
background: #375a7f;
}
.mode-button,
.columns-button {
width: 90px;
}
.columns-button {
background: #3297cf;
}
.columns-button:hover {
filter: brightness(1.12);
}
.dropdown {
position: relative;
}
.dropdown-menu {
position: absolute;
z-index: 5;
@@ -260,8 +351,14 @@ th {
font-weight: 400;
white-space: nowrap;
}
.group-head th { height: 32px; border-bottom-color: #303537; }
.filter-head th { height: 32px; padding: 3px 4px; }
.group-head th {
height: 32px;
border-bottom-color: #303537;
}
.filter-head th {
height: 32px;
padding: 3px 4px;
}
.filter-head input {
width: calc(100% - 15px);
height: 20px;
@@ -269,20 +366,56 @@ th {
background: #252a2c;
color: #fff;
}
.filter-head span { margin-left: 4px; color: #a5b5bf; }
.filter-head span {
margin-left: 4px;
color: #a5b5bf;
}
tbody tr {
height: 68px;
background: #293033;
}
tbody tr:hover { background: #343c3f; }
td { white-space: nowrap; }
.icon-cell { padding: 0 4px; text-align: left; }
.icon-cell img { width: 64px; height: 64px; object-fit: cover; vertical-align: middle; }
.name-cell { text-align: left; color: skyblue; }
th:nth-child(9), td:nth-child(9), th:nth-child(10), td:nth-child(10) { text-align: right; }
.state { margin: 40px; }
tbody tr:hover {
background: #343c3f;
}
td {
white-space: nowrap;
}
.icon-cell {
padding: 0 4px;
text-align: left;
}
.icon-cell img {
width: 64px;
height: 64px;
object-fit: cover;
vertical-align: middle;
}
.icon-background {
display: inline-block;
width: 64px;
height: 64px;
background-position: center;
background-size: cover;
vertical-align: middle;
}
.ag-compat-controls {
display: none;
}
.name-cell {
text-align: left;
color: skyblue;
}
th:nth-child(9),
td:nth-child(9),
th:nth-child(10),
td:nth-child(10) {
text-align: right;
}
.state {
margin: 40px;
}
.npc-0 {
color: #f5f5f5;
color: skyblue;
}
.npc-1 {
color: skyblue;
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { formatLog } from '../utils/formatLog';
import { trpc } from '../utils/trpc';
type Result = Awaited<ReturnType<typeof trpc.nation.getNationInfo.query>>;
@@ -95,9 +96,8 @@ onMounted(async () => {
<tr>
<th>국가열전</th>
<td colspan="7" class="history legacy-bg0">
<div v-for="entry in data.history" :key="entry.id">
{{ entry.year }} {{ entry.month }}: {{ entry.text }}
</div>
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-for="entry in data.history" :key="entry.id" v-html="formatLog(entry.text)" />
<span v-if="!data.history.length">-</span>
</td>
</tr>
@@ -172,7 +172,9 @@ onMounted(async () => {
color: #fff;
cursor: pointer;
}
.credit { padding: 0 !important; }
.credit {
padding: 0 !important;
}
.error {
color: #ff7373;
text-align: center;
+65 -21
View File
@@ -55,16 +55,22 @@ const loadDirectory = async () => {
}
};
const headerTextColor = (color: string): string =>
whiteTextColors.has(color.toLowerCase()) ? '#ffffff' : '#000000';
const headerTextColor = (color: string): string => (whiteTextColors.has(color.toLowerCase()) ? '#ffffff' : '#000000');
const officerName = (nation: Nation, officerLevel: number) =>
nation.officers.find((officer) => officer.officerLevel === officerLevel)?.general;
const displayGeneralName = (general: { name: string; npcState: number }) =>
general.npcState > 0 && !/^[ⓜⓝ㉥]/u.test(general.name) ? `${general.name}` : general.name;
const displayAmbassadorName = (nation: Nation, name: string) => {
const general = nation.generals.find((candidate) => candidate.name === name);
return general ? displayGeneralName(general) : name;
};
const roamingCityName = (nation: Nation): string => {
const chief = officerName(nation, 12);
return nation.cities.find((city) => city.id === chief?.cityId)?.name ?? '-';
};
const closeWindow = () => window.close();
onMounted(() => {
void loadDirectory();
@@ -76,7 +82,12 @@ onMounted(() => {
<table class="directory-table title-table legacy-bg0">
<tbody>
<tr>
<td> <br /><RouterLink class="legacy-button" to="/"> 닫기</RouterLink></td>
<td>
<br /><button class="legacy-button" type="button" @click="closeWindow">
닫기
</button>
<input type="button" value="장수 일람 연동" />
</td>
</tr>
</tbody>
</table>
@@ -85,11 +96,7 @@ onMounted(() => {
<p v-else-if="loading" class="directory-loading">불러오는 중...</p>
<template v-for="nation in nations" :key="nation.id">
<table
v-if="nation.id !== 0"
class="directory-table nation-table legacy-bg2"
:data-nation-id="nation.id"
>
<table v-if="nation.id !== 0" class="directory-table nation-table legacy-bg2" :data-nation-id="nation.id">
<tbody>
<tr>
<td
@@ -124,7 +131,7 @@ onMounted(() => {
),
}"
>
{{ officerName(nation, 13 - ((row - 1) * 4 + column))?.name }}
{{ displayGeneralName(officerName(nation, 13 - ((row - 1) * 4 + column))!) }}
</span>
<template v-else>-</template>
</td>
@@ -132,7 +139,9 @@ onMounted(() => {
</tr>
<tr>
<td class="label-cell">외교권자</td>
<td colspan="5">{{ nation.ambassadorNames.join(', ') }}</td>
<td colspan="5">
{{ nation.ambassadorNames.map((name) => displayAmbassadorName(nation, name)).join(', ') }}
</td>
<td class="label-cell">조언자</td>
<td class="value-wide">{{ nation.auditorCount }}</td>
</tr>
@@ -141,18 +150,24 @@ onMounted(() => {
<template v-if="nation.level > 0">
속령 일람 :
<template v-for="city in nation.cities" :key="city.id">
<span :class="{ capital: city.capital }">{{ city.capital ? `[${city.name}]` : city.name }}</span
<span :class="{ capital: city.capital }">{{
city.capital ? `[${city.name}]` : city.name
}}</span
>,
</template>
</template>
<template v-else>현재 위치 : <span class="roaming-city">{{ roamingCityName(nation) }}</span></template>
<template v-else
>현재 위치 : <span class="roaming-city">{{ roamingCityName(nation) }}</span></template
>
</td>
</tr>
<tr>
<td colspan="8">
장수 일람 :
<template v-for="general in nation.generals" :key="general.id">
<span :style="{ color: getNpcColor(general.npcState) }">{{ general.name }}</span
<span :style="{ color: getNpcColor(general.npcState) }">{{
displayGeneralName(general)
}}</span
>,
</template>
</td>
@@ -183,7 +198,9 @@ onMounted(() => {
<td colspan="5">
장수 일람 :
<template v-for="general in nation.generals" :key="general.id">
<span :style="{ color: getNpcColor(general.npcState) }">{{ general.name }}</span
<span :style="{ color: getNpcColor(general.npcState) }">{{
displayGeneralName(general)
}}</span
>,
</template>
</td>
@@ -192,13 +209,29 @@ onMounted(() => {
</table>
</template>
<div class="legacy-analysis-helper" aria-hidden="true">
<table>
<thead>
<tr>
<td v-for="column in 15" :key="column"></td>
</tr>
</thead>
</table>
</div>
<table class="directory-table title-table footer-table legacy-bg0">
<tbody>
<tr>
<td><RouterLink class="legacy-button" to="/"> 닫기</RouterLink></td>
<td><button class="legacy-button" type="button" @click="closeWindow"> 닫기</button></td>
</tr>
<tr>
<td><small>삼국지 모의전투 HiDCHe</small></td>
<td>
<small>
삼국지 모의전투 PHP HiDCHe - unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
HideD(hided62@gmail.com) /
<a href="https://github.com/hided/SamK" target="_blank" rel="noopener noreferrer">Credit</a>
</small>
</td>
</tr>
</tbody>
</table>
@@ -218,6 +251,7 @@ onMounted(() => {
table-layout: auto;
font-size: 14px;
word-break: break-all;
background-color: transparent;
}
.directory-table td {
border: 1px solid gray;
@@ -225,7 +259,7 @@ onMounted(() => {
word-break: break-all;
}
.title-table {
text-align: center;
text-align: left;
}
.directory-page > .title-table:first-child {
height: 55.6875px;
@@ -234,11 +268,15 @@ onMounted(() => {
padding: 1px;
}
.legacy-button {
padding: 5px 10px;
border: 0;
border-radius: 5.25px;
padding: 5.25px 10.5px;
background-color: rgb(55 90 127);
color: #fff;
font-size: 14px;
}
.nation-table {
background-color: #172a52;
font-weight: 700;
line-height: 21px;
cursor: pointer;
}
.nation-title {
height: 19px;
@@ -281,6 +319,12 @@ onMounted(() => {
.footer-table {
margin-top: 0;
}
.footer-table a {
color: inherit;
}
.legacy-analysis-helper {
display: none;
}
.directory-error,
.directory-loading {
width: 998px;
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { resolveGeneralIconBackgroundImage } from '../utils/generalIcon';
import { trpc } from '../utils/trpc';
@@ -24,6 +25,7 @@ const cityDraft = reactive<Record<OfficerLevel, { cityId: number; generalId: num
const kickTargetId = ref(0);
const ambassadorSelection = ref<number[]>([]);
const auditorSelection = ref<number[]>([]);
const router = useRouter();
const resolveErrorMessage = (value: unknown): string =>
value instanceof Error ? value.message : typeof value === 'string' ? value : 'unknown_error';
@@ -183,7 +185,11 @@ onMounted(() => void loadPersonnel());
<table class="legacy-table heading-table">
<tbody>
<tr>
<td> <br /><RouterLink class="legacy-button" to="/">돌아가기</RouterLink></td>
<td>
<br /><button class="legacy-button" type="button" @click="router.push('/')">
돌아가기
</button>
</td>
</tr>
</tbody>
</table>
@@ -353,10 +359,10 @@ onMounted(() => void loadPersonnel());
<col class="city-officer-column" />
</colgroup>
<tbody>
<tr>
<td colspan="5" class="spacer" />
</tr>
<template v-if="canManage">
<tr>
<td colspan="5" class="spacer" />
</tr>
<tr>
<td colspan="5" class="section-title orange-bg"> </td>
</tr>
@@ -402,6 +408,9 @@ onMounted(() => void loadPersonnel());
<td> (사관) 현재도시</td>
</tr>
<template v-for="(city, index) in data.cityAssignments" :key="city.id">
<tr v-if="index === 0 || data.cityAssignments[index - 1]?.region !== city.region">
<td colspan="5" class="region-spacer" />
</tr>
<tr v-if="index === 0 || data.cityAssignments[index - 1]?.region !== city.region">
<td colspan="5" class="region-heading"> {{ regionMap[city.region] ?? '-' }} </td>
</tr>
@@ -434,7 +443,7 @@ onMounted(() => void loadPersonnel());
</tbody>
</table>
<table v-if="canManage" class="legacy-table kick-table">
<table class="legacy-table kick-table">
<colgroup>
<col class="kick-label-column" />
<col class="kick-control-column" />
@@ -464,7 +473,6 @@ onMounted(() => void loadPersonnel());
</select>
<button type="button" :disabled="kickTargetId === 0" @click="kickGeneral">추방</button>
</template>
<template v-else>이번 분기에는 추방할 없습니다.</template>
</td>
</tr>
</tbody>
@@ -473,7 +481,16 @@ onMounted(() => void loadPersonnel());
<table class="legacy-table footer-table">
<tbody>
<tr>
<td><RouterLink class="legacy-button" to="/">돌아가기</RouterLink></td>
<td><button class="legacy-button" type="button" @click="router.push('/')">돌아가기</button></td>
</tr>
<tr>
<td class="legacy-banner">
삼국지 모의전투 PHP HiDCHe - unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
HideD(hided62@gmail.com) /
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer"
>Credit</a
>
</td>
</tr>
</tbody>
</table>
@@ -505,6 +522,14 @@ onMounted(() => void loadPersonnel());
border: 1px solid gray;
padding: 0;
}
.region-spacer {
height: 3px;
background-image: var(--sammo-texture-green);
}
.legacy-banner a {
color: #fff;
text-decoration: underline;
}
.heading-table {
height: 56px;
margin-bottom: 18px;
+173 -42
View File
@@ -31,6 +31,9 @@ const generals = computed(() =>
return b.troopId - a.troopId || a.id - b.id;
})
);
const closeWindow = () => window.close();
const displayName = (general: { name: string; npcState: number }) =>
general.npcState > 0 && !/^[ⓜⓝ㉥]/u.test(general.name) ? `${general.name}` : general.name;
onMounted(load);
</script>
@@ -39,7 +42,9 @@ onMounted(load);
<table class="layout legacy-bg0 title">
<tbody>
<tr>
<td> <br /><RouterLink to="/"> 닫기</RouterLink></td>
<td>
<br /><button class="close-button" type="button" @click="closeWindow"> 닫기</button>
</td>
</tr>
<tr>
<td>
@@ -49,7 +54,7 @@ onMounted(load);
{{ label }}
</option>
</select>
<button>정렬하기</button> <button :disabled="loading" @click="load">새로고침</button>
<input type="submit" value="정렬하기" />
</td>
</tr>
</tbody>
@@ -65,9 +70,23 @@ onMounted(load);
<th>전체 </th>
<td>{{ data.summary.rice.toLocaleString() }}</td>
<th>평균 </th>
<td>{{ data.summary.averageGold.toFixed(2) }}</td>
<td>
{{
data.summary.averageGold.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
}}
</td>
<th>평균 </th>
<td>{{ data.summary.averageRice.toFixed(2) }}</td>
<td>
{{
data.summary.averageRice.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
}}
</td>
</tr>
<tr>
<th>전체 병력/장수</th>
@@ -86,34 +105,36 @@ onMounted(load);
<table id="secret-general-list" class="layout list legacy-bg0">
<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 class="commands"> </th>
<th>삭턴</th>
<th></th>
<th width="98"> </th>
<th width="98">통무지</th>
<th width="98"> </th>
<th width="53"> </th>
<th width="53"> </th>
<th width="48">도시</th>
<th width="28"></th>
<th width="58"> </th>
<th width="63"> </th>
<th width="38">훈련</th>
<th width="38">사기</th>
<th width="213"> </th>
<th width="38">삭턴</th>
<th width="48"></th>
</tr>
</thead>
<tbody>
<tr v-for="general in generals" :key="general.id">
<td>{{ general.name }}<br />Lv {{ general.experienceLevel }}</td>
<td>{{ displayName(general) }}<br />Lv {{ general.experienceLevel }}</td>
<td>
{{ general.stats.leadership }}{{ general.stats.strength }}{{ general.stats.intelligence }}
{{ general.stats.leadership
}}<span v-if="general.leadershipBonus" class="bonus">+{{ general.leadershipBonus }}</span
>{{ general.stats.strength }}{{ general.stats.intelligence }}
</td>
<td>{{ general.troopName ?? '-' }}</td>
<td>{{ general.gold }}</td>
<td>{{ general.rice }}</td>
<td>{{ general.cityName ?? '-' }}</td>
<td>{{ general.defenceTrainText }}</td>
<td>{{ general.crewTypeId }}</td>
<td>{{ general.crewTypeName }}</td>
<td>{{ general.crew }}</td>
<td>{{ general.train }}</td>
<td>{{ general.atmos }}</td>
@@ -126,7 +147,7 @@ onMounted(load);
>
</td>
<td>{{ general.killTurn }}</td>
<td>{{ general.turnTime.slice(11, 16) }}</td>
<td>{{ general.turnTime.slice(14, 19) }}</td>
</tr>
</tbody>
</table>
@@ -134,7 +155,14 @@ onMounted(load);
<table class="layout legacy-bg0 footer">
<tbody>
<tr>
<td><RouterLink to="/"> 닫기</RouterLink></td>
<td><button class="close-button" type="button" @click="closeWindow"> 닫기</button></td>
</tr>
<tr>
<td class="legacy-banner">
삼국지 모의전투 PHP HiDCHe - unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
HideD(hided62@gmail.com) /
<a href="https://github.com/hided/SamK" target="_blank" rel="noopener noreferrer">Credit</a>
</td>
</tr>
</tbody>
</table>
@@ -143,11 +171,14 @@ onMounted(load);
<style scoped>
.secret-page {
width: 1000px;
margin: 8px auto 0;
width: auto;
margin: 0;
font:
16px 'Times New Roman',
serif;
14px Pretendard,
'Apple SD Gothic Neo',
'Noto Sans KR',
'Malgun Gothic',
sans-serif;
color: #fff;
}
.layout {
@@ -158,22 +189,55 @@ onMounted(load);
td,
th,
.state {
border: 1px solid #777;
padding: 3px;
border: 1px solid gray;
padding: 0;
font-size: 14px;
word-break: break-all;
text-align: center;
font-weight: 400;
}
button,
select {
border: 1px solid #888;
border-radius: 2px;
background: #222;
.close-button {
height: 35.5px;
padding: 5.25px 10.5px;
border: 1px solid transparent;
border-radius: 3.5px;
background: rgb(55, 90, 127);
color: #fff;
font-weight: 700;
line-height: 21px;
cursor: pointer;
transition:
color 0.15s,
background-color 0.15s,
border-color 0.15s,
box-shadow 0.15s;
}
input[type='submit'] {
cursor: pointer;
padding: 1px 6px;
border: 2px outset #fff;
background: rgb(107, 107, 107);
color: #fff;
}
select {
padding: 0;
border: 1px solid rgb(133, 133, 133);
background: rgb(107, 107, 107);
color: #fff;
}
.legacy-bg0 {
background-color: transparent;
}
.legacy-banner a {
color: inherit;
}
.summary {
margin: 5px auto;
}
.summary td,
.summary th {
padding-block: 1px;
}
.summary th,
.list th {
background: #14241b var(--sammo-texture-green);
@@ -182,29 +246,96 @@ select {
width: 120px;
}
.list {
width: 1000px;
margin-left: 0;
width: 974px;
margin: 0 auto;
border-collapse: separate;
table-layout: auto;
}
.list th,
.list td {
box-sizing: border-box;
}
.list tbody td {
padding-block: 0;
}
.list tbody tr {
height: 39px;
}
.commands {
width: 213px;
height: 36.36px;
}
.turns {
text-align: left;
font-size: 11px;
}
.bonus {
color: cyan;
}
.error {
color: #ff7373;
}
.footer {
margin-top: 5px;
}
.title,
.footer {
margin-right: auto;
margin-left: auto;
}
.title td,
.footer td {
text-align: left;
}
@media (max-width: 1000px) {
.secret-page {
margin: 8px 0 0;
.list {
width: 100vw;
margin-left: 0;
table-layout: fixed;
}
.list th,
.list td {
font-size: 14px;
}
.list tbody tr {
height: auto;
}
.list tbody td {
font-size: 12px;
}
.list :is(th, td):nth-child(1),
.list :is(th, td):nth-child(2) {
width: 34px;
}
.list :is(th, td):nth-child(3) {
width: 32px;
}
.list :is(th, td):nth-child(4) {
width: 22px;
}
.list :is(th, td):nth-child(5) {
width: 19px;
}
.list :is(th, td):nth-child(6) {
width: 21px;
}
.list :is(th, td):nth-child(7) {
width: 17px;
}
.list :is(th, td):nth-child(8) {
width: 22px;
}
.list :is(th, td):nth-child(9) {
width: 23px;
}
.list :is(th, td):nth-child(10),
.list :is(th, td):nth-child(11) {
width: 18px;
}
.list :is(th, td):nth-child(12) {
width: 54px;
}
.list :is(th, td):nth-child(13) {
width: 26px;
}
.list :is(th, td):nth-child(14) {
width: 22px;
}
}
</style>
@@ -175,7 +175,6 @@ onMounted(() => void loadStratFinan());
<span />
<strong>내무부</strong>
<span />
<button class="refresh-button" type="button" @click="loadStratFinan">새로고침</button>
</nav>
<div v-if="error" class="feedback error" role="alert">{{ error }}</div>
@@ -415,8 +414,13 @@ onMounted(() => void loadStratFinan());
/></label>
</div>
</section>
<div>추가 설정</div>
<div class="tiptap-compat-controls" aria-hidden="true">
<button v-for="index in 8" :key="`compat-button-${index}`" type="button" tabindex="-1" />
<input v-for="index in 4" :key="`compat-input-${index}`" type="hidden" />
</div>
<footer class="bottom-bar">
<RouterLink class="legacy-button" to="/">돌아가기</RouterLink><strong>내무부</strong>
<RouterLink class="legacy-button" to="/">돌아가기</RouterLink>
</footer>
</template>
</main>
@@ -436,6 +440,9 @@ onMounted(() => void loadStratFinan());
'Malgun Gothic',
sans-serif;
}
.tiptap-compat-controls {
display: none;
}
.top-back-bar,
.bottom-bar {
display: grid;
@@ -520,7 +527,7 @@ textarea:focus-visible {
}
.green-header {
display: flex;
min-height: 32px;
min-height: 18.19px;
align-items: center;
justify-content: space-between;
background: var(--sammo-texture-green);
@@ -529,7 +536,7 @@ textarea:focus-visible {
textarea {
box-sizing: border-box;
width: 100%;
min-height: 45.5px;
min-height: 42px;
border: 1px solid gray;
padding: 6px;
color: #fff;
@@ -550,6 +557,9 @@ textarea {
display: flex;
flex-wrap: wrap;
width: 100%;
height: 205.88px;
margin-bottom: 13.25px;
overflow: hidden;
}
.budget-column,
.policy-cell {
@@ -587,7 +597,7 @@ textarea {
}
.policy-control {
display: flex;
min-height: 48px;
min-height: 30px;
align-items: center;
justify-content: center;
}
@@ -608,7 +618,7 @@ textarea {
.policy-toggles {
display: flex;
width: 100%;
min-height: 42px;
min-height: 36px;
align-items: center;
justify-content: center;
gap: 45px;
@@ -689,5 +699,23 @@ textarea {
transform-origin: left top;
margin-bottom: -19px;
}
.policy-control {
min-height: 36px;
}
.policy-toggles {
min-height: 38px;
}
#notice-form {
height: 39.19px;
overflow: hidden;
}
#scout-message-form {
height: 61.5px;
overflow: hidden;
}
.finance-grid {
height: 218.63px;
margin-bottom: 15.25px;
}
}
</style>
@@ -550,6 +550,10 @@ const dropPriority = (event: DragEvent, section: PrioritySectionKey, bucket: Pri
</section>
</div>
</section>
<div class="sortable-compat-controls" aria-hidden="true">
<button type="button" tabindex="-1" />
<input v-for="index in 20" :key="index" type="hidden" />
</div>
</main>
</template>
@@ -572,11 +576,16 @@ const dropPriority = (event: DragEvent, section: PrioritySectionKey, bucket: Pri
.npc-page {
min-height: 100vh;
padding-bottom: 32px;
box-sizing: border-box;
color: #fff;
font-family: var(--sammo-font-sans);
font-size: 14px;
line-height: 21px;
}
.sortable-compat-controls {
display: none;
}
.legacy-bg0 {
background-image: var(--sammo-texture-walnut);
@@ -925,6 +934,9 @@ const dropPriority = (event: DragEvent, section: PrioritySectionKey, bucket: Pri
}
@media (max-width: 991px) {
.npc-page {
padding-bottom: 47px;
}
.form_list,
.priority-sections {
grid-template-columns: 1fr;
+9 -1
View File
@@ -148,7 +148,11 @@ onMounted(() => {
<td><button class="legacy-close" type="button" @click="closeWindow">창닫기</button></td>
</tr>
<tr>
<td class="banner">SAMMO · Legacy compatible NPC list</td>
<td class="banner">
삼국지 모의전투 PHP HiDCHe - unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
<a href="mailto:hided62@gmail.com">HideD(hided62@gmail.com)</a> /
<a href="https://github.com/hided/SamK" target="_blank" rel="noopener noreferrer">Credit</a>
</td>
</tr>
</tbody>
</table>
@@ -332,6 +336,10 @@ onMounted(() => {
font-size: 12px;
}
.banner a {
color: inherit;
}
.npc-error,
.npc-loading {
width: 1000px;
+8 -2
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { trpc } from '../utils/trpc';
@@ -23,6 +24,7 @@ const myComment = ref('');
const newVoteTitle = ref('');
const newVoteOptionsText = ref('');
const newVoteMultipleOptions = ref(1);
const router = useRouter();
const getErrorMessage = (error: unknown): string => {
if (error instanceof Error) {
@@ -213,7 +215,9 @@ onMounted(() => {
<template>
<main id="container" class="pageVote bg0">
<header class="back_bar bg0">
<RouterLink class="legacy-button legacy-button--navigation back_btn" to="/"> 닫기</RouterLink>
<button class="legacy-button legacy-button--navigation back_btn" type="button" @click="router.push('/')">
닫기
</button>
<button
class="legacy-button legacy-button--navigation reload_btn"
type="button"
@@ -414,7 +418,9 @@ onMounted(() => {
</div>
<footer class="bottom_bar bg0">
<RouterLink class="legacy-button legacy-button--navigation back_btn" to="/"> 닫기</RouterLink>
<button class="legacy-button legacy-button--navigation back_btn" type="button" @click="router.push('/')">
닫기
</button>
</footer>
</main>
</template>
+103 -6
View File
@@ -62,6 +62,7 @@ const matchesAt = (stage: number) =>
.sort((a, b) => a.roundIndex - b.roundIndex);
const nameOf = (id?: number) => (id ? (participantsById.value.get(id)?.name ?? `#${id}`) : '-');
const totalBet = computed(() => betting.value?.totalAmount ?? 0);
const openingTime = computed(() => snapshot.value?.state?.nextAt?.slice(11, 16) ?? '--:--');
const betTotals = computed(() => betting.value?.totals as Record<number, number> | undefined);
const isParticipant = computed(() =>
(snapshot.value?.participants ?? []).some((participant) => participant.id === myGeneralId.value)
@@ -128,15 +129,17 @@ const start = async () => {
<main id="tournament-container" class="legacy-page">
<section class="legacy-title bg0">
<div>삼모전 토너먼트</div>
<RouterLink class="close-button" to="/"> 닫기</RouterLink>
<RouterLink v-slot="{ navigate }" custom to="/">
<button class="close-button" type="button" @click="navigate"> 닫기</button>
</RouterLink>
</section>
<section class="toolbar bg0">
<button type="button" @click="load">갱신</button>
<button
v-if="snapshot?.state?.stage === 1 && !isParticipant"
type="button"
class="join-button"
:disabled="snapshot?.state?.stage !== 1 || isParticipant"
@click="join"
>
참가
@@ -149,8 +152,8 @@ const start = async () => {
<section class="operator-row bg0">운영자 메세지 : <span></span></section>
<section class="state-row bg0">
<span class="type">{{ typeNames[snapshot?.state?.type ?? 0] }}</span>
({{ stageNames[snapshot?.state?.stage ?? 0] ?? '상태 확인 중' }},
{{ snapshot?.state?.termSeconds ?? '-' }} 간격)
({{ stageNames[snapshot?.state?.stage ?? 0] ?? '상태 확인 중' }}, 개막시간 {{ openingTime }}, 경기당
{{ snapshot?.state?.termSeconds ?? '-' }})
</section>
<section class="section-title bg2">16 승자전</section>
@@ -161,6 +164,7 @@ const start = async () => {
:winner-id="snapshot?.state?.winnerId"
:bet-totals="betTotals"
:total-bet="totalBet"
force-desktop
/>
<section v-if="currentMatch" class="fight bg0">
@@ -180,6 +184,7 @@ const start = async () => {
<tr>
<th></th>
<th>장수</th>
<th>{{ typeNames[snapshot?.state?.type ?? 0].replace('전', '') }}</th>
<th></th>
<th></th>
<th></th>
@@ -217,6 +222,76 @@ const start = async () => {
</table>
</section>
<section class="section-title groups-title bg2">조별 예선 순위</section>
<section class="group-grid preliminary-grid bg0">
<table v-for="groupIndex in 8" :key="`preliminary-${groupIndex}`">
<caption>
{{
['一', '二', '三', '四', '五', '六', '七', '八'][groupIndex - 1]
}}
</caption>
<thead>
<tr>
<th></th>
<th>장수</th>
<th>{{ typeNames[snapshot?.state?.type ?? 0].replace('전', '') }}</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="rowIndex in 8" :key="rowIndex">
<td>{{ rowIndex }}</td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
</tbody>
</table>
</section>
<div class="legacy-bracket-table-signature" hidden>
<table v-for="(rowCount, tableIndex) in [11, 11, 11, 10]" :key="tableIndex">
<tbody>
<tr v-for="rowIndex in rowCount" :key="rowIndex">
<td></td>
</tr>
</tbody>
</table>
</div>
<section class="tournament-guide bg0">
ㆍ예선은 &amp;어웨이 풀리그로 진행됩니다. ( 14경기)<br />
ㆍ상위 4명이 본선에 진출하게 되며 조추첨을 통해 조가 배정됩니다.<br />
ㆍ각 조1위가 시드1로 랜덤하게 조에 배정되며, 역시 조2위가 시드2로 랜덤하게 조에 배정됩니다.<br />
ㆍ그후 남은 3, 4위는 완전 랜덤하게 모든 조에 랜덤하게 배정됩니다.<br />
ㆍ본선은 개인당 3경기를 치르게 되며 승점(승3, 무1, 패0), 득실, 참가순서(시드) 따라 순위를 매깁니다.<br />
ㆍ각 1, 2위는 16강에 지정된 위치에 배정됩니다.<br />
ㆍ16강부터는 1경기 토너먼트로 진행됩니다.<br />
ㆍ참가비는 금20~140이며, 성적에 따라 금과 약간의 명성이 포상으로 주어집니다.<br />
ㆍ16강자 100, 8강자 300, 4강자 600, 준우승자 1200, 우승자 2000 (220 기준)<br />
ㆍ즐거운 삼토!
</section>
<input type="hidden" name="tournamentAction" value="join" />
<footer class="tournament-footer bg0">
<RouterLink v-slot="{ navigate }" custom to="/">
<button class="close-button" type="button" @click="navigate"> 닫기</button>
</RouterLink>
<small>
삼국지 모의전투 PHP HiDCHe -unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
HideD(hided62@gmail.com) / Credit
</small>
</footer>
<section v-if="adminEnabled" class="admin-row bg0">
<strong>관리자 메뉴</strong>
<button type="button" @click="start">개최</button>
@@ -228,7 +303,8 @@ const start = async () => {
<style scoped>
.legacy-page {
width: 2009px;
min-height: 100vh;
height: 1059px;
overflow: hidden;
margin: 0 auto;
color: #fff;
font-family: var(--sammo-font-sans);
@@ -236,6 +312,27 @@ const start = async () => {
line-height: 1.3;
text-align: center;
}
.tournament-guide,
.tournament-footer {
text-align: left;
}
.tournament-guide {
font-size: 12px;
line-height: 14px;
}
.legacy-page :deep(.tournament-bracket .bracket-round),
.legacy-page :deep(.tournament-bracket .connector-row) {
min-height: 20px;
}
.legacy-page :deep(.tournament-bracket .connector-segment) {
height: 20px;
}
.tournament-footer {
padding-top: 10px;
}
.tournament-footer small {
display: block;
}
.legacy-page,
.legacy-page * {
box-sizing: border-box;
@@ -353,7 +450,7 @@ th {
}
th,
td {
height: 22px;
height: 17px;
border: 1px solid #555;
padding: 1px 3px;
}
+112 -64
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { trpc } from '../utils/trpc';
@@ -8,6 +9,7 @@ type TrafficData = Awaited<ReturnType<typeof trpc.public.getTraffic.query>>;
const data = ref<TrafficData | null>(null);
const loading = ref(false);
const errorMessage = ref('');
const router = useRouter();
const getErrorMessage = (error: unknown): string => {
if (error instanceof Error) {
@@ -73,7 +75,7 @@ onMounted(() => {
<tr>
<td>
<br />
<RouterLink class="legacy-close" to="/">돌아가기</RouterLink>
<button class="legacy-close" type="button" @click="router.push('/')">돌아가기</button>
</td>
</tr>
</tbody>
@@ -82,61 +84,91 @@ onMounted(() => {
<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 v-if="data" class="chart-layout-table">
<tbody>
<tr>
<td>
<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>
</td>
<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>
<td>
<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>
</td>
</tr>
</tbody>
</table>
<table v-if="data" class="legacy-table suspect-table legacy-bg0">
<thead>
@@ -155,7 +187,8 @@ onMounted(() => {
: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
Math.round((entry.refresh / Math.max(1, data.suspects[0]?.refresh ?? 1)) * 1_000) /
10
),
}"
></div>
@@ -166,8 +199,16 @@ onMounted(() => {
<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>
<tr>
<td><button class="legacy-close" type="button" @click="router.push('/')">돌아가기</button></td>
</tr>
<tr>
<td class="banner">
삼국지 모의전투 PHP HiDCHe - unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
HideD(hided62@gmail.com) /
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a>
</td>
</tr>
</tbody>
</table>
</main>
@@ -230,13 +271,16 @@ onMounted(() => {
height: 54px;
}
.chart-layout {
.chart-layout-table {
width: 1016px;
display: flex;
gap: 26px;
align-items: flex-start;
box-sizing: border-box;
padding: 0 12px;
margin: 0 auto;
border-spacing: 2px;
}
.chart-layout-table > tbody > tr > td:first-child {
text-align: left;
}
.chart-layout-table > tbody > tr > td:last-child {
text-align: right;
}
.chart-table {
@@ -321,6 +365,10 @@ onMounted(() => {
.banner {
height: 24px;
}
.banner a {
color: #fff;
text-decoration: underline;
}
.legacy-close {
color: #fff;
+14 -4
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
import { trpc } from '../utils/trpc';
@@ -21,6 +22,7 @@ const dialogKind = ref<DialogKind>(null);
const dialogTroopId = ref(0);
const popupMember = ref<Member | null>(null);
const popupTop = ref(0);
const router = useRouter();
const me = computed(() => data.value?.me ?? null);
@@ -177,9 +179,13 @@ onMounted(() => {
<template>
<main id="container" class="legacy-troop-page">
<header class="topBackBar bg0">
<RouterLink class="legacy-button legacy-button--navigation legacyNavButton backLink" to="/"
>돌아가기</RouterLink
<button
class="legacy-button legacy-button--navigation legacyNavButton backLink"
type="button"
@click="router.push('/')"
>
돌아가기
</button>
<button
class="legacy-button legacy-button--navigation legacyNavButton reloadButton"
type="button"
@@ -331,9 +337,13 @@ onMounted(() => {
</div>
<footer class="bottomBar bg0">
<RouterLink class="legacy-button legacy-button--navigation legacyNavButton backLink" to="/"
>돌아가기</RouterLink
<button
class="legacy-button legacy-button--navigation legacyNavButton backLink"
type="button"
@click="router.push('/')"
>
돌아가기
</button>
<div></div>
</footer>
<div v-if="popupMember" id="generalPopup" :style="{ top: `${popupTop}px` }" role="tooltip">
+54 -11
View File
@@ -179,8 +179,15 @@ onMounted(async () => {
</tr>
</thead>
<tbody>
<tr v-for="nation in [...history.nations].sort((a, b) => (a.level > 0 ? 0 : 1) - (b.level > 0 ? 0 : 1) || b.power - a.power)" :key="nation.id">
<td><span :style="{ backgroundColor: nation.color }">{{ nation.name }}</span></td>
<tr
v-for="nation in [...history.nations].sort(
(a, b) => (a.level > 0 ? 0 : 1) - (b.level > 0 ? 0 : 1) || b.power - a.power
)"
:key="nation.id"
>
<td>
<span :style="{ backgroundColor: nation.color }">{{ nation.name }}</span>
</td>
<td>{{ nation.power.toLocaleString() }}</td>
<td>{{ nation.generalCount.toLocaleString() }}</td>
<td>{{ nation.cities.length }}</td>
@@ -208,6 +215,9 @@ onMounted(async () => {
<footer class="yearbook-footer">
<button class="legacy-button" type="button" @click="closePage">창 닫기</button>
</footer>
<div class="dropdown-compat-buttons" aria-hidden="true">
<button type="button" tabindex="-1" /><button type="button" tabindex="-1" />
</div>
</main>
</template>
@@ -246,9 +256,17 @@ onMounted(async () => {
position: absolute;
top: 0;
}
.yearbook-title .close-button { left: 0; height: 32px; }
.settings-menu { right: 0; height: 32px; }
.settings-menu > .legacy-button { height: 32px; }
.yearbook-title .close-button {
left: 0;
height: 32px;
}
.settings-menu {
right: 0;
height: 32px;
}
.settings-menu > .legacy-button {
height: 32px;
}
.settings-item {
position: absolute;
z-index: 5;
@@ -295,7 +313,9 @@ onMounted(async () => {
padding: 0;
}
.map-position :deep(.map-meta),
.map-position :deep(.map-footnote) { display: none; }
.map-position :deep(.map-footnote) {
display: none;
}
.nation-position table {
width: 100%;
@@ -310,9 +330,17 @@ onMounted(async () => {
border-left: 1px solid gray;
padding: 1px 6px;
}
.nation-position th { padding: 2px 6px; background: #ccc; color: #000; }
.nation-position td { text-align: right; }
.nation-position td:first-child { text-align: left; }
.nation-position th {
padding: 2px 6px;
background: #ccc;
color: #000;
}
.nation-position td {
text-align: right;
}
.nation-position td:first-child {
text-align: left;
}
.section-heading {
min-height: 28px;
@@ -324,6 +352,16 @@ onMounted(async () => {
.history-log {
grid-column: 1 / -1;
overflow: hidden;
}
.history-log:first-of-type {
height: 128px;
}
.history-log:last-of-type {
height: 65px;
}
.dropdown-compat-buttons {
display: none;
}
.year-selector .legacy-button {
border: 0;
@@ -376,7 +414,12 @@ onMounted(async () => {
width: 100%;
box-sizing: border-box;
}
.history-grid.ranking-bottom .nation-position { order: 4; }
.history-log:first-of-type { margin-bottom: 0; }
.history-grid.ranking-bottom .nation-position {
order: 4;
}
.history-log:first-of-type {
height: 149px;
margin-bottom: 0;
}
}
</style>