Merge branch 'main' into feature/nation-general-lists

# Conflicts:
#	app/game-frontend/package.json
#	app/game-frontend/src/router/index.ts
This commit is contained in:
2026-07-26 06:32:54 +00:00
206 changed files with 53242 additions and 33635 deletions
@@ -3,13 +3,14 @@ import { computed, ref } from 'vue';
import type { BattleSimOptions, GeneralDraft } from '../../utils/battleSimulatorTypes';
interface Props {
general: GeneralDraft;
options: BattleSimOptions;
mode: 'attacker' | 'defender';
title: string;
canImportServer: boolean;
}
const props = defineProps<Props>();
const general = defineModel<GeneralDraft>('general', { required: true });
const emit = defineEmits<{
(event: 'import'): void;
@@ -59,7 +60,19 @@ const officerLevelOptions = [
<div class="general-subtitle">No {{ general.no }}</div>
</div>
<div class="general-actions">
<button class="action" type="button" @click="emit('import')">서버에서 가져오기</button>
<button
class="action"
type="button"
:disabled="!canImportServer"
:title="
canImportServer
? '게임 서버의 장수 정보를 불러옵니다.'
: '게임 장수를 보유해야 사용할 수 있습니다.'
"
@click="emit('import')"
>
서버에서 가져오기
</button>
<button class="action" type="button" @click="emit('save')">저장</button>
<input ref="fileInput" type="file" accept=".json" hidden @change="handleFileChange" />
<button class="action" type="button" @click="triggerLoad">불러오기</button>
@@ -187,21 +200,11 @@ const officerLevelOptions = [
<div class="form-row">
<label class="field">
<span>훈련</span>
<input
v-model.number="general.train"
type="number"
min="40"
:max="options.config.maxTrainByWar"
/>
<input v-model.number="general.train" type="number" min="40" :max="options.config.maxTrainByWar" />
</label>
<label class="field">
<span>사기</span>
<input
v-model.number="general.atmos"
type="number"
min="40"
:max="options.config.maxAtmosByWar"
/>
<input v-model.number="general.atmos" type="number" min="40" :max="options.config.maxAtmosByWar" />
</label>
<label class="field">
<span>전특</span>
@@ -308,30 +311,15 @@ const officerLevelOptions = [
<div class="form-row buff-row">
<label class="field">
<span>상대 회피</span>
<input
v-model.number="general.inheritBuff.warAvoidRatioOppose"
type="number"
min="0"
max="5"
/>
<input v-model.number="general.inheritBuff.warAvoidRatioOppose" type="number" min="0" max="5" />
</label>
<label class="field">
<span>상대 필살</span>
<input
v-model.number="general.inheritBuff.warCriticalRatioOppose"
type="number"
min="0"
max="5"
/>
<input v-model.number="general.inheritBuff.warCriticalRatioOppose" type="number" min="0" max="5" />
</label>
<label class="field">
<span>상대 계략</span>
<input
v-model.number="general.inheritBuff.warMagicTrialProbOppose"
type="number"
min="0"
max="5"
/>
<input v-model.number="general.inheritBuff.warMagicTrialProbOppose" type="number" min="0" max="5" />
</label>
</div>
</div>
@@ -391,6 +379,11 @@ const officerLevelOptions = [
color: #f0b6b6;
}
.action:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.form-block {
display: flex;
flex-direction: column;
@@ -1,13 +1,25 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import SkeletonLines from '../ui/SkeletonLines.vue';
import { computed, reactive } from 'vue';
import type { MessageType } from '@sammo-ts/logic';
import SkeletonLines from '../ui/SkeletonLines.vue';
import MessagePlate from './MessagePlate.vue';
interface MessageTarget {
generalId: number;
generalName: string;
nationId: number;
nationName: string;
color: string;
icon: string;
}
interface MessageEntry {
id: number;
text: string;
time: string;
msgType: MessageType;
src: MessageTarget;
dest: MessageTarget | null;
option?: Record<string, unknown> | null;
}
@@ -16,6 +28,22 @@ interface MessageBucket {
public: MessageEntry[];
national: MessageEntry[];
diplomacy: MessageEntry[];
permission: number;
latestRead: {
private: number;
diplomacy: number;
};
}
interface MailboxGroup {
label: string;
color?: string;
options: Array<{
label: string;
value: number;
disabled?: boolean;
color?: string;
}>;
}
const props = defineProps<{
@@ -23,7 +51,10 @@ const props = defineProps<{
loading: boolean;
targetMailbox: number;
draftText: string;
mailboxOptions: Array<{ label: string; value: number; disabled?: boolean }>;
mailboxGroups: MailboxGroup[];
generalId: number;
generalName: string;
nationId: number;
canRespondDiplomacy: boolean;
}>();
@@ -34,213 +65,390 @@ const emit = defineEmits<{
(event: 'refresh'): void;
(event: 'load-older', type: MessageType): void;
(event: 'respond', messageId: number, response: boolean): void;
(event: 'read-latest', type: 'private' | 'diplomacy', messageId: number): void;
(event: 'delete', messageId: number): void;
}>();
const messageTabs: Array<{ key: MessageType; label: string }> = [
{ key: 'public', label: '전체' },
{ key: 'national', label: '국가' },
{ key: 'private', label: '개인' },
{ key: 'diplomacy', label: '외교' },
const sections: Array<{ type: MessageType; label: string; className: string }> = [
{ type: 'public', label: '전체 메시지', className: 'PublicTalk' },
{ type: 'national', label: '국가 메시지', className: 'NationalTalk' },
{ type: 'private', label: '개인 메시지', className: 'PrivateTalk' },
{ type: 'diplomacy', label: '외교 메시지', className: 'DiplomacyTalk' },
];
const activeTab = ref<MessageType>('public');
const activeMessages = computed(() => {
if (!props.messages) {
return [] as MessageEntry[];
}
return props.messages[activeTab.value] ?? [];
const visibleLimits = reactive<Record<MessageType, number>>({
public: Number.POSITIVE_INFINITY,
national: Number.POSITIVE_INFINITY,
private: Number.POSITIVE_INFINITY,
diplomacy: Number.POSITIVE_INFINITY,
});
const bucket = (type: MessageType): MessageEntry[] => props.messages?.[type] ?? [];
const visibleMessages = (type: MessageType): MessageEntry[] => bucket(type).slice(0, visibleLimits[type]);
const permission = computed(() => props.messages?.permission ?? -1);
const setMailbox = (value: string) => {
const parsed = Number(value);
emit('update:targetMailbox', Number.isFinite(parsed) ? parsed : 0);
};
const isDiplomacyPrompt = (message: MessageEntry): boolean =>
message.msgType === 'diplomacy' &&
(message.option?.action === 'noAggression' ||
message.option?.action === 'cancelNA' ||
message.option?.action === 'stopWar');
const respond = (messageId: number, response: boolean) => {
if (!window.confirm(response ? '수락하시겠습니까?' : '거절하시겠습니까?')) {
const submit = () => {
if (!props.draftText.trim()) {
emit('refresh');
return;
}
emit('send');
};
const newestIncomingId = (type: 'private' | 'diplomacy'): number =>
bucket(type)
.filter((message) => message.src.generalId !== props.generalId)
.reduce((latest, message) => Math.max(latest, message.id), 0);
const canMarkRead = (type: 'private' | 'diplomacy'): boolean => {
if (!props.messages) {
return false;
}
const newest = newestIncomingId(type);
return newest > props.messages.latestRead[type];
};
const markRead = (type: 'private' | 'diplomacy') => {
const messageId = newestIncomingId(type);
if (messageId > 0) {
emit('read-latest', type, messageId);
}
};
const setSectionMailbox = (type: MessageType) => {
if (type === 'public') {
emit('update:targetMailbox', 9999);
} else if (type === 'national') {
emit('update:targetMailbox', 9000 + props.nationId);
}
};
const setReplyTarget = (type: MessageType, target: MessageTarget) => {
const mailbox =
(type === 'diplomacy' || type === 'national') && target.nationId !== props.nationId
? 9000 + target.nationId
: target.generalId;
if (mailbox > 0) {
emit('update:targetMailbox', mailbox);
}
};
const fold = (type: MessageType) => {
if (bucket(type).length >= 10) {
visibleLimits[type] = 10;
}
};
const forwardResponse = (messageId: number, response: boolean) => {
emit('respond', messageId, response);
};
</script>
<template>
<div class="message-panel">
<div class="message-input">
<select
class="message-select"
:value="targetMailbox"
@change="setMailbox(($event.target as HTMLSelectElement).value)"
>
<option
v-for="option in mailboxOptions"
:key="option.label"
:value="option.value"
:disabled="option.disabled"
<div class="MessagePanel">
<div class="MessageInputForm">
<div id="mailbox_list-col">
<select
id="mailbox_list"
class="message-select"
:value="targetMailbox"
aria-label="메시지 수신 대상"
@change="setMailbox(($event.target as HTMLSelectElement).value)"
>
{{ option.label }}
</option>
</select>
<input
class="message-text"
type="text"
maxlength="99"
:value="draftText"
placeholder="메시지 입력"
@input="emit('update:draftText', ($event.target as HTMLInputElement).value)"
@keydown.enter="emit('send')"
/>
<button class="message-send" @click="emit('send')">전송</button>
</div>
<div class="message-tabs">
<button
v-for="tab in messageTabs"
:key="tab.key"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key"
>
{{ tab.label }}
</button>
<button class="refresh" @click="emit('refresh')">갱신</button>
</div>
<div v-if="props.loading">
<SkeletonLines :lines="4" />
</div>
<div v-else-if="!props.messages" class="empty">메시지를 불러오지 못했습니다.</div>
<div v-else class="message-list">
<div v-if="activeMessages.length === 0" class="empty">메시지가 없습니다.</div>
<div v-else>
<div v-for="message in activeMessages" :key="message.id" class="message-item">
<div class="text">{{ message.text }}</div>
<div v-if="isDiplomacyPrompt(message)" class="message-response">
<button class="accept" :disabled="!canRespondDiplomacy" @click="respond(message.id, true)">
수락
</button>
<button class="decline" :disabled="!canRespondDiplomacy" @click="respond(message.id, false)">
거절
</button>
</div>
<div class="time">{{ message.time }}</div>
</div>
<button class="load-older" @click="emit('load-older', activeTab)">이전 메시지</button>
<optgroup
v-for="group in mailboxGroups"
:key="group.label"
:label="group.label"
:style="{ backgroundColor: group.color ?? '#000000', color: '#ffffff' }"
>
<option
v-for="option in group.options"
:key="`${group.label}-${option.value}`"
:value="option.value"
:disabled="option.disabled"
:style="{ backgroundColor: option.color ?? '#000000', color: '#ffffff' }"
>
{{ option.label }}
</option>
</optgroup>
</select>
</div>
<div id="msg_input-col">
<input
class="message-text"
type="text"
maxlength="99"
:value="draftText"
aria-label="메시지 입력"
@input="emit('update:draftText', ($event.target as HTMLInputElement).value)"
@keydown.enter="submit"
/>
</div>
<div id="msg_submit-col">
<button class="message-send" type="button" @click="submit">서신전달&amp;갱신</button>
</div>
</div>
<div v-if="loading && !messages" class="message-loading">
<SkeletonLines :lines="4" />
</div>
<template v-else>
<section
v-for="section in sections"
:key="section.type"
:class="['message-section', section.className]"
:data-message-type="section.type"
>
<div class="stickyAnchor"></div>
<header class="BoardHeader">
<div class="header-label">{{ section.label }}</div>
<button
v-if="section.type === 'public' || section.type === 'national'"
class="btn-more-small action-primary"
type="button"
@click="setSectionMailbox(section.type)"
>
여기로
</button>
<button
v-else
class="btn-more-small action-secondary"
type="button"
:disabled="!canMarkRead(section.type)"
@click="markRead(section.type)"
>
모두 읽음
</button>
</header>
<div v-if="bucket(section.type).length === 0" class="empty-message">메시지가 없습니다.</div>
<div v-else class="MessageList">
<MessagePlate
v-for="message in visibleMessages(section.type)"
:key="message.id"
:message="message"
:general-id="generalId"
:general-name="generalName"
:nation-id="nationId"
:permission="permission"
:can-respond-diplomacy="canRespondDiplomacy"
@set-target="setReplyTarget"
@delete="emit('delete', $event)"
@respond="forwardResponse"
/>
<div class="Actions">
<button class="fold-message" type="button" @click="fold(section.type)">접기</button>
<button class="load-older" type="button" @click="emit('load-older', section.type)">
이전 메시지 불러오기
</button>
</div>
</div>
</section>
</template>
</div>
</template>
<style scoped>
.message-panel {
display: flex;
flex-direction: column;
gap: 12px;
.MessagePanel {
color: #fff;
font-size: 14px;
}
.message-input {
.MessageInputForm {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 4fr) minmax(0, 1fr);
grid-template-areas: 'mailbox input submit';
background-color: #302016;
background-image: url('/image/game/back_walnut.jpg');
}
#mailbox_list-col {
grid-area: mailbox;
}
#msg_input-col {
grid-area: input;
}
#msg_submit-col {
grid-area: submit;
}
#mailbox_list-col,
#msg_input-col,
#msg_submit-col {
display: grid;
grid-template-columns: minmax(90px, 120px) 1fr auto;
gap: 6px;
}
.message-select,
.message-text,
.message-send {
height: 35.5px;
border: 1px solid #6c757d;
border-radius: 4px;
font: inherit;
}
.message-select {
width: 100%;
background-color: #212529;
padding: 4px 30px 4px 12px;
color: #fff;
font-weight: 700;
}
.message-text {
background: rgba(16, 16, 16, 0.8);
border: 1px solid rgba(201, 164, 90, 0.4);
color: inherit;
padding: 6px;
font-size: 0.75rem;
width: 100%;
background-color: #fff;
padding: 4px 8px;
color: #212529;
}
.message-send,
.action-primary {
background-color: #337ab7;
color: #fff;
}
.message-send {
border: 1px solid rgba(201, 164, 90, 0.4);
padding: 6px 10px;
font-size: 0.75rem;
cursor: pointer;
}
.message-tabs {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.message-tabs button {
border: 1px solid rgba(201, 164, 90, 0.4);
padding: 4px 8px;
font-size: 0.7rem;
cursor: pointer;
}
.message-tabs button.active {
background: rgba(201, 164, 90, 0.2);
.message-send:hover {
background-color: #375a7f;
}
.message-tabs .refresh {
margin-left: auto;
.message-send:focus,
.message-send:focus-visible {
outline: none !important;
outline-width: 0 !important;
box-shadow: none !important;
}
.message-list {
.message-loading {
padding: 8px;
}
.message-section {
min-width: 0;
}
.BoardHeader {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 25px;
align-items: center;
outline: 1px solid gray;
background-color: #302016;
background-image: url('/image/game/back_walnut.jpg');
color: #fff;
}
.message-item {
border: 1px solid rgba(201, 164, 90, 0.2);
padding: 6px;
font-size: 0.75rem;
.header-label {
flex: 1;
}
.message-item .time {
margin-top: 4px;
font-size: 0.65rem;
color: rgba(232, 221, 196, 0.6);
}
.message-response {
display: flex;
justify-content: flex-end;
gap: 4px;
margin-top: 5px;
margin-right: 5px;
}
.message-response button {
border: 1px solid rgba(201, 164, 90, 0.4);
padding: 3px 10px;
font-size: 0.7rem;
.btn-more-small {
margin: 1px;
border: 1px solid transparent;
border-radius: 3px;
padding: 2px 6px;
font-size: 11.2px;
line-height: 1.5;
cursor: pointer;
}
.message-response .accept {
color: #8fd18f;
.action-secondary {
border-color: #6c757d;
background-color: #6c757d;
color: #fff;
}
.message-response .decline {
color: #e09a9a;
.btn-more-small:disabled {
cursor: default;
opacity: 0.65;
}
.message-response button:disabled {
cursor: not-allowed;
opacity: 0.5;
.empty-message {
min-height: 22px;
}
.MessageList {
overflow-x: hidden;
}
.Actions {
display: grid;
}
.fold-message,
.load-older {
border: 1px solid transparent;
padding: 6px 12px;
color: #fff;
font: inherit;
cursor: pointer;
}
.fold-message {
background-color: #212529;
}
.load-older {
border: 1px dashed rgba(201, 164, 90, 0.3);
padding: 6px;
font-size: 0.7rem;
cursor: pointer;
background-color: #6c757d;
}
.empty {
color: rgba(232, 221, 196, 0.6);
@media (min-width: 940px) {
.MessagePanel {
display: grid;
grid-template-columns: 1fr 1fr;
}
.MessageInputForm,
.message-loading {
grid-column: 1 / 3;
}
.PublicTalk,
.PrivateTalk {
border-right: 1px solid gray;
}
.fold-message {
display: none;
}
.MessageList {
overflow-y: auto;
}
}
@media (max-width: 939.98px) {
.MessageInputForm {
position: sticky;
z-index: 5;
top: 0;
grid-template-columns: 1fr 1fr;
grid-template-areas:
'mailbox submit'
'input input';
}
.message-text {
height: 33.5px;
}
.BoardHeader {
position: sticky;
z-index: 4;
top: 62px;
}
}
</style>
@@ -0,0 +1,431 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import type { MessageType } from '@sammo-ts/logic';
interface MessageTarget {
generalId: number;
generalName: string;
nationId: number;
nationName: string;
color: string;
icon: string;
}
interface MessageEntry {
id: number;
text: string;
time: string;
msgType: MessageType;
src: MessageTarget;
dest: MessageTarget | null;
option?: Record<string, unknown> | null;
}
const props = defineProps<{
message: MessageEntry;
generalId: number;
generalName: string;
nationId: number;
permission: number;
canRespondDiplomacy: boolean;
}>();
const emit = defineEmits<{
(event: 'set-target', type: MessageType, target: MessageTarget): void;
(event: 'delete', messageId: number): void;
(event: 'respond', messageId: number, response: boolean): void;
}>();
const now = ref(Date.now());
let deleteTimer: number | null = null;
const destination = computed<MessageTarget>(
() =>
props.message.dest ?? {
generalId: 0,
generalName: '',
nationId: 0,
nationName: '재야',
color: '#000000',
icon: '/image/icons/default.jpg',
}
);
const invalid = computed(() => props.message.option?.invalid === true);
const hasAction = computed(() => typeof props.message.option?.action === 'string');
const nationDirection = computed(() => {
if (props.message.src.nationId === destination.value.nationId) {
return 'local';
}
return props.message.src.nationId === props.nationId ? 'src' : 'dest';
});
const parseMessageTime = (): number => {
const normalized = props.message.time.includes('T')
? props.message.time
: `${props.message.time.replace(' ', 'T')}Z`;
return Date.parse(normalized);
};
const deletable = computed(() => {
if (invalid.value || hasAction.value || props.message.src.generalId !== props.generalId) {
return false;
}
if (props.message.option?.deletable === false) {
return false;
}
const sentAt = parseMessageTime();
return Number.isFinite(sentAt) && sentAt + 5 * 60 * 1000 > now.value;
});
const scheduleDeleteExpiry = () => {
const sentAt = parseMessageTime();
if (!Number.isFinite(sentAt)) {
return;
}
const delay = sentAt + 5 * 60 * 1000 - Date.now();
if (delay <= 0) {
now.value = Date.now();
return;
}
deleteTimer = window.setTimeout(() => {
now.value = Date.now();
}, delay);
};
const isBright = (color: string): boolean => {
const match = /^#([0-9a-f]{6})$/i.exec(color);
if (!match) {
return false;
}
const value = Number.parseInt(match[1]!, 16);
const red = (value >> 16) & 0xff;
const green = (value >> 8) & 0xff;
const blue = value & 0xff;
return red * 0.299 + green * 0.587 + blue * 0.114 > 160;
};
const iconUrl = computed(() => {
const icon = props.message.src.icon?.trim();
if (!icon) {
return '/image/icons/default.jpg';
}
if (icon.startsWith('/') || /^https?:\/\//i.test(icon)) {
return icon;
}
return `${import.meta.env.BASE_URL}${icon.replace(/^\/+/, '')}`;
});
const targetClass = (target: MessageTarget) => ({
'msg-target': true,
'msg-bright': isBright(target.color),
'msg-dark': !isBright(target.color),
});
const setTarget = (target: MessageTarget) => {
emit('set-target', props.message.msgType, target);
};
const requestDelete = () => {
if (!window.confirm('삭제하시겠습니까?')) {
return;
}
emit('delete', props.message.id);
};
const respond = (response: boolean) => {
if (!window.confirm(response ? '수락하시겠습니까?' : '거절하시겠습니까?')) {
return;
}
emit('respond', props.message.id, response);
};
onMounted(scheduleDeleteExpiry);
onBeforeUnmount(() => {
if (deleteTimer !== null) {
window.clearTimeout(deleteTimer);
}
});
</script>
<template>
<article
:id="`msg_${message.id}`"
:class="['msg-plate', `msg-plate-${message.msgType}`, `msg-plate-${nationDirection}`]"
:data-id="message.id"
>
<div class="msg-icon">
<img class="general-icon" width="64" height="64" :src="iconUrl" :alt="message.src.generalName" />
</div>
<div class="msg-body">
<div class="msg-header">
<button v-if="deletable" class="delete-message" type="button" @click="requestDelete"></button>
<template v-if="message.msgType === 'private'">
<template v-if="message.src.generalId === generalId">
<span :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }"
></span
>
<span class="msg-from-to"></span>
<button
:class="targetClass(destination)"
:style="{ backgroundColor: destination.color }"
type="button"
@click="setTarget(destination)"
>
{{ destination.generalName }}:{{ destination.nationName }} |
</button>
</template>
<template v-else>
<button
:class="targetClass(message.src)"
:style="{ backgroundColor: message.src.color }"
type="button"
@click="setTarget(message.src)"
>
{{ message.src.generalName }}:{{ message.src.nationName }} |
</button>
<span class="msg-from-to"></span>
<span :class="targetClass(destination)" :style="{ backgroundColor: destination.color }"
></span
>
</template>
</template>
<template v-else-if="message.msgType === 'national' && message.src.nationId === destination.nationId">
<span :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
{{ message.src.generalName }}
</span>
</template>
<template
v-else-if="(message.msgType === 'national' || message.msgType === 'diplomacy') && permission >= 4"
>
<template v-if="message.src.nationId === nationId">
<span :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
{{ message.src.generalName }}
</span>
<span class="msg-from-to"></span>
<button
:class="targetClass(destination)"
:style="{ backgroundColor: destination.color }"
type="button"
@click="setTarget(destination)"
>
{{ destination.nationName }} |
</button>
</template>
<button
v-else
:class="targetClass(message.src)"
:style="{ backgroundColor: message.src.color }"
type="button"
@click="setTarget(message.src)"
>
{{ message.src.generalName }}:{{ message.src.nationName }} |
</button>
</template>
<template v-else-if="message.msgType === 'national' || message.msgType === 'diplomacy'">
<template v-if="message.src.nationId === nationId">
<span :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
{{ message.src.generalName }}
</span>
<span class="msg-from-to"></span>
<span :class="targetClass(destination)" :style="{ backgroundColor: destination.color }">
{{ destination.nationName }}
</span>
</template>
<span v-else :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
{{ message.src.generalName }}:{{ message.src.nationName }}
</span>
</template>
<button
v-else-if="message.src.generalId !== generalId"
:class="targetClass(message.src)"
:style="{ backgroundColor: message.src.color }"
type="button"
@click="setTarget(message.src)"
>
{{ message.src.generalName }}:{{ message.src.nationName }} |
</button>
<span v-else :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
{{ message.src.generalName }}
</span>
<span class="msg-time">&lt;{{ message.time }}&gt;</span>
</div>
<div :class="['msg-content', invalid ? 'msg-invalid' : 'msg-valid']">
{{ invalid ? '삭제된 메시지입니다' : message.text }}
</div>
<div v-if="hasAction" class="message-response">
<button
class="prompt-yes"
type="button"
:disabled="message.msgType === 'diplomacy' && !canRespondDiplomacy"
@click="respond(true)"
>
수락
</button>
<button
class="prompt-no"
type="button"
:disabled="message.msgType === 'diplomacy' && !canRespondDiplomacy"
@click="respond(false)"
>
거절
</button>
</div>
</div>
</article>
</template>
<style scoped>
.msg-plate {
display: grid;
grid-template-columns: 64px minmax(0, 1fr);
width: 100%;
min-height: 64px;
outline: 1px solid gray;
color: #fff;
font-size: 12.5px;
word-break: break-all;
}
.msg-plate-private {
background-color: #5d1e1a;
}
.msg-plate-private.msg-plate-dest {
background-color: #5d461a;
}
.msg-plate-public {
background-color: #141c65;
}
.msg-plate-national,
.msg-plate-diplomacy {
background-color: #00582c;
}
.msg-plate-national.msg-plate-dest,
.msg-plate-diplomacy.msg-plate-dest {
background-color: #704615;
}
.msg-plate-national.msg-plate-src,
.msg-plate-diplomacy.msg-plate-src {
background-color: #70153b;
}
.msg-icon {
width: 64px;
height: 64px;
border-right: 1px solid gray;
}
.general-icon {
display: block;
width: 64px;
max-width: none;
height: 64px;
object-fit: fill;
}
.msg-body {
min-width: 0;
padding-left: 0;
}
.msg-header {
position: relative;
margin-bottom: 3px;
color: #fff;
font-weight: 700;
}
.msg-target {
display: inline-block;
margin: 2px 2px 0;
border: 0;
border-radius: 3px;
padding: 2px 3px;
box-shadow: 2px 2px #000;
font: inherit;
font-weight: inherit;
}
button.msg-target {
cursor: pointer;
}
.msg-bright {
color: #000;
}
.msg-dark {
color: #fff;
}
.msg-from-to {
display: inline-block;
}
.msg-time {
font-size: 0.75em;
font-weight: 400;
}
.delete-message {
position: absolute;
z-index: 1;
top: 0;
right: 0;
margin: 2px 2px 0;
border: 1px solid #ffc107;
border-radius: 3px;
background: transparent;
padding: 2px 4px;
color: #ffc107;
font-size: 8px;
cursor: pointer;
}
.msg-content {
overflow: hidden;
margin-right: 5px;
margin-left: 10px;
white-space: pre-wrap;
}
.msg-invalid {
color: rgba(255, 255, 255, 0.5);
}
.message-response {
display: flex;
justify-content: flex-end;
gap: 0;
margin-top: 5px;
margin-right: 5px;
}
.message-response button {
min-width: 42px;
border: 1px outset buttonborder;
background: buttonface;
padding: 1px 6px;
color: buttontext;
font-size: 12.5px;
cursor: pointer;
}
.message-response button:disabled {
cursor: not-allowed;
opacity: 0.65;
}
</style>
+48 -4
View File
@@ -21,7 +21,6 @@ import NotFoundView from '../views/NotFoundView.vue';
import TournamentView from '../views/TournamentView.vue';
import BettingView from '../views/BettingView.vue';
import MyPageView from '../views/MyPageView.vue';
import MySettingsView from '../views/MySettingsView.vue';
import BoardView from '../views/BoardView.vue';
import DiplomacyView from '../views/DiplomacyView.vue';
import BestGeneralView from '../views/BestGeneralView.vue';
@@ -35,7 +34,38 @@ import NationBettingView from '../views/NationBettingView.vue';
import NpcListView from '../views/NpcListView.vue';
import NationListView from '../views/NationListView.vue';
import GeneralListView from '../views/GeneralListView.vue';
import TrafficView from '../views/TrafficView.vue';
import { useSessionStore } from '../stores/session';
import { trpc } from '../utils/trpc';
const accessPageByRouteName = {
home: 'front-info',
'nation-info': 'nation-info',
'nation-cities': 'nation-cities',
'global-info': 'global-info',
'nation-list': 'nation-list',
'general-list': 'general-list',
'current-city': 'current-city',
diplomacy: 'diplomacy',
'nation-generals': 'nation-generals',
'nation-personnel': 'nation-personnel',
'nation-finance': 'nation-finance',
'battle-center': 'battle-center',
board: 'board',
'board-secret': 'board',
'best-general': 'best-general',
'hall-of-fame': 'hall-of-fame',
'dynasty-list': 'dynasty',
'dynasty-detail': 'dynasty',
yearbook: 'yearbook',
'nation-betting': 'nation-betting',
traffic: 'traffic',
'npc-list': 'npc-list',
'my-page': 'my-page',
'npc-control': 'npc-control',
tournament: 'tournament',
betting: 'betting',
} as const;
const routes = [
{
@@ -211,7 +241,6 @@ const routes = [
component: BattleSimulatorView,
meta: {
requiresAuth: true,
requiresGeneral: true,
},
},
{
@@ -261,6 +290,7 @@ const routes = [
component: YearbookView,
meta: {
requiresAuth: true,
requiresGeneral: true,
},
},
{
@@ -272,6 +302,11 @@ const routes = [
requiresGeneral: true,
},
},
{
path: '/traffic',
name: 'traffic',
component: TrafficView,
},
{
path: '/npc-list',
name: 'npc-list',
@@ -297,8 +332,7 @@ const routes = [
},
{
path: '/my-settings',
name: 'my-settings',
component: MySettingsView,
redirect: '/my-page',
meta: {
requiresAuth: true,
requiresGeneral: true,
@@ -381,4 +415,14 @@ router.beforeEach(async (to) => {
return true;
});
router.afterEach((to) => {
const session = useSessionStore();
const routeName = typeof to.name === 'string' ? to.name : '';
const page = accessPageByRouteName[routeName as keyof typeof accessPageByRouteName];
if (!page || !session.hasGeneral) {
return;
}
void trpc.public.recordAccess.mutate({ page }).catch(() => undefined);
});
export default router;
+140 -22
View File
@@ -23,6 +23,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
type MapLayout = Awaited<ReturnType<typeof trpc.world.getMapLayout.query>>;
type CommandTable = Awaited<ReturnType<typeof trpc.turns.getCommandTable.query>>;
type MessageBundle = Awaited<ReturnType<typeof trpc.messages.getRecent.query>>;
type MessageContacts = Awaited<ReturnType<typeof trpc.messages.getContacts.query>>;
type BoardAccess = Awaited<ReturnType<typeof trpc.board.getAccess.query>>;
type ReservedTurnView = Awaited<ReturnType<typeof trpc.turns.reserved.getGeneral.query>>[number];
@@ -37,12 +38,14 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
const mapLayout = ref<MapLayout | null>(null);
const commandTable = ref<CommandTable | null>(null);
const messages = ref<MessageBundle | null>(null);
const messageContacts = ref<MessageContacts | null>(null);
const boardAccess = ref<BoardAccess | null>(null);
const reservedGeneralTurns = ref<ReservedTurnView[] | null>(null);
const reservedNationTurns = ref<ReservedTurnView[] | null>(null);
const messageDraftText = ref('');
const targetMailbox = ref<number>(MESSAGE_MAILBOX_PUBLIC);
let initializedMailboxGeneralId: number | null = null;
const general = computed(() => generalContext.value?.general ?? null);
const city = computed(() => generalContext.value?.city ?? null);
@@ -86,18 +89,85 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
} as const;
});
const mailboxOptions = computed(() => {
const options: Array<{ label: string; value: number; disabled?: boolean }> = [
{ label: '공공', value: MESSAGE_MAILBOX_PUBLIC },
const mailboxGroups = computed(() => {
type MailboxOption = {
label: string;
value: number;
disabled?: boolean;
color?: string;
};
type MailboxGroup = {
label: string;
color?: string;
options: MailboxOption[];
};
const ownNationId = general.value?.nationId ?? 0;
const ownMailbox = MESSAGE_MAILBOX_NATIONAL_BASE + ownNationId;
const permission = messages.value?.permission ?? -1;
const contacts = messageContacts.value?.nation ?? [];
const ownNation = contacts.find((nation) => nation.mailbox === ownMailbox);
const groups: MailboxGroup[] = [
{
label: '즐겨찾기',
color: '#000000',
options: [
{
label: '【 아국 메세지 】',
value: ownMailbox,
color: ownNation?.color ?? '#000000',
},
{
label: '【 전체 메세지 】',
value: MESSAGE_MAILBOX_PUBLIC,
color: '#000000',
},
],
},
];
if (nationId.value) {
options.push({ label: '국가', value: MESSAGE_MAILBOX_NATIONAL_BASE + nationId.value });
} else {
options.push({ label: '국가', value: -1, disabled: true });
if (permission >= 4) {
groups.push({
label: '외교메시지',
color: '#000000',
options: contacts
.filter((nation) => nation.mailbox !== ownMailbox && nation.nationId > 0)
.map((nation) => ({
label: nation.name,
value: nation.mailbox,
color: nation.color,
})),
});
}
options.push({ label: '외교', value: -2, disabled: true });
options.push({ label: '개인', value: -3, disabled: true });
return options;
const sortedContacts = [...contacts].sort((left, right) => {
if (left.mailbox === ownMailbox) return -1;
if (right.mailbox === ownMailbox) return 1;
return left.mailbox - right.mailbox;
});
for (const nation of sortedContacts) {
const options = [...nation.general]
.filter(([id]) => id !== generalId.value)
.sort((left, right) => left[1].localeCompare(right[1], 'ko'))
.map(([id, name, flags]) => {
const ruler = Boolean(flags & 1);
const ambassador = Boolean(flags & 4);
return {
label: ruler ? `*${name}*` : ambassador ? `#${name}#` : name,
value: id,
disabled: permission === 4 && ambassador && nation.mailbox !== ownMailbox,
color: nation.color,
};
});
if (options.length > 0) {
groups.push({
label: nation.name,
color: nation.color,
options,
});
}
}
return groups;
});
const statusLine = computed(() => {
@@ -147,25 +217,32 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
context.general.nationId > 0 && context.general.officerLevel >= 5
? trpc.turns.reserved.getNation.query({ generalId: id })
: Promise.resolve(null);
const [layout, lobby, map, commands, messageData, access, generalTurns, nationTurns] = await Promise.all([
layoutPromise,
trpc.lobby.info.query(),
trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }),
trpc.turns.getCommandTable.query({ generalId: id }),
trpc.messages.getRecent.query({ generalId: id }),
trpc.board.getAccess.query(),
generalTurnsPromise,
nationTurnsPromise,
]);
const [layout, lobby, map, commands, messageData, contacts, access, generalTurns, nationTurns] =
await Promise.all([
layoutPromise,
trpc.lobby.info.query(),
trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }),
trpc.turns.getCommandTable.query({ generalId: id }),
trpc.messages.getRecent.query({ generalId: id }),
trpc.messages.getContacts.query({ generalId: id }),
trpc.board.getAccess.query(),
generalTurnsPromise,
nationTurnsPromise,
]);
mapLayout.value = layout;
lobbyInfo.value = lobby;
worldMap.value = map;
commandTable.value = commands;
messages.value = messageData;
messageContacts.value = contacts;
boardAccess.value = access;
reservedGeneralTurns.value = generalTurns;
reservedNationTurns.value = nationTurns;
if (initializedMailboxGeneralId !== id) {
targetMailbox.value = MESSAGE_MAILBOX_NATIONAL_BASE + context.general.nationId;
initializedMailboxGeneralId = id;
}
} catch (err) {
error.value = resolveErrorMessage(err);
} finally {
@@ -200,12 +277,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
}
try {
messageDraftText.value = '';
await trpc.messages.send.mutate({
generalId: id,
mailbox,
text,
});
messageDraftText.value = '';
await refreshMessages();
} catch (err) {
error.value = resolveErrorMessage(err);
@@ -260,6 +337,44 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
}
};
const readLatestMessage = async (type: 'private' | 'diplomacy', messageId: number) => {
const id = generalId.value;
if (!id || messageId <= 0) {
return;
}
try {
await trpc.messages.readLatest.mutate({
generalId: id,
type,
messageId,
});
if (messages.value) {
messages.value = {
...messages.value,
latestRead: {
...messages.value.latestRead,
[type]: Math.max(messages.value.latestRead[type], messageId),
},
};
}
} catch (err) {
error.value = resolveErrorMessage(err);
}
};
const deleteMessage = async (messageId: number) => {
const id = generalId.value;
if (!id) {
return;
}
try {
await trpc.messages.delete.mutate({ generalId: id, messageId });
await refreshMessages();
} catch (err) {
error.value = resolveErrorMessage(err);
}
};
const setGeneralTurn = async (turnIndex: number, action: string) => {
const id = generalId.value;
if (!id) {
@@ -484,12 +599,13 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
selectedCity,
commandTable,
messages,
messageContacts,
boardAccess,
reservedGeneralTurns,
reservedNationTurns,
messageDraftText,
targetMailbox,
mailboxOptions,
mailboxGroups,
statusLine,
realtimeLabel,
setRealtimeEnabled,
@@ -498,6 +614,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
sendMessage,
loadOlderMessages,
respondToMessage,
readLatestMessage,
deleteMessage,
setGeneralTurn,
shiftGeneralTurns,
setNationTurn,
+2 -5
View File
@@ -24,11 +24,10 @@ export const formatLog = (text?: string): string => {
return '';
}
let match: RegExpExecArray | null = null;
let lastIndex = 0;
const result: string[] = [];
while ((match = logRegex.exec(text)) !== null) {
for (let match = logRegex.exec(text); match !== null; match = logRegex.exec(text)) {
const partAll = match[0];
const subPart = match[1];
const index = match.index;
@@ -40,9 +39,7 @@ export const formatLog = (text?: string): string => {
if (subPart === '/') {
result.push('</span>');
} else if (subPart.length === 2) {
result.push(
`<span style="${convertMap[subPart[0]] ?? ''}${convertMap2[subPart[1]] ?? ''}">`
);
result.push(`<span style="${convertMap[subPart[0]] ?? ''}${convertMap2[subPart[1]] ?? ''}">`);
} else {
result.push(`<span style="${convertMap[subPart] ?? ''}">`);
}
@@ -0,0 +1,23 @@
const lightTextBackgrounds = new Set([
'',
'#330000',
'#FF0000',
'#800000',
'#A0522D',
'#FF6347',
'#808000',
'#008000',
'#2E8B57',
'#008080',
'#6495ED',
'#0000FF',
'#000080',
'#483D8B',
'#7B68EE',
'#800080',
'#A9A9A9',
'#000000',
]);
export const legacyNationTextColor = (backgroundColor: string): '#FFFFFF' | '#000000' =>
lightTextBackgrounds.has(backgroundColor.toUpperCase()) ? '#FFFFFF' : '#000000';
+688 -201
View File
@@ -1,8 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import { formatLog } from '../utils/formatLog';
import { trpc } from '../utils/trpc';
@@ -11,7 +10,8 @@ type ResourceAuction = AuctionOverview['resourceAuctions'][number];
type UniqueAuction = AuctionOverview['uniqueAuctions'][number];
type UniqueDetail = Awaited<ReturnType<typeof trpc.auction.getUniqueDetail.query>>;
const activeTab = ref<'resource' | 'unique'>('resource');
const route = useRoute();
const activeTab = ref<'resource' | 'unique'>(route.query.type === 'unique' ? 'unique' : 'resource');
const loading = ref(false);
const actionBusy = ref(false);
const error = ref<string | null>(null);
@@ -38,22 +38,57 @@ const resolveErrorMessage = (value: unknown): string => {
};
const formatNumber = (value: number | null | undefined): string => (value ?? 0).toLocaleString();
const formatDate = (value: string): string =>
new Intl.DateTimeFormat('ko-KR', {
const cutDateTime = (value: string | null | undefined, showSecond = false): string => {
if (!value) {
return '-';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value.slice(5, showSecond ? 19 : 16);
}
const parts = new Intl.DateTimeFormat('ko-KR', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(new Date(value));
...(showSecond ? { second: '2-digit' } : {}),
hour12: false,
}).formatToParts(date);
const part = (type: Intl.DateTimeFormatPartTypes): string =>
parts.find((entry) => entry.type === type)?.value ?? '';
return `${part('month')}-${part('day')} ${part('hour')}:${part('minute')}${showSecond ? `:${part('second')}` : ''}`;
};
const resourceTitle = (auction: ResourceAuction): string =>
auction.type === 'BUY_RICE' ? '쌀 구매' : '쌀 판매';
const hostResource = (auction: ResourceAuction): string => (auction.type === 'BUY_RICE' ? '쌀' : '금');
const bidResource = (auction: ResourceAuction): string => (auction.type === 'BUY_RICE' ? '금' : '쌀');
const buyRice = computed(() =>
(overview.value?.resourceAuctions ?? []).filter((auction) => auction.type === 'BUY_RICE')
);
const sellRice = computed(() =>
(overview.value?.resourceAuctions ?? []).filter((auction) => auction.type === 'SELL_RICE')
);
const ongoingUnique = computed(() =>
(overview.value?.uniqueAuctions ?? []).filter((auction) => auction.status === 'OPEN')
);
const finishedUnique = computed(() =>
(overview.value?.uniqueAuctions ?? []).filter((auction) => auction.status !== 'OPEN')
);
const resourceAuctions = computed(() => overview.value?.resourceAuctions ?? []);
const uniqueAuctions = computed(() => overview.value?.uniqueAuctions ?? []);
const selectResource = (auction: ResourceAuction): void => {
selectedResource.value = auction;
bidAmount.value = auction.highestBid?.amount ?? auction.detail.startBidAmount ?? 0;
};
const selectUnique = async (auction: UniqueAuction): Promise<void> => {
selectedUnique.value = auction;
uniqueDetail.value = null;
error.value = null;
try {
uniqueDetail.value = await trpc.auction.getUniqueDetail.query({ auctionId: auction.id });
const highest = uniqueDetail.value.bids[0]?.amount ?? auction.detail.startBidAmount ?? 0;
bidAmount.value = Math.max(Math.ceil(highest * 1.01), highest + 10);
} catch (err) {
error.value = resolveErrorMessage(err);
}
};
const loadOverview = async (): Promise<void> => {
loading.value = true;
@@ -65,8 +100,14 @@ const loadOverview = async (): Promise<void> => {
overview.value.resourceAuctions.find((auction) => auction.id === selectedResource.value?.id) ?? null;
}
if (selectedUnique.value) {
selectedUnique.value =
const updated =
overview.value.uniqueAuctions.find((auction) => auction.id === selectedUnique.value?.id) ?? null;
selectedUnique.value = updated;
if (updated) {
await selectUnique(updated);
}
} else if (activeTab.value === 'unique' && ongoingUnique.value[0]) {
await selectUnique(ongoingUnique.value[0]);
}
} catch (err) {
error.value = resolveErrorMessage(err);
@@ -75,23 +116,6 @@ const loadOverview = async (): Promise<void> => {
}
};
const selectResource = (auction: ResourceAuction): void => {
selectedResource.value = auction;
bidAmount.value = auction.highestBid?.amount ?? auction.detail.startBidAmount ?? 0;
};
const selectUnique = async (auction: UniqueAuction): Promise<void> => {
selectedUnique.value = auction;
error.value = null;
try {
uniqueDetail.value = await trpc.auction.getUniqueDetail.query({ auctionId: auction.id });
const highest = uniqueDetail.value.bids[0]?.amount ?? auction.detail.startBidAmount ?? 0;
bidAmount.value = Math.max(Math.ceil(highest * 1.01), highest + 10);
} catch (err) {
error.value = resolveErrorMessage(err);
}
};
const runAction = async (action: () => Promise<void>): Promise<void> => {
if (actionBusy.value) {
return;
@@ -138,7 +162,7 @@ const bidResourceAuction = (): Promise<void> =>
} else {
await trpc.auction.bidSellRice.mutate({ auctionId: auction.id, amount: bidAmount.value });
}
message.value = `${auction.id}번 경매에 입찰했습니다.`;
message.value = '입찰했습니다.';
});
const bidUniqueAuction = (): Promise<void> =>
@@ -147,206 +171,669 @@ const bidUniqueAuction = (): Promise<void> =>
if (!auction) {
return;
}
if (!window.confirm(`${auction.detail.title ?? auction.targetCode ?? '유니크'}${bidAmount.value} 포인트를 입찰하시겠습니까?`)) {
if (
!window.confirm(
`${auction.detail.title ?? auction.targetCode ?? '유니크'}${bidAmount.value}유산포인트를 입찰하시겠습니까?`
)
) {
return;
}
await trpc.auction.bidUnique.mutate({
auctionId: auction.id,
amount: bidAmount.value,
tryExtendCloseDate: true,
tryExtendCloseDate: false,
});
message.value = `${auction.id}번 유니크 경매에 입찰했습니다.`;
await selectUnique(auction);
message.value = '입찰이 완료되었습니다.';
});
const closeWindow = (): void => window.close();
watch(activeTab, (tab) => {
error.value = null;
message.value = null;
if (tab === 'unique' && !selectedUnique.value && ongoingUnique.value[0]) {
void selectUnique(ongoingUnique.value[0]);
}
});
onMounted(() => {
void loadOverview();
});
</script>
<template>
<main class="auction-page">
<header class="page-header">
<div>
<h1>거래장</h1>
<p>· 거래와 유니크 아이템 경매를 확인합니다.</p>
</div>
<button class="ghost" :disabled="loading" @click="loadOverview">새로고침</button>
<main id="container" class="legacy-auction-page bg0">
<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">
갱신
</button>
<h1>{{ activeTab === 'resource' ? '경매장' : '유니크 경매장' }}</h1>
<button
class="legacy-button tab-button"
:aria-pressed="activeTab === 'resource'"
@click="activeTab = 'resource'"
>
/
</button>
<button
class="legacy-button tab-button"
:aria-pressed="activeTab === 'unique'"
@click="activeTab = 'unique'"
>
유니크
</button>
</header>
<nav class="tabs" aria-label="경매 종류">
<button :class="{ active: activeTab === 'resource' }" @click="activeTab = 'resource'">· 경매</button>
<button :class="{ active: activeTab === 'unique' }" @click="activeTab = 'unique'">유니크 경매</button>
</nav>
<p v-if="error" class="auction-notice error" role="alert">{{ error }}</p>
<p v-if="message" class="auction-notice success" role="status">{{ message }}</p>
<div v-if="loading && !overview" class="loading-state">불러오는 중...</div>
<p v-if="error" class="notice error">{{ error }}</p>
<p v-if="message" class="notice success">{{ message }}</p>
<SkeletonLines v-if="loading && !overview" :lines="8" />
<section v-else-if="activeTab === 'resource'" class="resource-auction bg0">
<h2 class="section-title bg2">거래장</h2>
<template v-else-if="activeTab === 'resource'">
<PanelCard title="진행 중인 금·쌀 경매" subtitle="행을 선택하면 아래에서 입찰할 수 있습니다.">
<div class="auction-table resource-table">
<div class="table-head">
<span>번호</span><span>종류</span><span>판매자</span><span>수량</span><span>입찰</span>
<span>현재</span><span>마감가</span><span>종료</span>
</div>
<button
v-for="auction in resourceAuctions"
:key="auction.id"
class="table-row"
:class="{ selected: selectedResource?.id === auction.id }"
@click="selectResource(auction)"
>
<span>{{ auction.id }}</span>
<span>{{ resourceTitle(auction) }}</span>
<span>{{ auction.hostName }}</span>
<span>{{ hostResource(auction) }} {{ formatNumber(auction.detail.amount) }}</span>
<span>{{ auction.highestBid?.bidderName ?? '-' }}</span>
<span>{{ bidResource(auction) }} {{ formatNumber(auction.highestBid?.amount ?? auction.detail.startBidAmount) }}</span>
<span>{{ bidResource(auction) }} {{ formatNumber(auction.detail.finishBidAmount) }}</span>
<span>{{ formatDate(auction.closeAt) }}</span>
</button>
<p v-if="resourceAuctions.length === 0" class="empty">진행 중인 경매가 없습니다.</p>
<section class="resource-section" aria-labelledby="buy-rice-heading">
<h3 id="buy-rice-heading" class="resource-kind buy-rice"> 구매</h3>
<div class="resource-row resource-header">
<span class="idx">번호</span><span class="host">판매자</span><span class="amount">수량</span>
<span class="highest-bidder">입찰자</span><span class="highest-bid">입찰</span>
<span class="bid-ratio"></span><span class="finish-bid">마감가</span>
<span class="close-date">거래 종료</span>
</div>
<button
v-for="auction in buyRice"
:key="auction.id"
class="resource-row clickable-row"
:class="{ selected: selectedResource?.id === auction.id }"
@click="selectResource(auction)"
>
<span class="idx tnum">{{ auction.id }}</span>
<span class="host">{{ auction.hostName }}</span>
<span class="amount tnum"> {{ formatNumber(auction.detail.amount) }}</span>
<span class="highest-bidder">{{ auction.highestBid?.bidderName ?? '-' }}</span>
<span class="highest-bid tnum" :class="{ 'no-bid': !auction.highestBid }">
{{ formatNumber(auction.highestBid?.amount ?? auction.detail.startBidAmount) }}
</span>
<span class="bid-ratio tnum">
{{
auction.highestBid && auction.detail.amount
? (auction.highestBid.amount / auction.detail.amount).toFixed(2)
: '-'
}}
</span>
<span class="finish-bid tnum"> {{ formatNumber(auction.detail.finishBidAmount) }}</span>
<span class="close-date tnum">{{ cutDateTime(auction.closeAt) }}</span>
</button>
<p v-if="buyRice.length === 0" class="empty-row">진행 중인 구매 경매가 없습니다.</p>
</section>
<form v-if="selectedResource" class="bid-form" @submit.prevent="bidResourceAuction">
<strong>{{ selectedResource.id }} {{ resourceTitle(selectedResource) }}</strong>
<label>
<span>입찰가 ({{ bidResource(selectedResource) }})</span>
<input v-model.number="bidAmount" type="number" min="1" step="10" required />
</label>
<button :disabled="actionBusy || selectedResource.isCallerHost">입찰</button>
</form>
</PanelCard>
<PanelCard title="경매 등록" subtitle="레거시와 동일하게 한 장수는 자원 경매를 한 건만 진행할 수 있습니다.">
<form class="open-form" @submit.prevent="openResourceAuction">
<label>
<span>매물</span>
<select v-model="openForm.type">
<option value="BUY_RICE"></option>
<option value="SELL_RICE"></option>
</select>
</label>
<label><span>수량</span><input v-model.number="openForm.amount" type="number" min="100" max="10000" step="10" /></label>
<label><span>기간()</span><input v-model.number="openForm.closeTurnCnt" type="number" min="1" max="24" /></label>
<label><span>시작가</span><input v-model.number="openForm.startBidAmount" type="number" min="1" step="10" /></label>
<label><span>마감가</span><input v-model.number="openForm.finishBidAmount" type="number" min="1" step="10" /></label>
<button :disabled="actionBusy">등록</button>
</form>
</PanelCard>
<PanelCard title="이전 경매" subtitle="최근 경매 기록 20건">
<ol class="log-list">
<!-- eslint-disable vue/no-v-html -->
<li v-for="log in overview?.recentLogs ?? []" :key="log.id" v-html="formatLog(log.text)" />
<!-- eslint-enable vue/no-v-html -->
<li v-if="(overview?.recentLogs.length ?? 0) === 0" class="empty">경매 기록이 없습니다.</li>
</ol>
</PanelCard>
</template>
<template v-else>
<PanelCard title="유니크 경매" :subtitle="`내 가명: ${overview?.callerAlias ?? '-'}`">
<div class="auction-table unique-table">
<div class="table-head">
<span>번호</span><span>경매명</span><span>주최자</span><span>종료</span><span>1순위</span><span>포인트</span>
</div>
<button
v-for="auction in uniqueAuctions"
:key="auction.id"
class="table-row"
:class="{ selected: selectedUnique?.id === auction.id }"
@click="selectUnique(auction)"
>
<span>{{ auction.id }}</span>
<span>{{ auction.detail.title ?? auction.targetCode }}</span>
<span :class="{ me: auction.isCallerHost }">{{ auction.hostName }}</span>
<span>{{ formatDate(auction.closeAt) }}</span>
<span :class="{ me: auction.highestBid?.isCaller }">{{ auction.highestBid?.bidderName ?? '-' }}</span>
<span>{{ formatNumber(auction.highestBid?.amount ?? auction.detail.startBidAmount) }}</span>
</button>
<p v-if="uniqueAuctions.length === 0" class="empty">유니크 경매가 없습니다.</p>
<section class="resource-section" aria-labelledby="sell-rice-heading">
<h3 id="sell-rice-heading" class="resource-kind sell-rice"> 판매</h3>
<div class="resource-row resource-header">
<span class="idx">번호</span><span class="host">판매자</span><span class="amount">수량</span>
<span class="highest-bidder">입찰자</span><span class="highest-bid">입찰가</span>
<span class="bid-ratio">단가</span><span class="finish-bid">마감가</span>
<span class="close-date">거래 종료</span>
</div>
</PanelCard>
<button
v-for="auction in sellRice"
:key="auction.id"
class="resource-row clickable-row"
:class="{ selected: selectedResource?.id === auction.id }"
@click="selectResource(auction)"
>
<span class="idx tnum">{{ auction.id }}</span>
<span class="host">{{ auction.hostName }}</span>
<span class="amount tnum"> {{ formatNumber(auction.detail.amount) }}</span>
<span class="highest-bidder">{{ auction.highestBid?.bidderName ?? '-' }}</span>
<span class="highest-bid tnum" :class="{ 'no-bid': !auction.highestBid }">
{{ formatNumber(auction.highestBid?.amount ?? auction.detail.startBidAmount) }}
</span>
<span class="bid-ratio tnum">
{{
auction.highestBid && auction.detail.amount
? (auction.highestBid.amount / auction.detail.amount).toFixed(2)
: '-'
}}
</span>
<span class="finish-bid tnum"> {{ formatNumber(auction.detail.finishBidAmount) }}</span>
<span class="close-date tnum">{{ cutDateTime(auction.closeAt) }}</span>
</button>
<p v-if="sellRice.length === 0" class="empty-row">진행 중인 판매 경매가 없습니다.</p>
</section>
<PanelCard v-if="uniqueDetail" title="유니크 경매 상세">
<form v-if="selectedResource" class="resource-bid-form" @submit.prevent="bidResourceAuction">
<span class="bid-description">
{{ selectedResource.id }} {{ selectedResource.type === 'BUY_RICE' ? '쌀' : '금' }}
{{ formatNumber(selectedResource.detail.amount) }} 경매에
{{ selectedResource.type === 'BUY_RICE' ? '금' : '쌀' }}
</span>
<input
v-model.number="bidAmount"
:aria-label="`${selectedResource.id} 경매 입찰가`"
type="number"
:min="selectedResource.detail.startBidAmount ?? 1"
:max="selectedResource.detail.finishBidAmount ?? undefined"
step="10"
required
/>
<button class="legacy-button" :disabled="actionBusy || selectedResource.isCallerHost">입찰</button>
</form>
<h3 class="subsection-title">경매 등록</h3>
<form class="open-form" @submit.prevent="openResourceAuction">
<fieldset>
<legend>매물</legend>
<div class="item-toggle">
<button
class="legacy-button"
type="button"
:aria-pressed="openForm.type === 'BUY_RICE'"
@click="openForm.type = 'BUY_RICE'"
>
</button>
<button
class="legacy-button"
type="button"
:aria-pressed="openForm.type === 'SELL_RICE'"
@click="openForm.type = 'SELL_RICE'"
>
</button>
</div>
</fieldset>
<label>
<span>수량 ({{ openForm.type === 'BUY_RICE' ? '쌀' : '금' }})</span>
<input v-model.number="openForm.amount" type="number" min="100" max="10000" step="10" />
</label>
<label
><span>기간()</span><input v-model.number="openForm.closeTurnCnt" type="number" min="3" max="24"
/></label>
<label>
<span>시작가 ({{ openForm.type === 'BUY_RICE' ? '금' : '쌀' }})</span>
<input v-model.number="openForm.startBidAmount" type="number" min="100" max="10000" step="10" />
</label>
<label>
<span>마감가 ({{ openForm.type === 'BUY_RICE' ? '금' : '쌀' }})</span>
<input v-model.number="openForm.finishBidAmount" type="number" min="100" max="10000" step="10" />
</label>
<button class="legacy-button register-button" :disabled="actionBusy">등록</button>
</form>
<h3 class="subsection-title">이전 경매(최근 20)</h3>
<div class="recent-logs">
<!-- eslint-disable vue/no-v-html -->
<div v-for="log in overview?.recentLogs ?? []" :key="log.id" v-html="formatLog(log.text)" />
<!-- eslint-enable vue/no-v-html -->
<div v-if="(overview?.recentLogs.length ?? 0) === 0" class="empty-row">경매 기록이 없습니다.</div>
</div>
</section>
<section v-else class="unique-auction bg0">
<div class="caller-alias">
가명: <strong>{{ overview?.callerAlias ?? '-' }}</strong>
</div>
<section v-if="uniqueDetail" class="unique-detail">
<h2 class="section-title bg2">경매 {{ uniqueDetail.auction.id }} 상세</h2>
<dl class="detail-grid">
<dt>경매명</dt><dd>{{ uniqueDetail.auction.detail.title ?? uniqueDetail.auction.targetCode }}</dd>
<dt>주최자(익명)</dt><dd :class="{ me: uniqueDetail.auction.isCallerHost }">{{ uniqueDetail.auction.hostName }}</dd>
<dt>종료일시</dt><dd>{{ formatDate(uniqueDetail.auction.closeAt) }}</dd>
<dt>잔여 포인트</dt><dd>{{ formatNumber(uniqueDetail.remainPoint) }}</dd>
<dt class="bg1">경매명</dt>
<dd>{{ uniqueDetail.auction.detail.title ?? uniqueDetail.auction.targetCode }}</dd>
<dt class="bg1">주최자(익명)</dt>
<dd :class="{ 'is-me': uniqueDetail.auction.isCallerHost }">{{ uniqueDetail.auction.hostName }}</dd>
<dt class="bg1">종료일시</dt>
<dd class="tnum">{{ cutDateTime(uniqueDetail.auction.closeAt, true) }}</dd>
<dt class="bg1">최대지연</dt>
<dd class="tnum">
{{ cutDateTime(uniqueDetail.auction.detail.availableLatestBidCloseDate, true) }}
</dd>
</dl>
<div class="bid-history">
<div v-for="bid in uniqueDetail.bids" :key="bid.id" class="bid-entry">
<span :class="{ me: bid.isCaller }">{{ bid.bidderName }}</span>
<strong>{{ formatNumber(bid.amount) }}</strong>
<time>{{ formatDate(bid.eventAt) }}</time>
</div>
<h3 class="subsection-title bg1">입찰자 목록</h3>
<div class="bid-row bid-header"><span>입찰자</span><span>입찰포인트</span><span>시각</span></div>
<div v-for="bid in uniqueDetail.bids" :key="bid.id" class="bid-row">
<span :class="{ 'is-me': bid.isCaller }">{{ bid.bidderName }}</span>
<span class="tnum">{{ formatNumber(bid.amount) }}</span>
<time class="tnum">{{ cutDateTime(bid.eventAt) }}</time>
</div>
<form v-if="uniqueDetail.auction.status === 'OPEN'" class="bid-form" @submit.prevent="bidUniqueAuction">
<label><span>유산 포인트</span><input v-model.number="bidAmount" type="number" min="1" required /></label>
<button :disabled="actionBusy">입찰</button>
</form>
</PanelCard>
</template>
<template v-if="uniqueDetail.auction.status === 'OPEN'">
<h3 class="subsection-title bg1">입찰하기</h3>
<form class="unique-bid-form" @submit.prevent="bidUniqueAuction">
<label for="unique-bid">
유산포인트 (잔여: {{ formatNumber(uniqueDetail.remainPoint) }}포인트)
</label>
<input id="unique-bid" v-model.number="bidAmount" type="number" min="1" required />
<button class="legacy-button" :disabled="actionBusy">입찰</button>
</form>
</template>
</section>
<section class="unique-list-section">
<h2 class="subsection-title bg1">진행중인 경매 목록</h2>
<div class="unique-row unique-header">
<span>번호</span><span>경매명</span><span>주최자</span><span>종료일시</span> <span>연장</span
><span>1순위</span><span>포인트</span>
</div>
<button
v-for="auction in ongoingUnique"
:key="auction.id"
class="unique-row clickable-row"
:class="{ selected: selectedUnique?.id === auction.id }"
@click="selectUnique(auction)"
>
<span>{{ auction.id }}</span
><span>{{ auction.detail.title ?? auction.targetCode }}</span>
<span :class="{ 'is-me': auction.isCallerHost }">{{ auction.hostName }}</span>
<span class="tnum">{{ cutDateTime(auction.closeAt) }}</span>
<span>{{ (auction.detail.remainCloseDateExtensionCnt ?? 0) > 0 ? '남음' : '소진' }}</span>
<span :class="{ 'is-me': auction.highestBid?.isCaller }">{{
auction.highestBid?.bidderName ?? '-'
}}</span>
<span class="tnum">{{
formatNumber(auction.highestBid?.amount ?? auction.detail.startBidAmount)
}}</span>
</button>
<p v-if="ongoingUnique.length === 0" class="empty-row">진행중인 유니크 경매가 없습니다.</p>
</section>
<section class="unique-list-section">
<h2 class="subsection-title bg1">종료된 경매 목록</h2>
<div class="unique-row unique-header">
<span>번호</span><span>경매명</span><span>주최자</span><span>종료일시</span> <span>연장</span
><span>1순위</span><span>포인트</span>
</div>
<button
v-for="auction in finishedUnique"
:key="auction.id"
class="unique-row clickable-row"
:class="{ selected: selectedUnique?.id === auction.id }"
@click="selectUnique(auction)"
>
<span>{{ auction.id }}</span
><span>{{ auction.detail.title ?? auction.targetCode }}</span>
<span :class="{ 'is-me': auction.isCallerHost }">{{ auction.hostName }}</span>
<span class="tnum">{{ cutDateTime(auction.closeAt) }}</span>
<span>{{ (auction.detail.remainCloseDateExtensionCnt ?? 0) > 0 ? '남음' : '소진' }}</span>
<span :class="{ 'is-me': auction.highestBid?.isCaller }">{{
auction.highestBid?.bidderName ?? '-'
}}</span>
<span class="tnum">{{
formatNumber(auction.highestBid?.amount ?? auction.detail.startBidAmount)
}}</span>
</button>
<p v-if="finishedUnique.length === 0" class="empty-row">종료된 유니크 경매가 없습니다.</p>
</section>
</section>
<footer class="bottom-bar bg0">
<button class="legacy-button close-button" type="button" @click="closeWindow"> 닫기</button>
</footer>
</main>
</template>
<style scoped>
.auction-page {
min-height: 100%;
padding: 18px;
color: #e8ddc4;
background: radial-gradient(circle at top, rgba(93, 57, 26, 0.25), transparent 42%), #080807;
.legacy-auction-page {
width: 100%;
max-width: 1000px;
box-sizing: border-box;
margin: 0 auto;
color: #fff;
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-size: 14px;
line-height: 21px;
}
.page-header, .tabs, .bid-form, .open-form, .detail-grid, .bid-entry {
display: flex;
.bg0 {
background-color: #302016;
background-image: url('/image/game/back_walnut.jpg');
}
.bg1 {
background-color: #14241b;
background-image: url('/image/game/back_green.jpg');
}
.bg2 {
background-color: #172a52;
background-image: url('/image/game/back_blue.jpg');
}
.top-back-bar {
width: 100%;
height: 32px;
display: grid;
grid-template-columns: 90px 90px 1fr 90px 90px;
}
.top-back-bar h1 {
margin: 0;
font-size: 24px;
font-weight: 500;
line-height: 32px;
text-align: center;
}
.legacy-button {
box-sizing: border-box;
border: solid #3d3d3d;
border-width: 0 1px 4px;
border-radius: 5.25px;
padding: 5.25px 10.5px;
color: #fff;
background: #444;
font: inherit;
font-weight: 700;
line-height: 21px;
cursor: pointer;
}
.legacy-button:hover,
.legacy-button:focus {
border-color: #353535;
background: #393939;
}
.legacy-button:focus-visible {
outline: 2px solid #8ab4f8;
outline-offset: -2px;
}
.legacy-button:active,
.legacy-button[aria-pressed='true'] {
border-color: #303030;
background: #333;
}
.legacy-button:disabled {
cursor: default;
opacity: 0.65;
}
.close-button,
.reload-button {
margin-right: 2px;
border-color: #004f28;
background: #00582c;
}
.close-button:hover,
.close-button:focus,
.reload-button:hover,
.reload-button:focus {
border-color: #004523;
background: #004a25;
}
.top-back-bar .close-button,
.top-back-bar .reload-button {
height: 32px;
}
.tab-button {
border-color: #3d3d3d;
background: #444;
}
.tab-button[aria-pressed='true'] {
border-color: #3d3d3d;
background: #444;
}
.tab-button:hover,
.tab-button:focus {
border-color: #353535;
background: #393939;
}
.section-title,
.subsection-title,
.resource-kind {
margin: 0;
min-height: 18px;
font: inherit;
font-weight: 400;
}
.resource-kind.buy-rice {
color: #000;
background: orange;
}
.resource-kind.sell-rice {
color: #000;
background: skyblue;
}
.resource-row {
width: 100%;
min-height: 22px;
display: grid;
grid-template-columns: 1fr 2fr 2fr 2fr 2fr 1fr 3fr 2fr;
align-items: center;
box-sizing: border-box;
border: 0;
border-bottom: 1px solid gray;
padding: 0;
color: inherit;
background: transparent;
font: inherit;
text-align: center;
}
.page-header { justify-content: space-between; gap: 16px; margin-bottom: 12px; }
.page-header h1 { margin: 0; font-size: 1.45rem; }
.page-header p { margin: 4px 0 0; color: rgba(232, 221, 196, 0.7); }
.tabs { gap: 6px; margin-bottom: 12px; }
button, input, select {
border: 1px solid rgba(201, 164, 90, 0.55);
background: rgba(20, 17, 12, 0.95);
color: #e8ddc4;
padding: 8px 10px;
.clickable-row {
cursor: pointer;
}
button { cursor: pointer; }
button:hover, button:focus-visible, button.active { background: rgba(201, 164, 90, 0.22); }
button:disabled { cursor: not-allowed; opacity: 0.45; }
.ghost { background: transparent; }
.notice { padding: 9px 12px; border: 1px solid; }
.notice.error { color: #ffb3a9; border-color: rgba(255, 90, 70, 0.45); }
.notice.success { color: #b9e6af; border-color: rgba(94, 177, 75, 0.45); }
.auction-page :deep(.panel-card) { margin-bottom: 12px; }
.auction-table { overflow-x: auto; }
.table-head, .table-row { display: grid; min-width: 820px; align-items: center; text-align: center; }
.resource-table .table-head, .resource-table .table-row { grid-template-columns: 52px 84px 1fr 1fr 1fr 1fr 1fr 150px; }
.unique-table .table-head, .unique-table .table-row { grid-template-columns: 52px 2fr 1fr 150px 1fr 110px; }
.table-head { border-bottom: 1px solid rgba(232, 221, 196, 0.4); padding: 7px; color: rgba(232, 221, 196, 0.7); }
.table-row { width: 100%; border: 0; border-bottom: 1px solid rgba(232, 221, 196, 0.12); background: transparent; }
.table-row.selected { background: rgba(201, 164, 90, 0.18); }
.table-row > span { padding: 8px 5px; }
.bid-form { justify-content: center; gap: 12px; margin-top: 14px; flex-wrap: wrap; }
.bid-form label, .open-form label { display: grid; gap: 5px; }
.open-form { align-items: end; gap: 10px; flex-wrap: wrap; }
.open-form label { min-width: 110px; flex: 1; }
.empty { padding: 14px; text-align: center; color: rgba(232, 221, 196, 0.6); }
.log-list { margin: 0; padding-left: 24px; }
.log-list li { padding: 4px 0; }
.detail-grid { display: grid; grid-template-columns: 130px 1fr 130px 1fr; gap: 1px; background: rgba(232, 221, 196, 0.18); }
.detail-grid dt, .detail-grid dd { margin: 0; padding: 9px; background: #11100d; }
.detail-grid dt { color: rgba(232, 221, 196, 0.65); }
.bid-history { margin-top: 12px; }
.bid-entry { justify-content: space-between; gap: 12px; padding: 7px 10px; border-bottom: 1px solid rgba(232, 221, 196, 0.14); }
.bid-entry time { color: rgba(232, 221, 196, 0.65); }
.me { color: aquamarine; font-weight: 700; }
@media (max-width: 720px) {
.auction-page { padding: 10px; }
.page-header { align-items: flex-start; }
.detail-grid { grid-template-columns: 110px 1fr; }
.clickable-row:hover,
.clickable-row:focus-visible,
.clickable-row.selected {
background-color: rgb(255 255 255 / 12%);
outline: 0;
}
.no-bid {
color: #ccc;
}
.tnum {
font-variant-numeric: tabular-nums;
}
.empty-row {
min-height: 24px;
margin: 0;
padding: 4px 8px;
text-align: center;
}
.resource-bid-form {
min-height: 42px;
display: grid;
grid-template-columns: 2fr 2fr 1fr;
align-items: center;
gap: 4px;
padding-right: 33.3333%;
padding-left: 25%;
}
.bid-description {
text-align: right;
}
input {
width: 100%;
min-width: 0;
box-sizing: border-box;
border: 1px solid #000;
border-radius: 5.25px;
padding: 5.25px 10.5px;
color: #303030;
background: #ddd;
font: inherit;
}
input:focus-visible {
outline: 2px solid #8ab4f8;
outline-offset: -2px;
}
.open-form {
min-height: 76px;
display: grid;
grid-template-columns: 1fr 2fr 1fr 2fr 2fr 1fr;
align-items: end;
gap: 4px;
box-sizing: border-box;
padding-right: 8.3333%;
padding-left: 16.6667%;
}
.open-form fieldset,
.open-form label {
min-width: 0;
margin: 0;
border: 0;
padding: 0;
}
.open-form legend {
padding: 0;
}
.open-form label {
display: grid;
gap: 2px;
}
.item-toggle {
display: flex;
}
.item-toggle .legacy-button {
flex: 1;
}
.register-button {
height: 100%;
align-self: stretch;
}
.recent-logs {
min-height: 24px;
}
.caller-alias {
min-height: 20px;
}
.caller-alias strong,
.is-me {
color: aqua;
font-weight: 700;
}
.detail-grid {
display: grid;
grid-template-columns: 1fr 2fr 1fr 2fr 1fr 2fr 1fr 2fr;
margin: 0;
text-align: center;
}
.detail-grid dt,
.detail-grid dd {
min-width: 0;
min-height: 18px;
display: grid;
align-content: center;
margin: 0;
}
.bid-row {
min-height: 22px;
display: grid;
grid-template-columns: 3fr 2fr 3fr;
align-items: center;
padding: 0 20%;
text-align: center;
}
.bid-header {
border-bottom: 1px solid #fff;
}
.bid-row > :nth-child(2) {
padding-right: 20px;
text-align: right;
}
.unique-bid-form {
min-height: 40px;
display: grid;
grid-template-columns: 3fr 2fr 1fr;
align-items: center;
padding: 0 25%;
}
.unique-bid-form label {
text-align: center;
}
.unique-row {
width: 100%;
min-height: 22px;
display: grid;
grid-template-columns: 1fr 4fr 1fr 2fr 1fr 1fr 2fr;
align-items: center;
box-sizing: border-box;
border: 0;
padding: 0;
color: inherit;
background: transparent;
font: inherit;
text-align: center;
}
.unique-header {
border-bottom: 1px solid #fff;
}
.unique-row > :last-child {
padding-right: 8px;
text-align: right;
}
.auction-notice {
min-height: 28px;
box-sizing: border-box;
margin: 0;
padding: 5px 8px;
}
.auction-notice.error {
background: #842029;
}
.auction-notice.success {
background: #0f5132;
}
.loading-state {
min-height: 120px;
display: grid;
place-items: center;
}
.bottom-bar {
padding-top: 20px;
}
@media (max-width: 991px) {
.legacy-auction-page {
max-width: none;
}
}
@media (max-width: 500px) {
.resource-row {
min-height: 43px;
grid-template-columns: 1fr 3fr 3fr 1fr 2fr 2fr;
grid-template-rows: 1fr 1fr;
}
.resource-row .idx {
grid-column: 1;
grid-row: 1 / 3;
}
.resource-row .host {
grid-column: 2;
grid-row: 1;
}
.resource-row .amount {
grid-column: 2;
grid-row: 2;
}
.resource-row .highest-bidder {
grid-column: 3;
grid-row: 1;
}
.resource-row .highest-bid {
grid-column: 3;
grid-row: 2;
}
.resource-row .bid-ratio {
grid-column: 4;
grid-row: 1 / 3;
}
.resource-row .finish-bid {
grid-column: 5;
grid-row: 1 / 3;
}
.resource-row .close-date {
grid-column: 6;
grid-row: 1 / 3;
}
.resource-bid-form {
grid-template-columns: 4fr 3fr 2fr;
padding-right: 16.6667%;
padding-left: 8.3333%;
}
.open-form {
grid-template-columns: 2fr 2.3333fr 2fr 2.3333fr 2.3333fr 1fr;
padding: 0;
}
.detail-grid {
grid-template-columns: 2fr 4fr 2fr 4fr;
}
.bid-row {
padding: 0;
grid-template-columns: 4fr 4fr 4fr;
}
.unique-bid-form {
padding: 0;
grid-template-columns: 5fr 4fr 3fr;
}
}
</style>
+180 -56
View File
@@ -3,7 +3,6 @@ import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
import { trpc } from '../utils/trpc';
import { getNpcColor } from '../utils/npcColor';
import { formatLog } from '../utils/formatLog';
@@ -269,7 +268,26 @@ onMounted(() => {
</PanelCard>
<PanelCard title="장수 정보">
<GeneralBasicCard :general="selectedGeneral" :loading="loading" />
<SkeletonLines v-if="loading" :lines="5" />
<div v-else-if="selectedGeneral" class="battle-general-card">
<div class="battle-general-name">
{{ selectedGeneral.name }} (관직 {{ selectedGeneral.officerLevel }})
</div>
<div class="battle-general-grid">
<span>통솔</span><strong>{{ selectedGeneral.stats.leadership }}</strong> <span>무력</span
><strong>{{ selectedGeneral.stats.strength }}</strong> <span>지력</span
><strong>{{ selectedGeneral.stats.intelligence }}</strong> <span>자금</span
><strong>{{ selectedGeneral.gold }}</strong> <span>군량</span
><strong>{{ selectedGeneral.rice }}</strong> <span>병력</span
><strong>{{ selectedGeneral.crew }}</strong> <span>훈련</span
><strong>{{ selectedGeneral.train }}</strong> <span>사기</span
><strong>{{ selectedGeneral.atmos }}</strong> <span>부상</span
><strong>{{ selectedGeneral.injury }}</strong> <span>경험</span
><strong>{{ selectedGeneral.experience }}</strong> <span>공헌</span
><strong>{{ selectedGeneral.dedication }}</strong> <span>전투</span
><strong>{{ selectedGeneral.warnum }}</strong>
</div>
</div>
<div v-if="selectedGeneral" class="general-meta">
<div>최근 : {{ selectedGeneral.turnTime ? selectedGeneral.turnTime.slice(-5) : '-' }}</div>
<div>최근 전투: {{ selectedGeneral.recentWar || '-' }}</div>
@@ -286,12 +304,7 @@ onMounted(() => {
<SkeletonLines v-if="loading || logLoading" :lines="3" />
<template v-else>
<div v-if="logs[type].length === 0" class="empty">기록이 없습니다.</div>
<div
v-for="entry in logs[type]"
:key="entry.id"
class="log-line"
v-html="entry.html"
/>
<div v-for="entry in logs[type]" :key="entry.id" class="log-line" v-html="entry.html" />
</template>
</div>
</div>
@@ -303,126 +316,237 @@ onMounted(() => {
<style scoped>
.battle-page {
width: 100%;
min-width: 500px;
max-width: 1000px;
min-height: 100vh;
padding: 24px;
margin: 0 auto;
padding: 0;
display: flex;
flex-direction: column;
gap: 24px;
gap: 0;
color: #fff;
background-color: #302016;
background-image: url('/image/game/back_walnut.jpg');
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-size: 14px;
line-height: 1.5;
}
.page-header {
position: relative;
min-height: 32px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
padding: 0 8px;
border: 1px solid #666;
background-color: #302016;
background-image: url('/image/game/back_walnut.jpg');
}
.page-title {
font-size: 1.6rem;
font-weight: 700;
font-size: 17px;
font-weight: 500;
}
.page-subtitle {
color: rgba(232, 221, 196, 0.7);
margin-top: 6px;
display: none;
}
.header-actions {
position: absolute;
left: 0;
top: 0;
display: flex;
flex-wrap: wrap;
gap: 10px;
gap: 4px;
}
.layout-grid {
display: grid;
grid-template-columns: minmax(0, 320px) minmax(0, 1fr);
gap: 18px;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0;
}
.stack {
display: flex;
flex-direction: column;
gap: 18px;
display: contents;
}
.selector-row {
display: grid;
grid-template-columns: auto minmax(140px, 1fr) minmax(180px, 2fr) auto;
gap: 8px;
grid-template-columns: 8.333% 33.333% 50% 8.333%;
gap: 0;
align-items: center;
}
.select-input {
min-width: 0;
padding: 6px 8px;
border: 1px solid rgba(201, 164, 90, 0.4);
background: rgba(12, 12, 12, 0.7);
height: 36px;
padding: 4px 6px;
border: 1px solid #777;
border-radius: 0;
background: #303030;
color: inherit;
font-size: 0.85rem;
font: inherit;
}
.ghost {
border: 1px solid rgba(201, 164, 90, 0.5);
background: transparent;
min-height: 32px;
border: 1px solid #777;
border-radius: 0;
background: #303030;
color: inherit;
padding: 6px 10px;
font-size: 0.8rem;
padding: 4px 8px;
font: inherit;
cursor: pointer;
}
.general-meta {
margin-top: 10px;
font-size: 0.85rem;
color: rgba(232, 221, 196, 0.75);
margin: 0;
padding: 6px 8px;
color: #ccc;
display: grid;
gap: 4px;
}
.log-grid {
.battle-general-card {
min-height: 292px;
background-color: #172a52;
background-image: url('/image/game/back_blue.jpg');
}
.battle-general-name {
min-height: 24px;
padding: 2px 6px;
text-align: center;
border-bottom: 1px solid #777;
background: rgba(220, 220, 220, 0.85);
color: #111;
font-weight: 700;
}
.battle-general-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
grid-template-columns: repeat(6, 1fr);
}
.battle-general-grid > * {
min-height: 24px;
padding: 2px 5px;
border-right: 1px solid #777;
border-bottom: 1px solid #777;
}
.battle-general-grid > span {
background-color: rgba(20, 75, 42, 0.7);
color: #fff;
text-align: center;
}
.battle-general-grid > strong {
text-align: right;
font-weight: 500;
}
.log-grid {
display: contents;
}
.log-block {
border: 1px solid rgba(201, 164, 90, 0.3);
padding: 8px;
background: rgba(12, 12, 12, 0.6);
min-height: 160px;
border: 1px solid #666;
padding: 0;
background: #111;
min-height: 180px;
}
.log-title {
font-weight: 600;
margin-bottom: 6px;
font-size: 0.9rem;
min-height: 34px;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid #666;
color: orange;
background: #252525;
font-size: 1.3em;
font-weight: 500;
}
.log-line {
padding: 4px 0;
border-bottom: 1px dashed rgba(201, 164, 90, 0.2);
}
.log-line:last-child {
border-bottom: none;
padding: 2px 8px;
border-bottom: 0;
}
.empty {
color: rgba(232, 221, 196, 0.6);
font-size: 0.85rem;
padding: 2px 8px;
color: #999;
}
.error {
color: #f08a5d;
font-size: 0.9rem;
padding: 5px 8px;
color: #ff7777;
border: 1px solid #a33;
text-align: center;
}
@media (max-width: 1024px) {
/* PanelCard is retained as a data wrapper, but its presentation follows the
flat bootstrap rows used by the reference page. */
:deep(.panel-card) {
height: 100%;
border: 1px solid #666;
border-radius: 0;
background-color: #302016;
background-image: url('/image/game/back_walnut.jpg');
box-shadow: none;
}
.stack:first-child :deep(.panel-card:first-child) {
grid-column: 1 / -1;
border: 0;
}
.stack:first-child :deep(.panel-card:first-child .panel-header) {
display: none;
}
.stack:first-child :deep(.panel-card:first-child .panel-body) {
padding: 0;
}
.stack:nth-child(2) :deep(.panel-card),
.stack:nth-child(2) :deep(.panel-body) {
display: contents;
}
.stack:nth-child(2) :deep(.panel-header) {
display: none;
}
:deep(.panel-header) {
min-height: 29px;
justify-content: center;
padding: 0;
}
:deep(.panel-title) {
color: skyblue;
font-size: 18px;
font-weight: 500;
}
:deep(.panel-header),
.log-title {
background-image: url('/image/game/back_green.jpg');
}
@media (max-width: 991px) {
.battle-page {
width: 500px;
}
.layout-grid {
grid-template-columns: 1fr;
}
.selector-row {
grid-template-columns: 16.666% 25% 41.666% 16.666%;
}
.log-grid {
grid-template-columns: 1fr;
}
}
@@ -26,6 +26,7 @@ type BattleExport = {
type ExportedInfo = { objType: 'general'; data: GeneralExport } | { objType: 'battle'; data: BattleExport };
type GeneralListResponse = Awaited<ReturnType<typeof trpc.battle.getGeneralList.query>>;
type GeneralMeResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
const loading = ref(true);
const error = ref<string | null>(null);
@@ -72,6 +73,7 @@ const importTarget = ref<GeneralDraft | null>(null);
const generalList = ref<GeneralListResponse | null>(null);
const generalListLoading = ref(false);
const selectedGeneralId = ref<number | null>(null);
const gameDefaults = ref<GeneralMeResponse>(null);
let generalIdSeed = 0;
@@ -250,6 +252,7 @@ const initializeDefaults = async () => {
try {
const [context, me] = await Promise.all([trpc.battle.getSimulatorContext.query(), trpc.general.me.query()]);
options.value = context;
gameDefaults.value = me;
year.value = context.world.currentYear;
month.value = context.world.currentMonth;
repeatCnt.value = 1;
@@ -283,6 +286,47 @@ const initializeDefaults = async () => {
}
};
const hasGameGeneral = computed(() => !!gameDefaults.value?.general?.id);
const applyGameEnvironment = () => {
if (!options.value) {
return;
}
const me = gameDefaults.value;
const nationTypeDefault = options.value.nationTypes[0]?.key ?? 'che_중립';
year.value = options.value.world.currentYear;
month.value = options.value.world.currentMonth;
attackerNation.type = me?.nation?.typeCode ?? nationTypeDefault;
defenderNation.type = attackerNation.type;
attackerNation.level = me?.nation?.level ?? 0;
defenderNation.level = attackerNation.level;
attackerNation.tech = me?.nation?.tech ? Math.floor(me.nation.tech / 1000) : 1;
defenderNation.tech = attackerNation.tech;
attackerCity.level = me?.city?.level ?? 5;
defenderCity.level = attackerCity.level;
defenderCity.def = me?.city?.defence ?? 1000;
defenderCity.wall = me?.city?.wall ?? 1000;
attackerNation.isCapital = !!me?.city && me.nation?.capitalCityId === me.city.id;
defenderNation.isCapital = attackerNation.isCapital;
error.value = null;
};
const applyIndependentEnvironment = () => {
if (!options.value) {
return;
}
const nationTypeDefault = options.value.nationTypes[0]?.key ?? 'che_중립';
year.value = options.value.world.startYear;
month.value = 1;
seed.value = '';
repeatCnt.value = 1;
Object.assign(attackerNation, { type: nationTypeDefault, tech: 1, level: 0, isCapital: false });
Object.assign(defenderNation, { type: nationTypeDefault, tech: 1, level: 0, isCapital: false });
attackerCity.level = 5;
Object.assign(defenderCity, { level: 5, def: 1000, wall: 1000 });
error.value = null;
};
onMounted(() => {
void initializeDefaults();
});
@@ -536,13 +580,19 @@ const runSimulation = async (action: BattleSimRequestPayload['action']) => {
}
isSimulating.value = true;
error.value = null;
if (action === 'battle') {
battleResult.value = null;
}
statusMessage.value = action === 'battle' ? '전투를 진행 중입니다.' : '수비자 순서를 계산 중입니다.';
try {
const payload = buildBattlePayload(action);
const response = await trpc.battle.simulate.mutate(payload);
const result =
'payload' in response && response.payload ? response.payload : await waitForSimulationResult(response.jobId);
'payload' in response && response.payload
? response.payload
: await waitForSimulationResult(response.jobId);
if (!result.result) {
error.value = result.reason || 'battle_failed';
@@ -786,6 +836,10 @@ const loadGeneralList = async () => {
};
const openImportModal = async (target: GeneralDraft) => {
if (!hasGameGeneral.value) {
error.value = '게임 장수를 보유한 사용자만 서버 장수 정보를 가져올 수 있습니다.';
return;
}
importTarget.value = target;
importOpen.value = true;
if (!generalList.value) {
@@ -801,47 +855,62 @@ const closeImportModal = () => {
importTarget.value = null;
};
const applyServerGeneral = async (target: GeneralDraft, generalId: number) => {
const response = await trpc.battle.getGeneralDetail.query({ generalId });
applyGeneralExport(target, {
no: response.general.no,
name: response.general.name,
officerLevel: response.general.officer_level,
expLevel: response.general.explevel,
leadership: response.general.leadership,
strength: response.general.strength,
intel: response.general.intel,
horse: response.general.horse,
weapon: response.general.weapon,
book: response.general.book,
item: response.general.item,
injury: response.general.injury,
rice: response.general.rice,
personal: response.general.personal,
special2: response.general.special2,
crew: response.general.crew,
crewtype: response.general.crewtype,
atmos: response.general.atmos,
train: response.general.train,
dex1: response.general.dex1,
dex2: response.general.dex2,
dex3: response.general.dex3,
dex4: response.general.dex4,
dex5: response.general.dex5,
defenceTrain: response.general.defence_train,
warnum: response.general.warnum,
killnum: response.general.killnum,
killcrew: response.general.killcrew,
inheritBuff: createInheritBuff(),
});
target.no = target === attackerGeneral.value ? 1 : resolveGeneralNo(response.general.no, target.id);
};
const applyMyGeneralToAttacker = async () => {
const generalId = gameDefaults.value?.general?.id;
if (!attackerGeneral.value || !generalId) {
error.value = '불러올 내 장수가 없습니다.';
return;
}
try {
error.value = null;
await applyServerGeneral(attackerGeneral.value, generalId);
} catch (err) {
error.value = resolveErrorMessage(err);
}
};
const confirmImport = async () => {
if (!importTarget.value || !selectedGeneralId.value) {
return;
}
try {
const response = await trpc.battle.getGeneralDetail.query({ generalId: selectedGeneralId.value });
applyGeneralExport(importTarget.value, {
no: response.general.no,
name: response.general.name,
officerLevel: response.general.officer_level,
expLevel: response.general.explevel,
leadership: response.general.leadership,
strength: response.general.strength,
intel: response.general.intel,
horse: response.general.horse,
weapon: response.general.weapon,
book: response.general.book,
item: response.general.item,
injury: response.general.injury,
rice: response.general.rice,
personal: response.general.personal,
special2: response.general.special2,
crew: response.general.crew,
crewtype: response.general.crewtype,
atmos: response.general.atmos,
train: response.general.train,
dex1: response.general.dex1,
dex2: response.general.dex2,
dex3: response.general.dex3,
dex4: response.general.dex4,
dex5: response.general.dex5,
defenceTrain: response.general.defence_train,
warnum: response.general.warnum,
killnum: response.general.killnum,
killcrew: response.general.killcrew,
inheritBuff: createInheritBuff(),
});
importTarget.value.no =
importTarget.value === attackerGeneral.value
? 1
: resolveGeneralNo(response.general.no, importTarget.value.id);
await applyServerGeneral(importTarget.value, selectedGeneralId.value);
} catch (err) {
error.value = resolveErrorMessage(err);
} finally {
@@ -882,19 +951,21 @@ const summaryRows = computed(() => {
{ label: '전투 페이즈', value: formatNumber(battleResult.value.phase) },
{
label: '준 피해',
value: battleResult.value.minKilled !== battleResult.value.maxKilled
? `${formatNumber(battleResult.value.killed)} (${formatNumber(battleResult.value.minKilled)} ~ ${formatNumber(
battleResult.value.maxKilled
)})`
: formatNumber(battleResult.value.killed),
value:
battleResult.value.minKilled !== battleResult.value.maxKilled
? `${formatNumber(battleResult.value.killed)} (${formatNumber(battleResult.value.minKilled)} ~ ${formatNumber(
battleResult.value.maxKilled
)})`
: formatNumber(battleResult.value.killed),
},
{
label: '받은 피해',
value: battleResult.value.minDead !== battleResult.value.maxDead
? `${formatNumber(battleResult.value.dead)} (${formatNumber(battleResult.value.minDead)} ~ ${formatNumber(
battleResult.value.maxDead
)})`
: formatNumber(battleResult.value.dead),
value:
battleResult.value.minDead !== battleResult.value.maxDead
? `${formatNumber(battleResult.value.dead)} (${formatNumber(battleResult.value.minDead)} ~ ${formatNumber(
battleResult.value.maxDead
)})`
: formatNumber(battleResult.value.dead),
},
{ label: '출병자 군량 소모', value: formatNumber(battleResult.value.attackerRice) },
{ label: '수비자 군량 소모', value: formatNumber(battleResult.value.defenderRice) },
@@ -937,6 +1008,32 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
</div>
</header>
<section class="independence-notice" aria-label="시뮬레이터 데이터 안내">
<div>
<strong>게임 상태와 분리된 모의 계산</strong>
<p>
현재 연도·국가·도시는 시작값으로만 읽으며, 아래 편집과 전투 결과는 ·DB·장수 상태를 변경하지
않습니다.
</p>
</div>
<div class="notice-actions">
<button class="ghost" type="button" :disabled="!options" @click="applyGameEnvironment">
현재 게임 환경 적용
</button>
<button class="ghost" type="button" :disabled="!options" @click="applyIndependentEnvironment">
독립 기본값
</button>
<button
class="ghost"
type="button"
:disabled="!hasGameGeneral || !attackerGeneral"
@click="applyMyGeneralToAttacker"
>
장수를 출병자로
</button>
</div>
</section>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="statusMessage" class="status">{{ statusMessage }}</div>
@@ -1028,10 +1125,11 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
<BattleGeneralCard
v-if="attackerGeneral"
:general="attackerGeneral!"
v-model:general="attackerGeneral"
:options="options!"
mode="attacker"
title="출병자 설정"
:can-import-server="hasGameGeneral"
@import="openImportModal(attackerGeneral!)"
@save="saveGeneral(attackerGeneral!)"
@load="(payload) => handleGeneralLoad({ target: attackerGeneral!, file: payload.file })"
@@ -1095,10 +1193,11 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
<BattleGeneralCard
v-for="(defender, index) in defenders"
:key="defender.id"
:general="defender"
v-model:general="defenders[index]"
:options="options!"
mode="defender"
:title="`수비자 설정 ${index + 1}`"
:can-import-server="hasGameGeneral"
@import="openImportModal(defender)"
@save="saveGeneral(defender)"
@load="(payload) => handleGeneralLoad({ target: defender, file: payload.file })"
@@ -1146,11 +1245,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
</div>
<div v-else class="select-wrap">
<select v-model.number="selectedGeneralId">
<optgroup
v-for="group in generalGroups"
:key="group.nation.id"
:label="group.nation.name"
>
<optgroup v-for="group in generalGroups" :key="group.nation.id" :label="group.nation.name">
<option
v-for="general in group.generals"
:key="general.id"
@@ -1176,6 +1271,8 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
display: flex;
flex-direction: column;
gap: 18px;
width: min(100%, 1000px);
margin: 0 auto;
padding-bottom: 30px;
background:
radial-gradient(circle at top left, rgba(201, 164, 90, 0.15), transparent 45%),
@@ -1207,6 +1304,34 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
gap: 8px;
}
.independence-notice {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 10px 12px;
border: 1px solid rgba(112, 170, 141, 0.45);
background: rgba(18, 52, 40, 0.35);
}
.independence-notice strong {
color: #bfe2cd;
font-size: 0.85rem;
}
.independence-notice p {
margin: 4px 0 0;
color: rgba(221, 239, 228, 0.75);
font-size: 0.75rem;
}
.notice-actions {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
gap: 6px;
}
button {
font-family: inherit;
background: none;
@@ -1230,6 +1355,11 @@ button {
background: rgba(16, 16, 16, 0.6);
}
button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.error {
color: #f5b7b1;
font-size: 0.85rem;
@@ -1369,5 +1499,10 @@ button {
flex-direction: column;
align-items: flex-start;
}
.independence-notice {
align-items: flex-start;
flex-direction: column;
}
}
</style>
+292 -61
View File
@@ -1,5 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { trpc } from '../utils/trpc';
type RankEntry = {
@@ -11,7 +13,6 @@ type RankEntry = {
fgColor: string;
picture: string | null;
imageServer: number;
value: number;
printValue: string;
};
@@ -21,18 +22,17 @@ type RankSection = {
entries: RankEntry[];
};
type UniqueOwner = {
id: number;
name: string;
nationName: string;
bgColor: string;
fgColor: string;
type UniqueItemEntry = {
itemKey: string;
itemName: string;
itemInfo: string;
owner: Omit<RankEntry, 'ownerName' | 'printValue'>;
};
type UniqueItemSection = {
title: string;
slot: string;
owners: UniqueOwner[];
entries: UniqueItemEntry[];
};
type BestGeneralPayload = {
@@ -41,12 +41,26 @@ type BestGeneralPayload = {
uniqueItems: UniqueItemSection[];
};
const router = useRouter();
const viewMode = ref<'user' | 'npc'>('user');
const loading = ref(false);
const errorMessage = ref('');
const data = ref<BestGeneralPayload | null>(null);
const refresh = async () => {
const imageUrl = (entry: { picture: string | null; imageServer: number }): string => {
const picture = entry.picture?.trim() || 'default.jpg';
return entry.imageServer ? `${import.meta.env.BASE_URL}d_pic/${picture}` : `/image/icons/${picture}`;
};
const closePage = async (): Promise<void> => {
if (window.opener) {
window.close();
return;
}
await router.push('/');
};
const refresh = async (): Promise<void> => {
loading.value = true;
errorMessage.value = '';
try {
@@ -58,8 +72,6 @@ const refresh = async () => {
}
};
const emptyLabel = computed(() => (loading.value ? '불러오는 중...' : '표시할 데이터가 없습니다.'));
onMounted(() => {
void refresh();
});
@@ -70,61 +82,280 @@ watch(viewMode, () => {
</script>
<template>
<main class="main-page">
<header class="page-header">
<div>
<h1 class="page-title">명장일람</h1>
<p class="page-subtitle">전장 기록을 기준으로 장수 순위를 확인합니다.</p>
</div>
<div class="header-actions">
<button class="ghost" :class="{ active: viewMode === 'user' }" @click="viewMode = 'user'">
유저 보기
</button>
<button class="ghost" :class="{ active: viewMode === 'npc' }" @click="viewMode = 'npc'">
NPC 보기
</button>
<button class="ghost" @click="refresh">새로고침</button>
</div>
</header>
<main id="best-general-container" class="legacy-ranking-page legacy-bg0">
<div class="legacy-ranking-title">
<br />
<button class="legacy-button" type="button" @click="closePage"> 닫기</button>
</div>
<div v-if="errorMessage" class="error">{{ errorMessage }}</div>
<div v-else-if="!data" class="placeholder">{{ emptyLabel }}</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>
</div>
<section v-if="data" class="grid gap-4">
<div v-for="section in data.sections" :key="section.title" class="bg-zinc-900 border border-zinc-800 rounded p-4">
<h2 class="text-base font-semibold mb-3">{{ section.title }}</h2>
<div v-if="section.entries.length === 0" class="text-xs text-zinc-500">{{ emptyLabel }}</div>
<ul v-else class="space-y-2">
<li
v-for="entry in section.entries"
:key="entry.id"
class="flex items-center justify-between bg-zinc-950 border border-zinc-800 rounded px-3 py-2 text-sm"
>
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full" :style="{ backgroundColor: entry.bgColor }" />
<span class="font-semibold">{{ entry.name }}</span>
<span class="text-xs text-zinc-400">{{ entry.nationName }}</span>
<div v-if="errorMessage" class="legacy-message error" role="alert">{{ errorMessage }}</div>
<div v-else-if="loading && !data" class="legacy-message">불러오는 중...</div>
<section v-if="data" class="ranking-sections" :aria-busy="loading">
<article v-for="section in data.sections" :key="section.title" class="rankView legacy-bg0">
<h2 class="rankType legacy-bg1">{{ section.title }}</h2>
<ul>
<li v-for="(entry, rank) in section.entries" :key="`${section.title}:${entry.id}:${rank}`">
<div class="hall-rank legacy-bg2">{{ rank + 1 }}</div>
<div class="hall-img">
<img class="generalIcon" :src="imageUrl(entry)" width="64" height="64" :alt="entry.name" />
</div>
<div class="text-xs text-zinc-200">{{ entry.printValue }}</div>
<div class="hall-nation" :style="{ backgroundColor: entry.bgColor, color: entry.fgColor }">
{{ entry.nationName || '-' }}
</div>
<div class="hall-name" :style="{ backgroundColor: entry.bgColor, color: entry.fgColor }">
<span>{{ entry.name || '-' }}</span>
<small v-if="entry.ownerName">({{ entry.ownerName }})</small>
</div>
<div class="hall-value">{{ entry.printValue }}</div>
</li>
</ul>
</div>
</article>
<article v-for="section in data.uniqueItems" :key="section.slot" class="rankView legacy-bg0">
<h2 class="rankType legacy-bg1">{{ section.title }}</h2>
<ul>
<li
v-for="(entry, index) in section.entries"
:key="`${entry.itemKey}:${index}`"
class="no-value"
>
<div class="hall-rank legacy-bg2 item-name" :title="entry.itemInfo">{{ entry.itemName }}</div>
<div class="hall-img">
<img
class="generalIcon"
:src="imageUrl(entry.owner)"
width="64"
height="64"
:alt="entry.owner.name"
/>
</div>
<div
class="hall-nation"
:style="{ backgroundColor: entry.owner.bgColor, color: entry.owner.fgColor }"
>
{{ entry.owner.nationName || '-' }}
</div>
<div
class="hall-name"
:style="{ backgroundColor: entry.owner.bgColor, color: entry.owner.fgColor }"
>
<span>{{ entry.owner.name || '-' }}</span>
</div>
</li>
</ul>
</article>
</section>
<section v-if="data" class="mt-6 bg-zinc-900 border border-zinc-800 rounded p-4">
<h2 class="text-base font-semibold mb-3">유니크 아이템 소유자</h2>
<div class="grid md:grid-cols-2 gap-4">
<div v-for="item in data.uniqueItems" :key="item.title" class="bg-zinc-950 border border-zinc-800 rounded p-3">
<h3 class="text-sm font-semibold mb-2">{{ item.title }}</h3>
<ul class="space-y-1 text-xs">
<li v-for="owner in item.owners" :key="owner.id" class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full" :style="{ backgroundColor: owner.bgColor }" />
<span>{{ owner.name }}</span>
<span class="text-zinc-500">{{ owner.nationName }}</span>
</li>
</ul>
</div>
</div>
</section>
<div class="legacy-ranking-bottom">
<button class="legacy-button" type="button" @click="closePage"> 닫기</button>
</div>
<footer class="legacy-banner">
삼국지 모의전투 HiDCHe core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD /
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a>
</footer>
</main>
</template>
<style scoped>
:global(body) {
min-width: 500px;
overflow-x: hidden;
}
.legacy-ranking-page {
width: 500px;
min-height: 100vh;
margin: 0 auto 100px;
color: #fff;
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-size: 14px;
}
.legacy-ranking-title,
.legacy-ranking-bottom {
text-align: left;
}
.view-selector {
text-align: center;
}
.legacy-ranking-title {
padding-top: 2px;
}
.view-selector {
padding: 2px 0;
}
.view-selector .legacy-button + .legacy-button {
margin-left: 4px;
}
.view-selector .legacy-button[aria-pressed='true'] {
border-style: inset;
}
.legacy-button {
border: 0;
border-radius: 5.25px;
background: #375a7f;
padding: 5.25px 10.5px;
font-weight: 700;
line-height: 21px;
}
.legacy-button:hover,
.legacy-button:focus,
.legacy-button:active {
background: #6b6b6b;
}
.legacy-button:focus-visible {
outline: revert;
outline-offset: 0;
}
.legacy-message {
border: 1px solid gray;
padding: 12px;
text-align: center;
}
.legacy-message.error {
color: #ff6b6b;
}
.legacy-banner {
font-size: 13px;
}
.legacy-banner a {
color: #fff;
text-decoration: underline;
}
.ranking-sections {
display: block;
}
.rankView {
position: relative;
margin: auto;
outline: 1px solid gray;
}
.rankType {
margin: 0;
border-bottom: 1px solid gray;
padding: 2px;
font-size: calc(19px + 0.784615vw);
font-weight: 500;
line-height: 1.2;
text-align: center;
}
.rankView ul {
display: flex;
flex-wrap: wrap;
box-sizing: border-box;
margin: -1px 0;
padding: 0;
list-style: none;
}
.rankView li {
box-sizing: border-box;
flex: 0 0 100px;
width: 100px;
min-height: 149px;
margin: 0;
border-top: 1px solid gray;
border-right: 1px solid gray;
text-align: center;
vertical-align: top;
}
.rankView li.no-value {
min-height: 128px;
}
.hall-rank,
.hall-nation,
.hall-value {
border-bottom: 1px solid gray;
}
.hall-rank.item-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hall-img {
height: 64px;
}
.generalIcon {
display: inline-block;
width: 64px;
height: 64px;
object-fit: fill;
}
.hall-nation,
.hall-name {
font-size: 11px;
}
.hall-name {
display: flex;
height: 28px;
flex-direction: column;
justify-content: center;
}
.hall-name small {
font-size: 95%;
}
.hall-value {
box-sizing: border-box;
padding: 3px 0;
line-height: 13px;
}
@media (min-width: 1000px) {
:global(body) {
min-width: 1000px;
}
.legacy-ranking-page {
width: 1000px;
}
.rankType {
font-size: 28px;
}
}
</style>
+94 -20
View File
@@ -189,9 +189,7 @@ const destroyLetter = async (letterId: number) => {
}
};
const prevOptions = computed(() =>
data.value?.letters.filter((letter) => letter.state !== 'CANCELLED') ?? []
);
const prevOptions = computed(() => data.value?.letters.filter((letter) => letter.state !== 'CANCELLED') ?? []);
const formatDate = (value: string) => new Date(value).toLocaleString('ko-KR');
@@ -216,12 +214,14 @@ const canRollback = (letter: DiplomacyLetter) =>
editable.value && data.value?.myNationId === letter.src.nationId && letter.state === 'PROPOSED';
const canDestroy = (letter: DiplomacyLetter) =>
editable.value && letter.state === 'ACTIVATED' && (data.value?.myNationId === letter.src.nationId || data.value?.myNationId === letter.dest.nationId);
editable.value &&
letter.state === 'ACTIVATED' &&
(data.value?.myNationId === letter.src.nationId || data.value?.myNationId === letter.dest.nationId);
const canRenew = (letter: DiplomacyLetter) => letter.state !== 'CANCELLED';
onMounted(() => {
loadLetters();
void loadLetters();
});
onBeforeUnmount(() => {
@@ -270,26 +270,84 @@ onBeforeUnmount(() => {
<div class="editor-group">
<div class="editor-label">내용(국가 공개)</div>
<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="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().toggleOrderedList().run()">
번호 목록
</button>
<button
type="button"
@click="
uploadTarget = 'brief';
fileInputRef?.click();
"
:disabled="uploadBusy"
>
이미지 업로드
</button>
</div>
<EditorContent v-if="briefEditor" :editor="briefEditor" />
</div>
<div class="editor-group">
<div class="editor-label">내용(외교권자 전용)</div>
<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="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().toggleOrderedList().run()">
번호 목록
</button>
<button
type="button"
@click="
uploadTarget = 'detail';
fileInputRef?.click();
"
:disabled="uploadBusy"
>
이미지 업로드
</button>
</div>
<EditorContent v-if="detailEditor" :editor="detailEditor" />
</div>
@@ -327,7 +385,10 @@ onBeforeUnmount(() => {
</button>
<div v-if="historyOpen[letter.id]" class="history-panel">
<template v-if="getPrevLetter(letter)">
<p>#{{ getPrevLetter(letter)?.id }} {{ getPrevLetter(letter)?.src.nationName }} {{ getPrevLetter(letter)?.dest.nationName }}</p>
<p>
#{{ getPrevLetter(letter)?.id }} {{ getPrevLetter(letter)?.src.nationName }}
{{ getPrevLetter(letter)?.dest.nationName }}
</p>
<div class="letter-text" v-html="getPrevLetter(letter)?.brief" />
</template>
<p v-else class="hint">이전 문서를 찾을 없습니다.</p>
@@ -335,11 +396,24 @@ onBeforeUnmount(() => {
</div>
</div>
<footer class="letter-actions">
<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="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)" type="button" @click="selectedPrevId = letter.id; applyPrevLetter()">추가 문서 작성</button>
<button
v-if="canRenew(letter)"
type="button"
@click="
selectedPrevId = letter.id;
applyPrevLetter();
"
>
추가 문서 작성
</button>
</footer>
</article>
</section>
@@ -565,4 +639,4 @@ onBeforeUnmount(() => {
.loading {
color: #9aa3b8;
}
</style>
</style>
+371 -218
View File
@@ -1,66 +1,12 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { formatLog } from '../utils/formatLog';
import { legacyNationTextColor } from '../utils/legacyNationColor';
import { trpc } from '../utils/trpc';
type EmperorDetail = {
id: number;
serverId: string;
winnerNationId: number | null;
phase: string;
nationCount: string;
nationName: string;
nationHist: string;
genCount: string;
personalHist: string;
specialHist: string;
name: string;
type: string;
color: string;
year: number;
month: number;
power: number;
gennum: number;
citynum: number;
pop: string;
poprate: string;
gold: number;
rice: number;
l12name: string;
l11name: string;
l10name: string;
l9name: string;
l8name: string;
l7name: string;
l6name: string;
l5name: string;
tiger: string;
eagle: string;
gen: string;
history: string[];
};
type NationEntry = {
nation: number;
isWinner: boolean;
name: string;
color: string;
type: string;
level: number | null;
tech: number | null;
maxPower: number | null;
maxCrew: number | null;
maxCities: number[];
generals: number[];
history: string[];
date: string;
generalsFull: Array<{ generalNo: number; name: string; lastYearMonth: number | null }>;
};
type DynastyDetailPayload = {
emperor: EmperorDetail;
nations: NationEntry[];
};
type DynastyDetailPayload = Awaited<ReturnType<typeof trpc.dynasty.getDetail.query>>;
const route = useRoute();
const router = useRouter();
@@ -72,191 +18,398 @@ const emperorId = computed(() => {
const idParam = route.params.id;
const raw = Array.isArray(idParam) ? idParam[0] : idParam;
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : null;
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
});
const dynastyApi = trpc as unknown as {
dynasty: {
getDetail: {
query: (input: { emperorId: number }) => Promise<DynastyDetailPayload>;
};
};
const closePage = async (): Promise<void> => {
if (window.opener) {
window.close();
return;
}
await router.push('/');
};
const loadDetail = async () => {
const loadDetail = async (): Promise<void> => {
if (emperorId.value === null) {
data.value = null;
errorMessage.value = '잘못된 왕조 번호입니다.';
return;
}
loading.value = true;
errorMessage.value = '';
try {
data.value = await dynastyApi.dynasty.getDetail.query({
emperorId: emperorId.value,
});
data.value = await trpc.dynasty.getDetail.query({ emperorId: emperorId.value });
} catch (error) {
data.value = null;
errorMessage.value = error instanceof Error ? error.message : '왕조 정보를 불러오지 못했습니다.';
} finally {
loading.value = false;
}
};
watch(emperorId, () => {
void loadDetail();
});
const formatArchiveDate = (value: string): string =>
new Intl.DateTimeFormat('sv-SE', {
dateStyle: 'short',
timeStyle: 'medium',
timeZone: 'UTC',
}).format(new Date(value));
onMounted(async () => {
await loadDetail();
});
watch(emperorId, loadDetail);
onMounted(loadDetail);
</script>
<template>
<main class="main-page">
<header class="page-header">
<div>
<h1 class="page-title">왕조 상세</h1>
<p class="page-subtitle">통일 세력과 멸망한 국가의 기록입니다.</p>
</div>
<div class="header-actions">
<button class="ghost" @click="router.push('/dynasty')">목록으로</button>
<button class="ghost" @click="loadDetail">데이터 새로고침</button>
</div>
</header>
<main id="dynasty-detail-container" class="dynasty-page">
<table class="legacy-table legacy-bg0 title-table">
<tbody>
<tr>
<td>
<br />
<button class="native-button" type="button" @click="closePage"> 닫기</button>
<span class="all-link">
<RouterLink to="/dynasty"><button class="native-button" type="button">전체보기</button></RouterLink>
</span>
</td>
</tr>
</tbody>
</table>
<div v-if="errorMessage" class="error">{{ errorMessage }}</div>
<div v-else-if="loading" class="placeholder">불러오는 중...</div>
<div v-else-if="!data" class="placeholder">표시할 데이터가 없습니다.</div>
<div v-if="errorMessage" class="legacy-message error" role="alert">{{ errorMessage }}</div>
<div v-else-if="loading && !data" class="legacy-message" role="status">불러오는 중...</div>
<section v-if="data" class="grid gap-4">
<article class="bg-zinc-900 border border-zinc-800 rounded p-4">
<div class="flex items-center gap-3 mb-3">
<span class="w-3 h-3 rounded-full" :style="{ backgroundColor: data.emperor.color }" />
<div>
<h2 class="text-lg font-semibold">{{ data.emperor.name }}</h2>
<p class="text-xs text-zinc-400">{{ data.emperor.phase }} · {{ data.emperor.year }} {{ data.emperor.month }}</p>
</div>
</div>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-2 text-sm text-zinc-300">
<div>국력: {{ data.emperor.power }}</div>
<div>도시: {{ data.emperor.citynum }}</div>
<div>장수: {{ data.emperor.gennum }}</div>
<div>인구: {{ data.emperor.pop }}</div>
<div>인구율: {{ data.emperor.poprate }}</div>
<div>/: {{ data.emperor.gold }} / {{ data.emperor.rice }}</div>
</div>
</article>
<template v-if="data">
<table class="legacy-table legacy-bg0 emperor-table">
<colgroup>
<col class="short-label" />
<col class="picture-column" />
<col class="long-value" />
<col class="short-label" />
<col class="picture-column" />
<col class="long-value" />
</colgroup>
<tbody>
<tr>
<td class="phase-heading centered" colspan="6">
<span class="large-text">{{ data.emperor.phase }}</span>
</td>
</tr>
<tr>
<td class="legacy-bg1 centered"> <br />(최종 / 최대)</td>
<td class="centered" colspan="2">{{ data.emperor.nationCount }}</td>
<td class="legacy-bg1 centered"> <br />(최종 / 최대)</td>
<td class="centered" colspan="2">{{ data.emperor.genCount }}</td>
</tr>
<tr>
<td class="legacy-bg1 centered"> </td>
<td colspan="5">{{ data.emperor.nationName }}</td>
</tr>
<tr>
<td class="legacy-bg1 centered">국가별 성향</td>
<td colspan="5">{{ data.emperor.nationHist }}</td>
</tr>
<tr>
<td class="legacy-bg1 centered"> </td>
<td colspan="5">{{ data.emperor.personalHist }}</td>
</tr>
<tr>
<td class="legacy-bg1 centered"> </td>
<td colspan="5">{{ data.emperor.specialHist }}</td>
</tr>
<tr>
<td
class="nation-heading centered"
colspan="6"
:style="{
backgroundColor: data.emperor.color,
color: legacyNationTextColor(data.emperor.color),
}"
>
<span class="large-text">
{{ data.emperor.name }} ({{ data.emperor.year }} {{ data.emperor.month }})
</span>
</td>
</tr>
<tr>
<td class="legacy-bg1 centered"> </td>
<td class="centered" colspan="2">{{ data.emperor.power }}</td>
<td class="legacy-bg1 centered"> </td>
<td class="centered" colspan="2">{{ data.emperor.type }}</td>
</tr>
<tr>
<td class="legacy-bg1 centered"> </td>
<td class="centered" colspan="2">{{ data.emperor.gennum }}</td>
<td class="legacy-bg1 centered"> </td>
<td class="centered" colspan="2">{{ data.emperor.citynum }}</td>
</tr>
<tr>
<td class="legacy-bg1 centered"> </td>
<td class="centered" colspan="2">{{ data.emperor.pop }}</td>
<td class="legacy-bg1 centered"> </td>
<td class="centered" colspan="2">{{ data.emperor.poprate }}</td>
</tr>
<tr>
<td class="legacy-bg1 centered"> </td>
<td class="centered" colspan="2">{{ data.emperor.gold }}</td>
<td class="legacy-bg1 centered"> </td>
<td class="centered" colspan="2">{{ data.emperor.rice }}</td>
</tr>
<tr>
<td class="legacy-bg1 centered"> </td>
<td>&nbsp;</td>
<td>{{ data.emperor.l12name }}</td>
<td class="legacy-bg1 centered"> </td>
<td>&nbsp;</td>
<td>{{ data.emperor.l11name }}</td>
</tr>
<tr>
<td class="legacy-bg1 centered"> </td>
<td>&nbsp;</td>
<td>{{ data.emperor.l10name }}</td>
<td class="legacy-bg1 centered"> </td>
<td>&nbsp;</td>
<td>{{ data.emperor.l9name }}</td>
</tr>
<tr>
<td class="legacy-bg1 centered"> </td>
<td>&nbsp;</td>
<td>{{ data.emperor.l8name }}</td>
<td class="legacy-bg1 centered"> </td>
<td>&nbsp;</td>
<td>{{ data.emperor.l7name }}</td>
</tr>
<tr>
<td class="legacy-bg1 centered"> </td>
<td>&nbsp;</td>
<td>{{ data.emperor.l6name }}</td>
<td class="legacy-bg1 centered"> </td>
<td>&nbsp;</td>
<td>{{ data.emperor.l5name }}</td>
</tr>
<tr>
<td class="legacy-bg1 centered"> </td>
<td colspan="5">{{ data.emperor.tiger }}</td>
</tr>
<tr>
<td class="legacy-bg1 centered"> </td>
<td colspan="5">{{ data.emperor.eagle }}</td>
</tr>
<tr>
<td class="legacy-bg1 centered"> <br />(공헌도 순서)</td>
<td colspan="5">{{ data.emperor.gen }}</td>
</tr>
<tr>
<td class="legacy-bg1 centered"> </td>
<td colspan="5">
<!-- 레거시 색상 tag를 동일한 span 구조로 변환한다. -->
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-for="(entry, index) in data.emperor.history" :key="index" v-html="formatLog(entry)" />
</td>
</tr>
</tbody>
</table>
<article class="bg-zinc-900 border border-zinc-800 rounded p-4">
<h3 class="text-base font-semibold mb-2">왕조 구성</h3>
<div class="grid sm:grid-cols-2 gap-2 text-sm text-zinc-300">
<div>세력 : {{ data.emperor.nationCount }}</div>
<div>장수 : {{ data.emperor.genCount }}</div>
<div>세력 목록: {{ data.emperor.nationName }}</div>
<div>세력 유형: {{ data.emperor.nationHist }}</div>
</div>
</article>
<table
v-for="nation in data.nations"
:key="nation.nation"
class="legacy-table legacy-bg2 old-nation-table"
>
<colgroup>
<col class="old-label" />
<col class="old-value" />
<col class="old-label" />
<col class="old-value" />
<col class="old-label" />
<col class="old-value" />
</colgroup>
<thead>
<tr>
<td
class="centered"
colspan="6"
:style="{ backgroundColor: nation.color, color: legacyNationTextColor(nation.color) }"
>
{{ nation.name }}
</td>
</tr>
</thead>
<tbody class="centered">
<tr>
<td class="legacy-bg1">성향</td>
<td>{{ nation.typeName }}</td>
<td class="legacy-bg1">-</td>
<td>-</td>
<td class="legacy-bg1">일자</td>
<td>{{ formatArchiveDate(nation.date) }}</td>
</tr>
<tr>
<td class="legacy-bg1">최종 작위</td>
<td>{{ nation.levelName }}</td>
<td class="legacy-bg1">최종 장수 </td>
<td>{{ nation.generals.length }}</td>
<td class="legacy-bg1">기술력</td>
<td>{{ nation.tech ?? '' }}</td>
</tr>
<tr>
<td class="legacy-bg1">최대 영토 </td>
<td>{{ nation.maxCities.length }}</td>
<td class="legacy-bg1">최대 병력 </td>
<td>{{ nation.maxCrew ?? 0 }}</td>
<td class="legacy-bg1">최대 국력</td>
<td>{{ nation.maxPower ?? 0 }}</td>
</tr>
<tr>
<td class="legacy-bg1 top-cell">최대영토</td>
<td colspan="5">{{ nation.maxCities.join(', ') }}</td>
</tr>
<tr>
<td class="legacy-bg1 top-cell">장수명단</td>
<td colspan="5">
<template v-for="general in nation.generalsFull" :key="general.generalNo">
{{ general.name }},
</template>
</td>
</tr>
<tr>
<td class="legacy-bg1 top-cell">국가열전</td>
<td class="legacy-bg0 nation-history" colspan="5">
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-for="(entry, index) in nation.history" :key="index" v-html="formatLog(entry)" />
</td>
</tr>
</tbody>
</table>
</template>
<article class="bg-zinc-900 border border-zinc-800 rounded p-4">
<h3 class="text-base font-semibold mb-2">관직</h3>
<ul class="grid sm:grid-cols-2 gap-2 text-sm text-zinc-300">
<li v-if="data.emperor.l12name">승상: {{ data.emperor.l12name }}</li>
<li v-if="data.emperor.l11name">태사: {{ data.emperor.l11name }}</li>
<li v-if="data.emperor.l10name">태부: {{ data.emperor.l10name }}</li>
<li v-if="data.emperor.l9name">태위: {{ data.emperor.l9name }}</li>
<li v-if="data.emperor.l8name">사도: {{ data.emperor.l8name }}</li>
<li v-if="data.emperor.l7name">사공: {{ data.emperor.l7name }}</li>
<li v-if="data.emperor.l6name">상서령: {{ data.emperor.l6name }}</li>
<li v-if="data.emperor.l5name">상서: {{ data.emperor.l5name }}</li>
</ul>
<div v-if="!data.emperor.l12name && !data.emperor.l11name" class="text-xs text-zinc-500">등록된 관직 정보가 없습니다.</div>
</article>
<article class="bg-zinc-900 border border-zinc-800 rounded p-4">
<h3 class="text-base font-semibold mb-2">장수 기록</h3>
<div class="text-sm text-zinc-300 space-y-2">
<div>
<span class="text-xs text-zinc-400">호랑이(전공):</span>
<div>{{ data.emperor.tiger || '기록 없음' }}</div>
</div>
<div>
<span class="text-xs text-zinc-400">독수리(방화):</span>
<div>{{ data.emperor.eagle || '기록 없음' }}</div>
</div>
<div>
<span class="text-xs text-zinc-400">헌납 순위:</span>
<div>{{ data.emperor.gen || '기록 없음' }}</div>
</div>
</div>
</article>
<article class="bg-zinc-900 border border-zinc-800 rounded p-4">
<h3 class="text-base font-semibold mb-2">통일 세력 연표</h3>
<div v-if="data.emperor.history.length === 0" class="text-xs text-zinc-500">연표가 없습니다.</div>
<ul v-else class="space-y-2 text-sm text-zinc-300">
<li v-for="(entry, index) in data.emperor.history" :key="`${data.emperor.id}-hist-${index}`">
{{ entry }}
</li>
</ul>
</article>
<section class="grid gap-4">
<header class="flex items-center justify-between">
<h3 class="text-base font-semibold">국가 기록</h3>
<span class="text-xs text-zinc-400"> {{ data.nations.length }}</span>
</header>
<article
v-for="nation in data.nations"
:key="nation.nation"
class="bg-zinc-900 border border-zinc-800 rounded p-4"
>
<div class="flex items-center gap-3 mb-2">
<span class="w-3 h-3 rounded-full" :style="{ backgroundColor: nation.color }" />
<div>
<h4 class="text-base font-semibold">
{{ nation.name }}
<span
v-if="nation.isWinner"
class="ml-2 inline-flex items-center rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] text-amber-200"
>
통일 세력
</span>
</h4>
<p class="text-xs text-zinc-400">{{ nation.type || '미상' }} · {{ new Date(nation.date).toLocaleString() }}</p>
</div>
</div>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-2 text-sm text-zinc-300 mb-3">
<div>레벨: {{ nation.level ?? '-' }}</div>
<div>기술: {{ nation.tech ?? '-' }}</div>
<div>최대 국력: {{ nation.maxPower ?? '-' }}</div>
<div>최대 병력: {{ nation.maxCrew ?? '-' }}</div>
<div>최대 도시: {{ nation.maxCities.join(', ') || '-' }}</div>
<div>장수 : {{ nation.generals.length }}</div>
</div>
<div class="text-sm text-zinc-300">
<h5 class="text-xs text-zinc-400 mb-1">장수 목록</h5>
<div v-if="nation.generalsFull.length === 0" class="text-xs text-zinc-500">기록된 장수가 없습니다.</div>
<ul v-else class="flex flex-wrap gap-2">
<li
v-for="general in nation.generalsFull"
:key="general.generalNo"
class="bg-zinc-950 border border-zinc-800 rounded px-2 py-1 text-xs"
>
{{ general.name }}<span v-if="general.lastYearMonth"> ({{ general.lastYearMonth }})</span>
</li>
</ul>
</div>
<div class="text-sm text-zinc-300 mt-3">
<h5 class="text-xs text-zinc-400 mb-1">국가 연표</h5>
<div v-if="nation.history.length === 0" class="text-xs text-zinc-500">연표가 없습니다.</div>
<ul v-else class="space-y-1">
<li v-for="(entry, index) in nation.history" :key="`${nation.nation}-hist-${index}`">
{{ entry }}
</li>
</ul>
</div>
</article>
</section>
</section>
<table class="legacy-table legacy-bg0 footer-table">
<tbody>
<tr>
<td>
<button class="native-button" type="button" @click="closePage"> 닫기</button><br />
</td>
</tr>
<tr>
<td class="banner">
삼국지 모의전투 TypeScript core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD
</td>
</tr>
</tbody>
</table>
</main>
</template>
<style scoped>
:global(body) {
min-width: 1016px;
}
.dynasty-page {
width: 1000px;
margin: 8px auto 0;
color: #fff;
font-family: 'Times New Roman', serif;
font-size: 16px;
line-height: normal;
}
.legacy-table {
width: 1000px;
border-spacing: 2px;
}
.legacy-table td {
padding: 1px;
}
.title-table {
height: 47px;
}
.all-link {
float: right;
}
.emperor-table {
margin-top: 0;
}
.short-label {
width: 98px;
}
.picture-column {
width: 64px;
}
.long-value {
width: 332px;
}
.phase-heading {
background: skyblue;
color: #000;
}
.large-text {
font-size: x-large;
}
.centered {
text-align: center;
}
.old-nation-table {
margin: 20px auto 0;
}
.old-label {
width: 98px;
}
.old-value {
width: 238px;
}
.top-cell {
vertical-align: top;
}
.nation-history {
text-align: left;
}
.native-button {
appearance: auto;
box-sizing: border-box;
border: 2px outset buttonborder;
border-radius: 0;
padding: 1px 6px;
background: buttonface;
color: buttontext;
font-family: Arial;
font-size: 13.3333px;
font-weight: 400;
line-height: normal;
cursor: default;
}
.legacy-message {
margin-top: 10px;
border: 1px solid gray;
padding: 12px;
background: #302016;
text-align: center;
}
.legacy-message.error {
color: #ff6b6b;
}
.footer-table {
min-height: 50px;
margin-top: 20px;
}
.banner {
font-size: 13px;
}
</style>
+229 -59
View File
@@ -1,35 +1,30 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { legacyNationTextColor } from '../utils/legacyNationColor';
import { trpc } from '../utils/trpc';
type DynastyEntry = {
id: number;
serverId: string;
phase: string;
name: string;
year: number;
month: number;
color: string;
type: string;
power: number;
gennum: number;
citynum: number;
};
type DynastyListPayload = {
entries: DynastyEntry[];
};
type DynastyListPayload = Awaited<ReturnType<typeof trpc.dynasty.getList.query>>;
const router = useRouter();
const loading = ref(false);
const errorMessage = ref('');
const entries = ref<DynastyEntry[]>([]);
const data = ref<DynastyListPayload | null>(null);
const loadDynasty = async () => {
const closePage = async (): Promise<void> => {
if (window.opener) {
window.close();
return;
}
await router.push('/');
};
const loadDynasty = async (): Promise<void> => {
loading.value = true;
errorMessage.value = '';
try {
const result = (await trpc.dynasty.getList.query()) as DynastyListPayload;
entries.value = result.entries;
data.value = await trpc.dynasty.getList.query();
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '왕조일람을 불러오지 못했습니다.';
} finally {
@@ -37,50 +32,225 @@ const loadDynasty = async () => {
}
};
onMounted(async () => {
await loadDynasty();
});
onMounted(loadDynasty);
</script>
<template>
<main class="main-page">
<header class="page-header">
<div>
<h1 class="page-title">왕조일람</h1>
<p class="page-subtitle">천하통일로 정리된 왕조 기록을 확인합니다.</p>
</div>
<div class="header-actions">
<button class="ghost" @click="loadDynasty">목록 새로고침</button>
</div>
</header>
<main id="dynasty-list-container" class="dynasty-page">
<table class="legacy-table legacy-bg0 title-table">
<tbody>
<tr>
<td>
<br />
<button class="native-button" type="button" @click="closePage"> 닫기</button><br />
</td>
</tr>
</tbody>
</table>
<div v-if="errorMessage" class="error">{{ errorMessage }}</div>
<div v-else-if="loading" class="placeholder">불러오는 중...</div>
<div v-else-if="entries.length === 0" class="placeholder">표시할 왕조 기록이 없습니다.</div>
<div v-if="errorMessage" class="legacy-message error" role="alert">{{ errorMessage }}</div>
<div v-else-if="loading && !data" class="legacy-message" role="status">불러오는 중...</div>
<section v-else class="grid gap-4">
<RouterLink
v-for="entry in entries"
<template v-if="data">
<table v-if="data.current" class="legacy-table legacy-bg0 current-table spaced-table">
<tbody>
<tr>
<td class="current-heading" colspan="8">
<span class="large-text">현재 ({{ data.current.year }} {{ data.current.month }})</span>
<RouterLink to="/yearbook"><button class="native-button" type="button">역사 보기</button></RouterLink>
</td>
</tr>
</tbody>
</table>
<div v-if="data.entries.length === 0" class="legacy-message">표시할 왕조 기록이 없습니다.</div>
<table
v-for="entry in data.entries"
:key="entry.id"
class="block bg-zinc-900 border border-zinc-800 rounded p-4 hover:border-zinc-600 transition"
:to="`/dynasty/${entry.id}`"
class="legacy-table legacy-bg0 dynasty-table spaced-table"
>
<div class="flex items-center gap-3 mb-2">
<span class="w-3 h-3 rounded-full" :style="{ backgroundColor: entry.color }" />
<div>
<h2 class="text-lg font-semibold">{{ entry.name }}</h2>
<p class="text-xs text-zinc-400">
{{ entry.phase }} · {{ entry.year }} {{ entry.month }}
</p>
</div>
</div>
<div class="grid sm:grid-cols-3 gap-2 text-sm text-zinc-300">
<div>세력 유형: {{ entry.type || '미상' }}</div>
<div>도시: {{ entry.citynum }}</div>
<div>장수: {{ entry.gennum }}</div>
<div class="sm:col-span-3">국력: {{ entry.power }}</div>
</div>
</RouterLink>
</section>
<colgroup>
<col class="label-column" />
<col class="value-column" />
<col class="label-column" />
<col class="value-column" />
<col class="label-column" />
<col class="value-column" />
<col class="label-column" />
<col class="value-column" />
</colgroup>
<tbody>
<tr>
<td class="phase-heading" colspan="8">
<span class="large-text">{{ entry.phase }}</span>
<RouterLink :to="`/dynasty/${entry.id}`">
<button class="native-button" type="button">자세히</button>
</RouterLink>
<RouterLink v-if="entry.serverId" :to="{ path: '/yearbook', query: { serverID: entry.serverId } }">
<button class="native-button" type="button">역사 보기</button>
</RouterLink>
</td>
</tr>
<tr>
<td
class="nation-heading"
colspan="8"
:style="{ backgroundColor: entry.color, color: legacyNationTextColor(entry.color) }"
>
<span class="large-text">{{ entry.name }} ({{ entry.year }} {{ entry.month }})</span>
</td>
</tr>
<tr>
<td class="legacy-bg1 centered"> </td>
<td class="centered">{{ entry.power }}</td>
<td class="legacy-bg1 centered"> </td>
<td class="centered">{{ entry.gennum }}</td>
<td class="legacy-bg1 centered"> </td>
<td class="centered">{{ entry.citynum }}</td>
<td class="legacy-bg1 centered"> </td>
<td class="centered">{{ entry.type }}</td>
</tr>
<tr>
<td class="legacy-bg1 centered"> </td>
<td class="centered">{{ entry.l12name }}</td>
<td class="legacy-bg1 centered"> </td>
<td class="centered">{{ entry.l11name }}</td>
<td class="legacy-bg1 centered"> </td>
<td class="centered">{{ entry.l10name }}</td>
<td class="legacy-bg1 centered"> </td>
<td class="centered">{{ entry.l9name }}</td>
</tr>
<tr>
<td class="legacy-bg1 centered"> </td>
<td class="centered">{{ entry.l8name }}</td>
<td class="legacy-bg1 centered"> </td>
<td class="centered">{{ entry.l7name }}</td>
<td class="legacy-bg1 centered"> </td>
<td class="centered">{{ entry.l6name }}</td>
<td class="legacy-bg1 centered"> </td>
<td class="centered">{{ entry.l5name }}</td>
</tr>
</tbody>
</table>
</template>
<table class="legacy-table legacy-bg0 footer-table spaced-table">
<tbody>
<tr>
<td>
<button class="native-button" type="button" @click="closePage"> 닫기</button><br />
</td>
</tr>
<tr>
<td class="banner">
삼국지 모의전투 TypeScript core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD
</td>
</tr>
</tbody>
</table>
</main>
</template>
<style scoped>
:global(body) {
min-width: 1016px;
}
.dynasty-page {
width: 1000px;
margin: 8px auto 0;
color: #fff;
font-family: 'Times New Roman', serif;
font-size: 16px;
line-height: normal;
}
.legacy-table {
width: 1000px;
border-spacing: 2px;
}
.legacy-table td {
padding: 1px;
}
.spaced-table {
margin-top: 10px;
}
.title-table {
height: 47px;
}
.current-table {
height: 37px;
}
.dynasty-table {
height: 139px;
}
.current-heading,
.phase-heading,
.nation-heading,
.centered {
text-align: center;
}
.current-heading {
background: #333;
}
.phase-heading {
background: skyblue;
color: #000;
}
.large-text {
font-size: x-large;
}
.label-column {
width: 80px;
}
.value-column {
width: 170px;
}
.native-button {
appearance: auto;
box-sizing: border-box;
border: 2px outset buttonborder;
border-radius: 0;
padding: 1px 6px;
background: buttonface;
color: buttontext;
font-family: Arial;
font-size: 13.3333px;
font-weight: 400;
line-height: normal;
cursor: default;
}
.legacy-message {
margin-top: 10px;
border: 1px solid gray;
padding: 12px;
background: #302016;
text-align: center;
}
.legacy-message.error {
color: #ff6b6b;
}
.footer-table {
min-height: 50px;
}
.banner {
font-size: 13px;
}
</style>
+45 -5
View File
@@ -135,7 +135,7 @@ onMounted(async () => {
</select>
</label>
<div v-if="errorMessage" class="legacy-message error">{{ errorMessage }}</div>
<div v-if="errorMessage" class="legacy-message error" role="alert">{{ errorMessage }}</div>
<div v-else-if="loading" class="legacy-message">불러오는 중...</div>
<div v-else-if="!data" class="legacy-message">표시할 데이터가 없습니다.</div>
@@ -171,6 +171,10 @@ onMounted(async () => {
<div class="legacy-hall-bottom">
<button class="legacy-button" type="button" @click="closePage"> 닫기</button>
</div>
<footer class="legacy-banner">
삼국지 모의전투 HiDCHe core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD /
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a>
</footer>
</main>
</template>
@@ -191,7 +195,7 @@ onMounted(async () => {
.legacy-hall-title,
.legacy-hall-bottom {
text-align: center;
text-align: left;
}
.legacy-hall-title {
@@ -205,12 +209,33 @@ onMounted(async () => {
}
.scenario-search select {
min-width: 220px;
width: 189px;
height: 20px;
border: 1px solid #555;
background: #ddd;
color: #303030;
}
.legacy-button {
border: 0;
border-radius: 5.25px;
background: #375a7f;
padding: 5.25px 10.5px;
font-weight: 700;
line-height: 21px;
}
.legacy-button:hover,
.legacy-button:focus,
.legacy-button:active {
background: #6b6b6b;
}
.legacy-button:focus-visible {
outline: revert;
outline-offset: 0;
}
.legacy-message {
border: 1px solid gray;
padding: 12px;
@@ -221,6 +246,15 @@ onMounted(async () => {
color: #ff6b6b;
}
.legacy-banner {
font-size: 13px;
}
.legacy-banner a {
color: #fff;
text-decoration: underline;
}
.hall-sections {
display: block;
}
@@ -235,7 +269,9 @@ onMounted(async () => {
margin: 0;
border-bottom: 1px solid gray;
padding: 2px;
font-size: 1.17em;
font-size: calc(19px + 0.784615vw);
font-weight: 500;
line-height: 1.2;
text-align: center;
}
@@ -275,7 +311,7 @@ onMounted(async () => {
display: inline-block;
width: 64px;
height: 64px;
object-fit: cover;
object-fit: fill;
}
.hall-server,
@@ -309,5 +345,9 @@ onMounted(async () => {
.legacy-hall-page {
width: 1000px;
}
.rankType {
font-size: 28px;
}
}
</style>
+13 -10
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { RouterLink, useRouter } from 'vue-router';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import { trpc } from '../utils/trpc';
@@ -281,6 +281,7 @@ onMounted(() => {
<p class="join-subtitle">로그인 완료, 아직 장수가 없는 상태입니다.</p>
</div>
<div class="join-tabs">
<RouterLink class="simulator-link" to="/battle-simulator">전투 시뮬레이터</RouterLink>
<button :class="{ active: activeTab === 'create' }" @click="activeTab = 'create'">장수 생성</button>
<button :class="{ active: activeTab === 'possess' }" @click="activeTab = 'possess'">NPC 빙의</button>
</div>
@@ -464,17 +465,13 @@ onMounted(() => {
<section v-else class="join-grid">
<PanelCard title="빙의 가능한 NPC 목록" subtitle="NPC 타입2 장수를 선택해 빙의합니다.">
<template #actions>
<button class="ghost" :disabled="npcLoading" @click="loadNpcCandidates(true)">
목록 새로고침
</button>
<button class="ghost" :disabled="npcLoading" @click="loadNpcCandidates(true)">목록 새로고침</button>
</template>
<div v-if="npcError" class="muted">{{ npcError }}</div>
<div v-if="npcLoading && npcCandidates.length === 0">
<SkeletonLines :lines="3" />
</div>
<div v-else-if="npcCandidates.length === 0" class="muted">
빙의 가능한 NPC가 없습니다.
</div>
<div v-else-if="npcCandidates.length === 0" class="muted">빙의 가능한 NPC가 없습니다.</div>
<div v-else class="npc-list">
<div v-for="npc in npcCandidates" :key="npc.id" class="npc-card">
<div class="npc-header">
@@ -490,9 +487,7 @@ onMounted(() => {
<div>나이 {{ npc.age }}</div>
<div>도시 {{ npc.city?.name ?? '-' }}</div>
</div>
<button class="npc-action" :disabled="submitting" @click="possessGeneral(npc.id)">
빙의
</button>
<button class="npc-action" :disabled="submitting" @click="possessGeneral(npc.id)">빙의</button>
</div>
</div>
<div class="npc-footer">
@@ -542,6 +537,14 @@ onMounted(() => {
font-size: 0.8rem;
}
.simulator-link {
border: 1px solid rgba(112, 170, 141, 0.55);
padding: 6px 10px;
color: #bfe2cd;
font-size: 0.8rem;
text-decoration: none;
}
.join-tabs button.active {
background: rgba(201, 164, 90, 0.2);
}
+53 -34
View File
@@ -50,7 +50,7 @@ const {
reservedNationTurns,
messageDraftText,
targetMailbox,
mailboxOptions,
mailboxGroups,
statusLine,
realtimeLabel,
} = storeToRefs(dashboard);
@@ -121,10 +121,11 @@ watch(
<RouterLink class="ghost" to="/dynasty">왕조일람</RouterLink>
<RouterLink class="ghost" to="/yearbook">연감</RouterLink>
<RouterLink class="ghost" to="/nation-betting">천통국 베팅</RouterLink>
<RouterLink class="ghost" to="/traffic">접속량정보</RouterLink>
<RouterLink class="ghost" to="/npc-list">빙의일람</RouterLink>
<a class="ghost" href="/xe/community" target="_blank" rel="noopener">게시판</a>
<RouterLink class="ghost" to="/battle-simulator">전투 시뮬레이터</RouterLink>
<RouterLink class="ghost" to="/my-page"> 정보</RouterLink>
<RouterLink class="ghost" to="/my-page"> 정보&amp;설정</RouterLink>
<RouterLink class="ghost" :class="{ highlight: tournamentStage === 1 }" to="/tournament"
>토너먼트</RouterLink
>
@@ -222,22 +223,26 @@ watch(
</div>
<div v-if="mobileTab === 'messages'" class="mobile-panel">
<PanelCard title="메시지함">
<MessagePanel
:messages="messages"
:loading="loading"
:target-mailbox="targetMailbox"
:draft-text="messageDraftText"
:mailbox-options="mailboxOptions"
:can-respond-diplomacy="messages?.canRespondDiplomacy ?? false"
@update:target-mailbox="targetMailbox = $event"
@update:draft-text="messageDraftText = $event"
@send="dashboard.sendMessage"
@load-older="dashboard.loadOlderMessages"
@refresh="dashboard.refreshMessages"
@respond="dashboard.respondToMessage"
/>
</PanelCard>
<MessagePanel
class="mobile-message-panel"
:messages="messages"
:loading="loading"
:target-mailbox="targetMailbox"
:draft-text="messageDraftText"
:mailbox-groups="mailboxGroups"
:general-id="general?.id ?? 0"
:general-name="general?.name ?? ''"
:nation-id="general?.nationId ?? 0"
:can-respond-diplomacy="messages?.canRespondDiplomacy ?? false"
@update:target-mailbox="targetMailbox = $event"
@update:draft-text="messageDraftText = $event"
@send="dashboard.sendMessage"
@load-older="dashboard.loadOlderMessages"
@refresh="dashboard.refreshMessages"
@respond="dashboard.respondToMessage"
@read-latest="dashboard.readLatestMessage"
@delete="dashboard.deleteMessage"
/>
</div>
</section>
@@ -257,22 +262,6 @@ watch(
<div>세력 {{ lobbyInfo?.nationCnt ?? '-' }}</div>
</div>
</PanelCard>
<PanelCard title="메시지함">
<MessagePanel
:messages="messages"
:loading="loading"
:target-mailbox="targetMailbox"
:draft-text="messageDraftText"
:mailbox-options="mailboxOptions"
:can-respond-diplomacy="messages?.canRespondDiplomacy ?? false"
@update:target-mailbox="targetMailbox = $event"
@update:draft-text="messageDraftText = $event"
@send="dashboard.sendMessage"
@load-older="dashboard.loadOlderMessages"
@refresh="dashboard.refreshMessages"
@respond="dashboard.respondToMessage"
/>
</PanelCard>
</div>
<div class="stack">
@@ -308,6 +297,26 @@ watch(
<div v-else class="placeholder">개인 기록 영역</div>
</PanelCard>
</div>
<MessagePanel
class="desktop-message-panel"
:messages="messages"
:loading="loading"
:target-mailbox="targetMailbox"
:draft-text="messageDraftText"
:mailbox-groups="mailboxGroups"
:general-id="general?.id ?? 0"
:general-name="general?.name ?? ''"
:nation-id="general?.nationId ?? 0"
:can-respond-diplomacy="messages?.canRespondDiplomacy ?? false"
@update:target-mailbox="targetMailbox = $event"
@update:draft-text="messageDraftText = $event"
@send="dashboard.sendMessage"
@load-older="dashboard.loadOlderMessages"
@refresh="dashboard.refreshMessages"
@respond="dashboard.respondToMessage"
@read-latest="dashboard.readLatestMessage"
@delete="dashboard.deleteMessage"
/>
</section>
</main>
</template>
@@ -402,6 +411,16 @@ button {
gap: 16px;
}
.desktop-message-panel {
grid-column: 1 / -1;
}
.mobile-message-panel {
width: 100vw;
min-width: 0;
margin-left: -24px;
}
.layout-mobile {
display: flex;
flex-direction: column;
+543 -467
View File
@@ -1,19 +1,16 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useMediaQuery } from '@vueuse/core';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
import CityBasicCard from '../components/main/CityBasicCard.vue';
import NationBasicCard from '../components/main/NationBasicCard.vue';
import { trpc } from '../utils/trpc';
import { formatLog } from '../utils/formatLog';
const SCREEN_MODE_KEY = 'sammo-screen-mode';
const SCREEN_MODE_KEY = 'sam.screenMode';
const CUSTOM_CSS_KEY = 'sam_customCSS';
type ScreenMode = 'auto' | '500px' | '1000px';
type LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction';
type ItemSlotKey = 'horse' | 'weapon' | 'book' | 'item';
type MyGeneralResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
type WorldStateSnapshot = {
type WorldSnapshot = {
currentYear: number;
currentMonth: number;
tickSeconds: number;
@@ -21,48 +18,54 @@ type WorldStateSnapshot = {
meta: Record<string, unknown>;
} | null;
type LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction';
type LogLine = {
id: number;
html: string;
};
type ItemSlotKey = 'horse' | 'weapon' | 'book' | 'item';
type ItemSlot = {
key: ItemSlotKey;
label: string;
code: string | null;
};
const logTypes: LogType[] = ['generalHistory', 'battleDetail', 'battleResult', 'generalAction'];
const logLabels: Record<LogType, string> = {
generalHistory: '장수 열전',
battleDetail: '전투 기록',
battleResult: '전투 결과',
generalAction: '개인 기록',
type SettingForm = {
tnmt: number;
defence_train: number;
use_treatment: number;
use_auto_nation_turn: number;
};
const data = ref<MyGeneralResponse | null>(null);
const world = ref<WorldSnapshot>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const data = ref<MyGeneralResponse | null>(null);
const worldState = ref<WorldStateSnapshot>(null);
const screenMode = ref<ScreenMode>('auto');
const customCss = ref('');
const cssSaving = ref(false);
let cssTimer: number | null = null;
const logs = reactive<Record<LogType, LogLine[]>>({
const form = reactive<SettingForm>({
tnmt: 1,
defence_train: 80,
use_treatment: 10,
use_auto_nation_turn: 1,
});
const logTypes: LogType[] = ['generalAction', 'battleDetail', 'generalHistory', 'battleResult'];
const logLabels: Record<LogType, string> = {
generalAction: '개인 기록',
battleDetail: '전투 기록',
generalHistory: '장수 열전',
battleResult: '전투 결과',
};
const logColors: Record<LogType, string> = {
generalAction: 'skyblue',
battleDetail: 'orange',
generalHistory: 'skyblue',
battleResult: 'orange',
};
const logs = reactive<Record<LogType, Array<{ id: number; html: string }>>>({
generalHistory: [],
battleDetail: [],
battleResult: [],
generalAction: [],
});
const logLoading = reactive<Record<LogType, boolean>>({
generalHistory: false,
battleDetail: false,
battleResult: false,
generalAction: false,
});
const logHasMore = reactive<Record<LogType, boolean>>({
generalHistory: true,
battleDetail: true,
@@ -70,520 +73,593 @@ const logHasMore = reactive<Record<LogType, boolean>>({
generalAction: true,
});
const activeLogTab = ref<LogType>('generalAction');
const isMobile = useMediaQuery('(max-width: 1024px)');
const screenMode = ref<'auto' | '500px' | '1000px'>('auto');
const errorText = (value: unknown): string =>
value instanceof Error ? value.message : typeof value === 'string' ? value : 'unknown_error';
const resolveErrorMessage = (value: unknown): string => {
if (value instanceof Error) {
return value.message;
}
if (typeof value === 'string') {
return value;
}
return 'unknown_error';
const asRecord = (value: unknown): Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
const numberValue = (value: unknown, fallback: number): number => {
const parsed = typeof value === 'number' ? value : Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
};
const resolveNumber = (value: unknown, fallback: number): number => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return fallback;
};
const statusLine = computed(() =>
world.value
? `${world.value.currentYear}${world.value.currentMonth}월 · ${Math.max(
1,
Math.round(world.value.tickSeconds / 60)
)}분 턴`
: '내 정보를 불러오는 중'
);
const statusLine = computed(() => {
if (!worldState.value) {
return '내 정보를 불러오는 중';
}
const turnTerm = resolveNumber((worldState.value.config as Record<string, unknown>)?.turnTermMinutes, 0);
const termLabel = turnTerm > 0 ? ` · 턴 ${turnTerm}` : '';
return `${worldState.value.currentYear}${worldState.value.currentMonth}${termLabel}`;
});
const itemSlots = computed<ItemSlot[]>(() => {
const items = data.value?.general?.items;
return [
{ key: 'horse', label: '말', code: items?.horse ?? null },
{ key: 'weapon', label: '무기', code: items?.weapon ?? null },
{ key: 'book', label: '서적', code: items?.book ?? null },
{ key: 'item', label: '아이템', code: items?.item ?? null },
];
});
const canSave = computed(() => (data.value?.settings.myset ?? 1) > 0);
const penalties = computed(() => Object.entries(data.value?.penalties ?? {}));
const items = computed<Array<{ key: ItemSlotKey; name: string; code: string | null }>>(() => [
{ key: 'horse', name: '말', code: data.value?.general.items.horse ?? null },
{ key: 'weapon', name: '무기', code: data.value?.general.items.weapon ?? null },
{ key: 'book', name: '서적', code: data.value?.general.items.book ?? null },
{ key: 'item', name: '도구', code: data.value?.general.items.item ?? null },
]);
const autorunUser = computed(() => asRecord(world.value?.meta.autorun_user));
const showAutoNationTurn = computed(() => Boolean(asRecord(autorunUser.value.options).chief));
const showVacation = computed(() => !autorunUser.value.limit_minutes);
const actionAvailability = computed(() => {
const general = data.value?.general;
const meta = (worldState.value?.meta ?? {}) as Record<string, unknown>;
const config = (worldState.value?.config ?? {}) as Record<string, unknown>;
const autorunUser = (meta.autorun_user ?? {}) as Record<string, unknown>;
const turntime = meta.turntime ? new Date(String(meta.turntime)) : null;
const opentime = meta.opentime ? new Date(String(meta.opentime)) : null;
const preopen = Boolean(turntime && opentime && turntime.getTime() <= opentime.getTime());
const npcMode = resolveNumber(config.npcMode, 0);
const meta = world.value?.meta ?? {};
const config = world.value?.config ?? {};
const constConfig = asRecord(config.const);
const availableInstantAction = asRecord(constConfig.availableInstantAction ?? config.availableInstantAction);
const turnTime = meta.turntime ? new Date(String(meta.turntime)) : null;
const openTime = meta.opentime ? new Date(String(meta.opentime)) : null;
const preopen = Boolean(turnTime && openTime && turnTime.getTime() <= openTime.getTime());
const npcMode = numberValue(config.npcMode ?? config.npcmode, 0);
return {
canDieOnPrestart: Boolean(preopen && general && general.npcState === 0 && general.nationId === 0),
canBuildNationCandidate: Boolean(preopen && general && general.nationId === 0),
canVacation: !(autorunUser.limit_minutes ?? false),
canInstantRetreat: Boolean(general && general.nationId > 0),
canSelectOtherGeneral: Boolean(npcMode === 2 && general && general.npcState === 0),
dieOnPrestart: Boolean(preopen && general?.npcState === 0 && general.nationId === 0),
buildNationCandidate: Boolean(preopen && general?.nationId === 0),
instantRetreat: Boolean(availableInstantAction.instantRetreat),
selectOtherGeneral: Boolean(npcMode === 2 && general?.npcState === 0),
};
});
const loadLogs = async () => {
if (!data.value?.general?.id) {
return;
const applyCustomCss = (text: string) => {
let style = document.getElementById('sammo-custom-css') as HTMLStyleElement | null;
if (!style) {
style = document.createElement('style');
style.id = 'sammo-custom-css';
document.head.appendChild(style);
}
await Promise.all(
logTypes.map((type) => loadLog(type))
);
style.textContent = text;
};
const loadLog = async (type: LogType, beforeId?: number) => {
if (logLoading[type]) {
return;
}
if (logLoading[type]) return;
logLoading[type] = true;
try {
const response = await trpc.general.getMyLog.query({ type, beforeId });
const formatted = response.logs.map((entry) => ({
id: entry.id,
html: formatLog(entry.text),
}));
if (beforeId) {
logs[type].push(...formatted);
} else {
logs[type] = formatted;
}
logHasMore[type] = formatted.length >= 24;
} catch (err) {
error.value = resolveErrorMessage(err);
const next = response.logs.map((entry) => ({ id: entry.id, html: formatLog(entry.text) }));
logs[type] = beforeId ? [...logs[type], ...next] : next;
logHasMore[type] = next.length >= 24;
} catch (cause) {
error.value = errorText(cause);
} finally {
logLoading[type] = false;
}
};
const loadMyPage = async () => {
if (loading.value) {
return;
}
const loadPage = async () => {
if (loading.value) return;
loading.value = true;
error.value = null;
try {
const general = await trpc.general.me.query();
const world = await (trpc.world.getState.query as unknown as () => Promise<WorldStateSnapshot>)();
const [general, state] = await Promise.all([
trpc.general.me.query(),
trpc.world.getState.query() as Promise<WorldSnapshot>,
]);
data.value = general;
worldState.value = world ?? null;
await loadLogs();
} catch (err) {
error.value = resolveErrorMessage(err);
world.value = state;
if (general) {
Object.assign(form, general.settings);
}
await Promise.all(logTypes.map((type) => loadLog(type)));
} catch (cause) {
error.value = errorText(cause);
} finally {
loading.value = false;
}
};
const confirmAction = async (message: string, action: () => Promise<void>) => {
if (!confirm(message)) {
return;
}
const saveSettings = async () => {
if (!canSave.value) return;
try {
await action();
await loadMyPage();
} catch (err) {
alert(`실패했습니다: ${resolveErrorMessage(err)}`);
await trpc.general.setMySetting.mutate({ ...form });
await loadPage();
} catch (cause) {
alert(`실패했습니다: ${errorText(cause)}`);
}
};
const handleDieOnPrestart = () =>
confirmAction('정말로 삭제하시겠습니까?', async () => {
await trpc.general.dieOnPrestart.mutate();
window.location.reload();
});
const handleBuildNationCandidate = () =>
confirmAction('거병 이후 장수를 삭제할 수 없습니다. 거병하시겠습니까?', async () => {
await trpc.general.buildNationCandidate.mutate();
});
const handleInstantRetreat = () =>
confirmAction('아군 접경으로 이동할까요?', async () => {
await trpc.general.instantRetreat.mutate();
});
const handleVacation = () =>
confirmAction('휴가 기능을 신청할까요?', async () => {
await trpc.general.vacation.mutate();
});
const handleDropItem = (slot: ItemSlot) =>
confirmAction(`${slot.label}(${slot.code ?? '-'})을(를) 파기하시겠습니까?`, async () => {
await trpc.general.dropItem.mutate({ itemType: slot.key });
});
const refreshScreenMode = () => {
if (typeof window === 'undefined') {
return;
}
const mode = window.localStorage.getItem(SCREEN_MODE_KEY);
if (mode === '500px' || mode === '1000px') {
screenMode.value = mode;
} else {
screenMode.value = 'auto';
const confirmMutation = async (message: string, mutation: () => Promise<unknown>) => {
if (!confirm(message)) return;
try {
await mutation();
await loadPage();
} catch (cause) {
alert(`실패했습니다: ${errorText(cause)}`);
}
};
watch(
() => isMobile.value,
(value) => {
if (value && !logTypes.includes(activeLogTab.value)) {
activeLogTab.value = 'generalAction';
}
}
);
const dropItem = (item: { key: ItemSlotKey; name: string; code: string | null }) =>
confirmMutation(`${item.code ?? item.name}을(를) 버리시겠습니까?`, () =>
trpc.general.dropItem.mutate({ itemType: item.key })
);
watch(screenMode, (mode) => {
localStorage.setItem(SCREEN_MODE_KEY, mode);
document.dispatchEvent(new CustomEvent('tryChangeScreenMode'));
});
watch(customCss, (text) => {
if (cssTimer !== null) window.clearTimeout(cssTimer);
cssSaving.value = true;
cssTimer = window.setTimeout(() => {
localStorage.setItem(CUSTOM_CSS_KEY, text);
applyCustomCss(text);
cssSaving.value = false;
}, 500);
});
onMounted(() => {
refreshScreenMode();
void loadMyPage();
const storedMode = localStorage.getItem(SCREEN_MODE_KEY);
screenMode.value = storedMode === '500px' || storedMode === '1000px' ? storedMode : 'auto';
customCss.value = localStorage.getItem(CUSTOM_CSS_KEY) ?? '';
applyCustomCss(customCss.value);
void loadPage();
});
</script>
<template>
<main class="my-page" :class="`screen-${screenMode}`">
<header class="page-header">
<div>
<h1 class="page-title"> 정보</h1>
<p class="page-subtitle">{{ statusLine }}</p>
</div>
<div class="header-actions">
<RouterLink class="ghost" to="/">메인</RouterLink>
<RouterLink class="ghost" to="/my-settings">게임 설정</RouterLink>
<button class="ghost" @click="loadMyPage">새로고침</button>
</div>
</header>
<main id="container" class="legacy-page bg0" :class="`screen-${screenMode}`">
<div class="title-row">
<span> </span>
<RouterLink class="legacy-button" to="/">돌아가기</RouterLink>
<button class="legacy-button" type="button" @click="loadPage">새로고침</button>
</div>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="error" class="error-row">{{ error }}</div>
<div class="status-row">{{ statusLine }}</div>
<section class="layout-grid">
<div class="stack">
<PanelCard title="장수 상태">
<GeneralBasicCard :general="data?.general ?? null" :loading="loading" />
</PanelCard>
<PanelCard title="도시 상태">
<CityBasicCard :city="data?.city ?? null" :loading="loading" />
</PanelCard>
<PanelCard title="세력 상태">
<NationBasicCard :nation="data?.nation ?? null" :loading="loading" />
</PanelCard>
</div>
<div class="stack">
<PanelCard title="장수 상태 변경" subtitle="중요 액션은 확인 후 실행됩니다.">
<div class="action-grid">
<button
v-if="actionAvailability.canVacation"
class="action-btn"
type="button"
@click="handleVacation"
>
휴가 신청
</button>
<button
v-if="actionAvailability.canDieOnPrestart"
class="action-btn"
type="button"
@click="handleDieOnPrestart"
>
장수 삭제
</button>
<button
v-if="actionAvailability.canBuildNationCandidate"
class="action-btn"
type="button"
@click="handleBuildNationCandidate"
>
사전 거병
</button>
<button
v-if="actionAvailability.canInstantRetreat"
class="action-btn"
type="button"
@click="handleInstantRetreat"
>
접경 귀환
</button>
<RouterLink
v-if="actionAvailability.canSelectOtherGeneral"
class="action-btn link"
to="/join"
>
다른 장수 선택
</RouterLink>
<section class="top-grid">
<div class="general-column">
<div class="section-title sky">장수 정보</div>
<div v-if="loading || !data" class="loading">불러오는 중...</div>
<div v-else class="general-table">
<div class="portrait-cell">
<img
:src="
data.general.picture ? `/image/game/${data.general.picture}` : '/image/game/default.jpg'
"
alt=""
/>
<strong>{{ data.general.name }}</strong>
</div>
</PanelCard>
<PanelCard title="아이템 파기" subtitle="소지 중인 장비를 선택합니다.">
<div class="item-grid">
<button
v-for="slot in itemSlots"
:key="slot.key"
class="item-btn"
type="button"
:disabled="!slot.code"
@click="handleDropItem(slot)"
>
{{ slot.label }}: {{ slot.code ?? '-' }}
</button>
</div>
</PanelCard>
<PanelCard v-if="isMobile" title="장수 기록">
<div class="log-tabs">
<button
v-for="type in logTypes"
:key="type"
:class="{ active: activeLogTab === type }"
@click="activeLogTab = type"
>
{{ logLabels[type] }}
</button>
</div>
<div class="log-block">
<div class="log-title">{{ logLabels[activeLogTab] }}</div>
<SkeletonLines v-if="loading || logLoading[activeLogTab]" :lines="4" />
<!-- eslint-disable vue/no-v-html -->
<template v-else>
<div v-if="logs[activeLogTab].length === 0" class="empty">기록이 없습니다.</div>
<div
v-for="entry in logs[activeLogTab]"
:key="entry.id"
class="log-line"
v-html="entry.html"
/>
<button
v-if="logHasMore[activeLogTab]"
class="ghost log-more"
@click="loadLog(activeLogTab, logs[activeLogTab].at(-1)?.id)"
>
이전 로그 불러오기
</button>
</template>
<!-- eslint-enable vue/no-v-html -->
</div>
</PanelCard>
<PanelCard v-else title="장수 기록" subtitle="개인 기록 및 전투 로그">
<div class="log-grid">
<div v-for="type in logTypes" :key="type" class="log-block">
<div class="log-title">{{ logLabels[type] }}</div>
<SkeletonLines v-if="loading || logLoading[type]" :lines="3" />
<!-- eslint-disable vue/no-v-html -->
<template v-else>
<div v-if="logs[type].length === 0" class="empty">기록이 없습니다.</div>
<div v-for="entry in logs[type]" :key="entry.id" class="log-line" v-html="entry.html" />
<button
v-if="logHasMore[type]"
class="ghost log-more"
@click="loadLog(type, logs[type].at(-1)?.id)"
>
이전 로그 불러오기
</button>
</template>
<!-- eslint-enable vue/no-v-html -->
<dl>
<div>
<dt>통솔</dt>
<dd>{{ data.general.stats.leadership }}</dd>
</div>
</div>
</PanelCard>
<div>
<dt>무력</dt>
<dd>{{ data.general.stats.strength }}</dd>
</div>
<div>
<dt>지력</dt>
<dd>{{ data.general.stats.intelligence }}</dd>
</div>
<div>
<dt>소속</dt>
<dd>{{ data.nation?.name ?? '재야' }}</dd>
</div>
<div>
<dt>도시</dt>
<dd>{{ data.city?.name ?? '-' }}</dd>
</div>
<div>
<dt>/</dt>
<dd>{{ data.general.gold }} / {{ data.general.rice }}</dd>
</div>
<div>
<dt>병력</dt>
<dd>{{ data.general.crew }}</dd>
</div>
<div>
<dt>훈련/사기</dt>
<dd>{{ data.general.train }} / {{ data.general.atmos }}</dd>
</div>
<div>
<dt>경험/공헌</dt>
<dd>{{ data.general.experience }} / {{ data.general.dedication }}</dd>
</div>
</dl>
</div>
</div>
<div class="settings-column">
<div class="setting-line">
토너먼트
<label><input v-model.number="form.tnmt" type="radio" :value="0" />수동참여</label>
<label><input v-model.number="form.tnmt" type="radio" :value="1" />자동참여</label>
</div>
<div class="hint"> 개막직전 남는자리가 있을경우 랜덤하게 참여합니다.</div>
<label class="setting-line">
환약 사용
<select v-model.number="form.use_treatment">
<option :value="10">경상</option>
<option :value="21">중상</option>
<option :value="41">심각</option>
<option :value="61">위독</option>
<option :value="100">사용안함</option>
</select>
</label>
<div class="hint"> 부상을 입었을 환약을 사용하는 기준입니다.</div>
<label v-if="showAutoNationTurn" class="setting-line">
자동 사령턴 허용
<select v-model.number="form.use_auto_nation_turn">
<option :value="1">허용</option>
<option :value="0">허용 안함</option>
</select>
</label>
<label class="setting-line">
수비
<select v-model.number="form.defence_train">
<option :value="90">수비 (훈사90)</option>
<option :value="80">수비 (훈사80)</option>
<option :value="60">수비 (훈사60)</option>
<option :value="40">수비 (훈사40)</option>
<option :value="999">수비 안함 [훈련 -3, 사기 -6]</option>
</select>
</label>
<button
id="set_my_setting"
class="action-button"
type="button"
:hidden="!canSave"
@click="saveSettings"
>
설정저장
</button>
<div class="hint"> 설정저장은 이달중 {{ data?.settings.myset ?? 0 }} 남았습니다.</div>
<div v-if="penalties.length" class="penalties">
징계 목록(저장 갱신)
<div v-for="[key, value] in penalties" :key="key">{{ key }} : {{ value }}</div>
</div>
<div v-if="showVacation" class="action-line">
<br />
<button
class="action-button"
type="button"
@click="confirmMutation('휴가 기능을 신청할까요?', () => trpc.general.vacation.mutate())"
>
휴가 신청
</button>
</div>
<div v-if="actionAvailability.dieOnPrestart" class="action-line">
가오픈 기간 장수 삭제<br />
<button
class="action-button"
@click="confirmMutation('정말로 삭제하시겠습니까?', () => trpc.general.dieOnPrestart.mutate())"
>
장수 삭제
</button>
</div>
<div v-if="actionAvailability.buildNationCandidate" class="action-line">
서버 개시 이전 거병(2턴부터 건국 가능)<br />
<button
class="action-button"
@click="
confirmMutation('거병 이후 장수를 삭제할 수 없게됩니다. 거병하시겠습니까?', () =>
trpc.general.buildNationCandidate.mutate()
)
"
>
사전 거병
</button>
</div>
<div v-if="actionAvailability.instantRetreat" class="action-line">
거리 3칸 이내 아국 도시로 즉시 이동<br />
<button
class="action-button"
@click="
confirmMutation('아군 접경으로 이동할까요?', () => trpc.general.instantRetreat.mutate())
"
>
접경 귀환
</button>
</div>
<div class="screen-mode-row">
<span>500px/1000px 모드<br />(모바일 전용, 즉시 설정)</span>
<div class="button-group">
<label><input v-model="screenMode" type="radio" value="auto" />자동</label>
<label><input v-model="screenMode" type="radio" value="500px" />500px</label>
<label><input v-model="screenMode" type="radio" value="1000px" />1000px</label>
</div>
</div>
<div class="item-title">아이템 파기</div>
<div class="item-group">
<button
v-for="item in items"
:key="item.key"
type="button"
:disabled="!item.code"
@click="dropItem(item)"
>
{{ item.code ?? '-' }}
</button>
</div>
<label class="custom-css">
개인용 CSS <span>{{ cssSaving ? '(저장 중)' : '' }}</span>
<textarea id="custom_css" v-model="customCss" />
</label>
</div>
</section>
<section class="log-grid">
<article v-for="type in logTypes" :key="type" class="log-panel">
<h2 :style="{ color: logColors[type] }">{{ logLabels[type] }}</h2>
<div v-if="logLoading[type]" class="loading">불러오는 중...</div>
<div v-else>
<div v-for="entry in logs[type]" :key="entry.id" class="log-line" v-html="entry.html" />
<div v-if="logs[type].length === 0" class="empty">기록이 없습니다.</div>
<button
v-if="logHasMore[type]"
class="load-old"
type="button"
@click="loadLog(type, logs[type].at(-1)?.id)"
>
이전 로그 불러오기
</button>
</div>
</article>
</section>
</main>
</template>
<style scoped>
.my-page {
.legacy-page {
width: 100%;
max-width: 1000px;
min-width: 500px;
min-height: 100vh;
padding: 24px;
display: flex;
flex-direction: column;
gap: 24px;
transition: width 0.2s ease;
margin: 0 auto;
padding: 0;
color: #fff;
background-color: #111;
background-image: url('/image/game/back_walnut.jpg');
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-size: 14px;
line-height: 1.3;
}
.my-page.screen-500px {
.legacy-page.screen-500px {
max-width: 500px;
}
.my-page.screen-1000px {
.legacy-page.screen-1000px {
max-width: 1000px;
}
.page-header {
.title-row {
height: 54px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
align-content: flex-start;
align-items: flex-start;
justify-content: flex-start;
flex-wrap: wrap;
gap: 0 4px;
border: 1px solid #666;
background: transparent;
font-size: 14px;
}
.page-title {
font-size: 1.6rem;
.title-row > span {
flex-basis: 100%;
height: 18px;
letter-spacing: 0;
}
.legacy-button,
button,
select,
textarea {
border: 1px solid #777;
border-radius: 0;
color: #fff;
background: #6b6b6b;
font: inherit;
}
.legacy-button {
min-height: 34px;
padding: 5px 10px;
border-color: #2d5d7f;
border-radius: 4px;
background: #315f86;
color: #fff;
font-weight: 700;
text-decoration: none;
letter-spacing: 0;
}
.page-subtitle {
color: rgba(232, 221, 196, 0.7);
margin-top: 6px;
}
.header-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.layout-grid {
display: grid;
grid-template-columns: minmax(0, 320px) minmax(0, 1fr);
gap: 18px;
}
.stack {
display: flex;
flex-direction: column;
gap: 18px;
}
.action-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
}
.action-btn {
border: 1px solid rgba(201, 164, 90, 0.5);
background: rgba(12, 12, 12, 0.6);
color: inherit;
padding: 8px 12px;
font-size: 0.85rem;
button {
cursor: pointer;
}
button:disabled {
cursor: default;
opacity: 0.45;
}
.status-row,
.error-row {
padding: 4px 8px;
text-align: center;
}
.action-btn.link {
display: inline-flex;
align-items: center;
justify-content: center;
.error-row {
color: #ff7777;
border: 1px solid #a33;
}
.item-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 8px;
}
.item-btn {
border: 1px solid rgba(201, 164, 90, 0.4);
background: rgba(12, 12, 12, 0.6);
color: inherit;
padding: 8px 10px;
font-size: 0.82rem;
cursor: pointer;
text-align: left;
}
.item-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.top-grid,
.log-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.log-block {
border: 1px solid rgba(201, 164, 90, 0.3);
padding: 8px;
background: rgba(12, 12, 12, 0.6);
min-height: 160px;
.general-column,
.settings-column,
.log-panel {
border: 1px solid #666;
background-image: url('/image/game/back_walnut.jpg');
}
.log-title {
font-weight: 600;
margin-bottom: 6px;
font-size: 0.9rem;
.section-title,
.log-panel h2 {
min-height: 34px;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid #666;
background-color: #14241b;
background-image: url('/image/game/back_green.jpg');
font-size: 1.25em;
font-weight: 500;
}
.log-line {
padding: 4px 0;
border-bottom: 1px dashed rgba(201, 164, 90, 0.2);
.sky {
color: skyblue;
}
.log-line:last-child {
border-bottom: none;
.general-table {
display: grid;
grid-template-columns: 150px 1fr;
padding: 0;
background-color: #172a52;
background-image: url('/image/game/back_blue.jpg');
}
.log-more {
.portrait-cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 10px;
border-right: 1px solid #777;
}
.portrait-cell img {
width: 64px;
height: 64px;
object-fit: cover;
}
dl {
margin: 0;
}
dl > div {
display: grid;
grid-template-columns: 80px 1fr;
border-bottom: 1px solid #777;
}
dt,
dd {
margin: 0;
padding: 2px 5px;
border-right: 1px solid #777;
}
dt {
color: #aaa;
}
.settings-column {
padding: 10px 18px;
}
.setting-line {
display: block;
margin-top: 5px;
}
.hint {
margin: 0 0 13px;
color: orange;
}
.action-button {
width: 160px;
height: 30px;
margin: 4px 0;
background: #225500;
}
.action-line {
margin: 12px 0;
}
.penalties {
margin: 12px 0;
color: #f66;
}
.screen-mode-row {
display: grid;
grid-template-columns: 160px 1fr;
align-items: center;
margin: 14px 0;
}
.button-group {
display: flex;
}
.button-group label {
padding: 5px 8px;
border: 1px solid #666;
background: #26384d;
}
.button-group input {
margin-right: 4px;
}
.item-title {
margin-top: 12px;
}
.item-group {
display: grid;
grid-template-columns: repeat(4, 1fr);
margin: 5px 0 14px;
}
.item-group button {
min-height: 30px;
}
.custom-css {
display: block;
}
.custom-css textarea {
display: block;
width: 420px;
max-width: 100%;
height: 150px;
color: #fff;
background: #000;
}
.log-panel {
min-height: 180px;
}
.log-panel h2 {
color: orange;
}
.log-line,
.empty,
.loading {
padding: 2px 8px;
}
.load-old {
width: 100%;
min-height: 32px;
margin-top: 8px;
}
.log-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.log-tabs button {
border: 1px solid rgba(201, 164, 90, 0.4);
background: transparent;
color: inherit;
padding: 6px 10px;
font-size: 0.8rem;
cursor: pointer;
}
.log-tabs button.active {
background: rgba(201, 164, 90, 0.2);
}
.ghost {
border: 1px solid rgba(201, 164, 90, 0.5);
background: transparent;
color: inherit;
padding: 6px 10px;
font-size: 0.8rem;
cursor: pointer;
}
.empty {
color: rgba(232, 221, 196, 0.6);
font-size: 0.85rem;
}
.error {
color: #f08a5d;
font-size: 0.9rem;
}
@media (max-width: 1024px) {
.layout-grid {
grid-template-columns: 1fr;
@media (max-width: 991px) {
.legacy-page {
width: 500px;
}
.top-grid,
.log-grid {
grid-template-columns: 1fr;
}
@@ -242,7 +242,7 @@ watch(
);
onMounted(() => {
loadData();
void loadData();
});
onBeforeUnmount(() => {
@@ -278,19 +278,17 @@ onBeforeUnmount(() => {
<div class="panel-header">
<h2>국가 방침</h2>
<div class="panel-actions">
<button v-if="editable && !editingNationMsg" type="button" @click="startEditNationMsg">
수정
</button>
<button v-if="editable && editingNationMsg" type="button" @click="saveNationMsg">
저장
</button>
<button v-if="editable && editingNationMsg" type="button" @click="cancelEditNationMsg">
취소
</button>
<button v-if="editable && !editingNationMsg" type="button" @click="startEditNationMsg">수정</button>
<button v-if="editable && editingNationMsg" type="button" @click="saveNationMsg">저장</button>
<button v-if="editable && editingNationMsg" type="button" @click="cancelEditNationMsg">취소</button>
</div>
</div>
<div v-if="editingNationMsg" class="editor-toolbar">
<button type="button" @click="editor?.chain().focus().toggleBold().run()" :class="{ active: editor?.isActive('bold') }">
<button
type="button"
@click="editor?.chain().focus().toggleBold().run()"
:class="{ active: editor?.isActive('bold') }"
>
굵게
</button>
<button
@@ -323,21 +321,57 @@ onBeforeUnmount(() => {
<div class="panel-card">
<h3>자금 예산</h3>
<dl>
<div><dt>현재</dt><dd>{{ data.gold.toLocaleString() }}</dd></div>
<div><dt>단기 수입</dt><dd>{{ data.income.gold.war.toLocaleString() }}</dd></div>
<div><dt>세금</dt><dd>{{ Math.floor(incomeGoldCity).toLocaleString() }}</dd></div>
<div><dt>수입/지출</dt><dd>+{{ Math.floor(incomeGold).toLocaleString() }} / {{ Math.floor(-outcomeByBill).toLocaleString() }}</dd></div>
<div><dt>국고 예산</dt><dd>{{ Math.floor(data.gold + incomeGold - outcomeByBill).toLocaleString() }}</dd></div>
<div>
<dt>현재</dt>
<dd>{{ data.gold.toLocaleString() }}</dd>
</div>
<div>
<dt>단기 수입</dt>
<dd>{{ data.income.gold.war.toLocaleString() }}</dd>
</div>
<div>
<dt>세금</dt>
<dd>{{ Math.floor(incomeGoldCity).toLocaleString() }}</dd>
</div>
<div>
<dt>수입/지출</dt>
<dd>
+{{ Math.floor(incomeGold).toLocaleString() }} /
{{ Math.floor(-outcomeByBill).toLocaleString() }}
</dd>
</div>
<div>
<dt>국고 예산</dt>
<dd>{{ Math.floor(data.gold + incomeGold - outcomeByBill).toLocaleString() }}</dd>
</div>
</dl>
</div>
<div class="panel-card">
<h3>군량 예산</h3>
<dl>
<div><dt>현재</dt><dd>{{ data.rice.toLocaleString() }}</dd></div>
<div><dt>둔전 수입</dt><dd>{{ Math.floor(incomeRiceWall).toLocaleString() }}</dd></div>
<div><dt>세금</dt><dd>{{ Math.floor(incomeRiceCity).toLocaleString() }}</dd></div>
<div><dt>수입/지출</dt><dd>+{{ Math.floor(incomeRice).toLocaleString() }} / {{ Math.floor(-outcomeByBill).toLocaleString() }}</dd></div>
<div><dt>국고 예산</dt><dd>{{ Math.floor(data.rice + incomeRice - outcomeByBill).toLocaleString() }}</dd></div>
<div>
<dt>현재</dt>
<dd>{{ data.rice.toLocaleString() }}</dd>
</div>
<div>
<dt>둔전 수입</dt>
<dd>{{ Math.floor(incomeRiceWall).toLocaleString() }}</dd>
</div>
<div>
<dt>세금</dt>
<dd>{{ Math.floor(incomeRiceCity).toLocaleString() }}</dd>
</div>
<div>
<dt>수입/지출</dt>
<dd>
+{{ Math.floor(incomeRice).toLocaleString() }} /
{{ Math.floor(-outcomeByBill).toLocaleString() }}
</dd>
</div>
<div>
<dt>국고 예산</dt>
<dd>{{ Math.floor(data.rice + incomeRice - outcomeByBill).toLocaleString() }}</dd>
</div>
</dl>
</div>
<div class="panel-card">
@@ -359,7 +393,13 @@ onBeforeUnmount(() => {
<div class="panel-card">
<h3>기밀 권한</h3>
<div class="input-row">
<input v-model.number="policyDraft.secretLimit" type="number" min="1" max="99" :disabled="!editable" />
<input
v-model.number="policyDraft.secretLimit"
type="number"
min="1"
max="99"
:disabled="!editable"
/>
<span></span>
<button type="button" @click="setSecretLimit" :disabled="!editable">변경</button>
</div>
@@ -376,7 +416,9 @@ onBeforeUnmount(() => {
/>
전쟁 금지
</label>
<span class="hint">잔여 {{ data.warSettingCnt.remain }} ( +{{ data.warSettingCnt.inc }})</span>
<span class="hint"
>잔여 {{ data.warSettingCnt.remain }} ( +{{ data.warSettingCnt.inc }})</span
>
</div>
</div>
<div class="panel-card">
@@ -574,4 +616,4 @@ onBeforeUnmount(() => {
.loading {
color: #9aa3b8;
}
</style>
</style>
+131 -60
View File
@@ -73,9 +73,7 @@ const listYearMonth = computed(() => {
});
const listItems = computed(() =>
list.value
? Object.values(list.value.bettingList).sort((left, right) => right.id - left.id)
: []
list.value ? Object.values(list.value.bettingList).sort((left, right) => right.id - left.id) : []
);
const info = computed(() => detail.value?.bettingInfo ?? null);
@@ -112,9 +110,7 @@ const detailRows = computed(() =>
const myBetMap = computed(() => new Map(detail.value?.myBetting ?? []));
const totalAmount = computed(() =>
(detail.value?.bettingDetail ?? []).reduce((sum, [, value]) => sum + value, 0)
);
const totalAmount = computed(() => (detail.value?.bettingDetail ?? []).reduce((sum, [, value]) => sum + value, 0));
const pureAmount = computed(() =>
(detail.value?.bettingDetail ?? []).reduce(
@@ -137,9 +133,7 @@ const candidateAmounts = computed(() => {
return result;
});
const usedAmount = computed(() =>
Array.from(myBetMap.value.values()).reduce((sum, value) => sum + value, 0)
);
const usedAmount = computed(() => Array.from(myBetMap.value.values()).reduce((sum, value) => sum + value, 0));
const selectedKey = computed(() => JSON.stringify([...selectedCandidates.value].sort((a, b) => a - b)));
@@ -150,10 +144,7 @@ const getErrorMessage = (error: unknown): string => {
return typeof error === 'string' ? error : '요청을 처리하지 못했습니다.';
};
const parseYearMonth = (yearMonth: number): [number, number] => [
Math.floor(yearMonth / 12),
(yearMonth % 12) + 1,
];
const parseYearMonth = (yearMonth: number): [number, number] => [Math.floor(yearMonth / 12), (yearMonth % 12) + 1];
const readSelection = (value: string): number[] => {
try {
@@ -171,8 +162,7 @@ const selectionLabel = (value: string): string =>
.map((index) => candidates.value[index]?.title ?? '-')
.join(', ');
const isListOpen = (item: BettingListItem): boolean =>
!item.finished && listYearMonth.value <= item.closeYearMonth;
const isListOpen = (item: BettingListItem): boolean => !item.finished && listYearMonth.value <= item.closeYearMonth;
const isDetailOpen = computed(() =>
Boolean(info.value && !info.value.finished && currentYearMonth.value <= info.value.closeYearMonth)
@@ -192,19 +182,72 @@ const rowColor = (key: string): string => {
return matched === 0 ? 'red' : matched < info.value.selectCnt ? 'yellow' : 'green';
};
const expectedMultiplier = (key: string, betAmount: number): string => {
if (betAmount <= 0) {
return '0.0';
const rewardByMatch = computed(() => {
const selectCount = info.value?.selectCnt ?? 0;
const rewards = new Array<number>(selectCount + 1).fill(0);
if (selectCount <= 0) {
return rewards;
}
const amountByMatch = new Map<number, number>();
for (const [key, betAmount] of detailRows.value) {
const matched = matchCount(key);
amountByMatch.set(matched, (amountByMatch.get(matched) ?? 0) + betAmount);
}
if (selectCount === 1 || info.value?.isExclusive) {
rewards[selectCount] = totalAmount.value;
return rewards;
}
let remainingReward = totalAmount.value;
let accumulatedReward = 0;
let nextReward = totalAmount.value;
for (let matched = selectCount; matched > 0; matched -= 1) {
nextReward /= 2;
accumulatedReward += nextReward;
if (!amountByMatch.has(matched)) {
continue;
}
rewards[matched] = accumulatedReward;
remainingReward -= accumulatedReward;
accumulatedReward = 0;
}
for (let matched = selectCount; matched >= 0; matched -= 1) {
if (!amountByMatch.has(matched)) {
continue;
}
rewards[matched] += remainingReward;
break;
}
return rewards;
});
const expectedReward = (key: string): number => {
if (!info.value?.finished) {
const reward = info.value?.isExclusive || info.value?.selectCnt === 1 ? totalAmount.value : totalAmount.value / 2;
return (reward / betAmount).toFixed(1);
return info.value?.isExclusive || info.value?.selectCnt === 1 ? totalAmount.value : totalAmount.value / 2;
}
const matched = matchCount(key);
const matchedAmount = detailRows.value
.filter(([candidateKey]) => matchCount(candidateKey) === matched)
.reduce((sum, [, value]) => sum + value, 0);
return matchedAmount > 0 ? (totalAmount.value / matchedAmount).toFixed(1) : '0.0';
return rewardByMatch.value[matchCount(key)] ?? 0;
};
const rewardDivisor = (key: string, betAmount: number): number =>
info.value?.finished
? detailRows.value
.filter(([candidateKey]) => matchCount(candidateKey) === matchCount(key))
.reduce((sum, [, value]) => sum + value, 0)
: betAmount;
const expectedMultiplier = (key: string, betAmount: number): string => {
const divisor = rewardDivisor(key, betAmount);
return divisor > 0 ? (expectedReward(key) / divisor).toFixed(1) : '0.0';
};
const myExpectedReward = (key: string, betAmount: number): string => {
const myAmount = myBetMap.value.get(key);
if (myAmount === undefined) {
return '';
}
const divisor = rewardDivisor(key, betAmount);
const reward = divisor > 0 ? (myAmount * expectedReward(key)) / divisor : 0;
return `(${myAmount.toLocaleString('ko-KR')} -> ${reward.toFixed(1)})`;
};
const loadList = async () => {
@@ -295,7 +338,7 @@ onMounted(() => {
<main id="nation-betting-container" class="nation-betting-page legacy-bg0">
<header class="legacy-top-bar">
<RouterLink class="legacy-nav-button" to="/">돌아가기</RouterLink>
<button class="legacy-nav-button" type="button" :disabled="loadingList" @click="loadList">갱신</button>
<div></div>
<h1>국가 베팅장</h1>
<div></div>
<div></div>
@@ -309,32 +352,37 @@ onMounted(() => {
{{ info.name }}
<span v-if="info.finished">(종료)</span>
<span v-else-if="currentYearMonth <= info.closeYearMonth">
({{ parseYearMonth(info.closeYearMonth)[0] }}
{{ parseYearMonth(info.closeYearMonth)[1] }}월까지)
({{ parseYearMonth(info.closeYearMonth)[0] }} {{ parseYearMonth(info.closeYearMonth)[1] }}월까지)
</span>
<span v-else>(베팅 마감)</span>
(총액: {{ totalAmount.toLocaleString('ko-KR') }})
</div>
<div class="betting-candidates">
<button
<div
v-for="(candidate, index) in candidates"
:key="`${info.id}-${index}`"
type="button"
class="betting-candidate"
:class="{ picked: selectedCandidates.includes(index) || (info.finished && winner.has(index)) }"
:disabled="!isDetailOpen"
@click="toggleCandidate(index)"
class="betting-candidate-cell"
>
<span class="candidate-title legacy-bg1">{{ candidate.title }}</span>
<span class="candidate-info">
<span v-for="line in candidate.info.split('<br>')" :key="line">{{ line }}</span>
</span>
<span class="candidate-rate">
선택율:
{{ (((candidateAmounts.get(index) ?? 0) / Math.max(1, pureAmount)) * 100).toFixed(1) }}%
</span>
</button>
<button
type="button"
class="betting-candidate"
:class="{
picked: selectedCandidates.includes(index) || (info.finished && winner.has(index)),
}"
:disabled="!isDetailOpen"
@click="toggleCandidate(index)"
>
<span class="candidate-title legacy-bg1">{{ candidate.title }}</span>
<span class="candidate-info">
<span v-for="line in candidate.info.split('<br>')" :key="line">{{ line }}</span>
</span>
<span class="candidate-rate">
선택율:
{{ (((candidateAmounts.get(index) ?? 0) / Math.max(1, pureAmount)) * 100).toFixed(1) }}%
</span>
</button>
</div>
</div>
<form v-if="isDetailOpen" class="betting-form" @submit.prevent="submitBet">
@@ -361,7 +409,7 @@ onMounted(() => {
{{ selectionLabel(key) }}
</div>
<div>{{ betAmount.toLocaleString('ko-KR') }}</div>
<div>{{ myBetMap.get(key)?.toLocaleString('ko-KR') ?? '' }}</div>
<div>{{ myExpectedReward(key, betAmount) }}</div>
<div>{{ expectedMultiplier(key, betAmount) }}</div>
</div>
</div>
@@ -382,8 +430,7 @@ onMounted(() => {
{{ item.name }}
<span v-if="item.finished">(종료)</span>
<span v-else-if="isListOpen(item)">
({{ parseYearMonth(item.closeYearMonth)[0] }}
{{ parseYearMonth(item.closeYearMonth)[1] }}월까지)
({{ parseYearMonth(item.closeYearMonth)[0] }} {{ parseYearMonth(item.closeYearMonth)[1] }}월까지)
</span>
<span v-else>(베팅 마감)</span>
</button>
@@ -400,12 +447,11 @@ onMounted(() => {
.nation-betting-page {
position: relative;
width: 500px;
min-height: 100vh;
margin: 0 auto;
color: #fff;
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic';
font-size: 14px;
line-height: 1.3;
line-height: 1.5;
overflow-x: hidden;
}
@@ -458,19 +504,32 @@ onMounted(() => {
}
.section-title {
min-height: 22px;
min-height: 21px;
text-align: center;
line-height: 22px;
line-height: 21px;
}
.betting-candidates {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 4px;
padding: 4px;
display: flex;
flex-wrap: wrap;
margin-top: -3.5px;
margin-right: -1.75px;
margin-left: -1.75px;
}
.betting-candidate-cell {
flex: 0 0 auto;
width: 33.33333333%;
max-width: 100%;
padding-right: 1.75px;
padding-left: 1.75px;
margin-top: 3.5px;
}
.betting-candidate {
width: 100%;
height: 100%;
min-height: 143px;
min-width: 0;
padding: 0;
border: 1px solid gray;
@@ -530,7 +589,7 @@ onMounted(() => {
.betting-form input {
grid-column: span 4;
min-width: 0;
height: 30px;
height: 35.5px;
border: 1px solid #777;
background: #ddd;
color: #303030;
@@ -538,10 +597,11 @@ onMounted(() => {
.betting-form button {
grid-column: span 2;
height: 35.5px;
}
.payout-table {
margin-top: 6px;
margin-top: 0;
}
.payout-row {
@@ -551,7 +611,7 @@ onMounted(() => {
.payout-row > div {
min-width: 0;
padding: 2px 4px;
padding: 0 4px;
}
.payout-row > div:not(:first-child) {
@@ -572,7 +632,7 @@ onMounted(() => {
.betting-item {
display: block;
width: 100%;
width: auto;
margin: 0.25em;
border: 0;
background: transparent;
@@ -595,6 +655,7 @@ onMounted(() => {
.betting-footer .legacy-nav-button {
width: 90px;
height: 35.5px;
}
.betting-notice,
@@ -602,6 +663,15 @@ onMounted(() => {
padding: 6px 10px;
}
.betting-notice {
position: fixed;
top: 8px;
right: 8px;
z-index: 20;
width: min(320px, calc(100vw - 16px));
background: #303030;
}
.betting-notice.error {
border: 1px solid #9b4848;
color: #ffd0d0;
@@ -617,8 +687,9 @@ onMounted(() => {
width: 1000px;
}
.betting-candidates {
grid-template-columns: repeat(6, minmax(0, 1fr));
.betting-candidate-cell {
/* Legacy Bootstrap switches .col-4 to .col-lg-2 at 940px. */
width: 16.66666667%;
}
.betting-form {
+693 -691
View File
@@ -1,78 +1,61 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import { trpc } from '../utils/trpc';
import { npcPriorityHelp } from '../utils/npcPriorityHelp';
import { trpc } from '../utils/trpc';
type NpcPolicyResponse = Awaited<ReturnType<typeof trpc.npc.getPolicy.query>>;
type NationPolicy = NpcPolicyResponse['currentNationPolicy'];
type PolicyKey = keyof NationPolicy;
type PolicyField = {
key: PolicyKey;
type NumericPolicyKey = Exclude<keyof NationPolicy, 'CombatForce' | 'SupportForce' | 'DevelopForce'>;
type PrioritySectionKey = 'nation' | 'general';
type PriorityBucket = 'active' | 'inactive';
interface PolicyField {
key: NumericPolicyKey;
label: string;
step: number;
description: string;
hint?: string;
percent?: boolean;
};
min?: number;
max?: number;
}
type PolicySection = {
title: string;
fields: PolicyField[];
};
const NUMERIC_POLICY_KEYS = [
'reqNationGold',
'reqNationRice',
'reqHumanWarUrgentGold',
'reqHumanWarUrgentRice',
'reqHumanWarRecommandGold',
'reqHumanWarRecommandRice',
'reqHumanDevelGold',
'reqHumanDevelRice',
'reqNPCWarGold',
'reqNPCWarRice',
'reqNPCDevelGold',
'reqNPCDevelRice',
'minimumResourceActionAmount',
'maximumResourceActionAmount',
'minNPCWarLeadership',
'minWarCrew',
'minNPCRecruitCityPopulation',
'safeRecruitCityPopulationRatio',
'properWarTrainAtmos',
'cureThreshold',
] as const;
type NumericPolicyKey = (typeof NUMERIC_POLICY_KEYS)[number];
type PrioritySectionKey = 'nation' | 'general';
type PriorityListState = {
interface PriorityListState {
active: string[];
inactive: string[];
available: string[];
};
}
interface PriorityPanel {
key: PrioritySectionKey;
title: string;
description: string[];
setter: NpcPolicyResponse['lastSetters']['nation'];
state: PriorityListState;
}
interface DragState {
section: PrioritySectionKey;
bucket: PriorityBucket;
index: number;
}
const loading = ref(false);
const error = ref<string | null>(null);
const notice = ref<string | null>(null);
const data = ref<NpcPolicyResponse | null>(null);
const policyDraft = ref<NationPolicy | null>(null);
const lastSavedPolicy = ref<NationPolicy | null>(null);
const nationPriority = ref<PriorityListState | null>(null);
const generalPriority = ref<PriorityListState | null>(null);
const lastSavedNationPriority = ref<string[]>([]);
const lastSavedGeneralPriority = ref<string[]>([]);
const dragState = ref<DragState | null>(null);
const resolveErrorMessage = (value: unknown): string => {
if (value instanceof Error) {
return value.message;
}
if (typeof value === 'string') {
return value;
}
if (value instanceof Error) return value.message;
if (typeof value === 'string') return value;
return 'unknown_error';
};
@@ -85,851 +68,870 @@ const clonePolicy = (source: NationPolicy): NationPolicy => ({
const assignPriorityState = (active: string[], available: string[]): PriorityListState => {
const activeSet = new Set(active);
const inactive = available.filter((item) => !activeSet.has(item));
return {
active: [...active],
inactive,
inactive: available.filter((item) => !activeSet.has(item)),
available: [...available],
};
};
const loadPolicy = async () => {
if (loading.value) {
return;
}
if (loading.value) return;
loading.value = true;
error.value = null;
try {
data.value = await trpc.npc.getPolicy.query();
} catch (err) {
error.value = resolveErrorMessage(err);
} catch (caught) {
error.value = resolveErrorMessage(caught);
} finally {
loading.value = false;
}
};
onMounted(() => {
void loadPolicy();
watch(data, (value) => {
if (!value) return;
policyDraft.value = clonePolicy(value.currentNationPolicy);
lastSavedPolicy.value = clonePolicy(value.currentNationPolicy);
nationPriority.value = assignPriorityState(value.currentNationPriority, value.availableNationPriorityItems);
generalPriority.value = assignPriorityState(
value.currentGeneralActionPriority,
value.availableGeneralActionPriorityItems
);
lastSavedNationPriority.value = [...value.currentNationPriority];
lastSavedGeneralPriority.value = [...value.currentGeneralActionPriority];
});
watch(
() => data.value,
(value) => {
if (!value) {
return;
}
policyDraft.value = clonePolicy(value.currentNationPolicy);
lastSavedPolicy.value = clonePolicy(value.currentNationPolicy);
nationPriority.value = assignPriorityState(value.currentNationPriority, value.availableNationPriorityItems);
generalPriority.value = assignPriorityState(
value.currentGeneralActionPriority,
value.availableGeneralActionPriorityItems
);
lastSavedNationPriority.value = [...value.currentNationPriority];
lastSavedGeneralPriority.value = [...value.currentGeneralActionPriority];
}
);
onMounted(() => void loadPolicy());
const formatNumber = (value: number): string => new Intl.NumberFormat('ko-KR').format(Math.round(value));
const calcPolicyValue = (key: NumericPolicyKey): number => {
if (!data.value || !policyDraft.value) {
return 0;
}
if (!data.value || !policyDraft.value) return 0;
const value = policyDraft.value[key];
if (value === 0) {
return data.value.zeroPolicy[key];
}
return value;
return value === 0 ? data.value.zeroPolicy[key] : value;
};
const safeRecruitPercent = computed({
get: () => (policyDraft.value?.safeRecruitCityPopulationRatio ?? 0) * 100,
set: (value: number) => {
if (!policyDraft.value) {
return;
}
policyDraft.value.safeRecruitCityPopulationRatio = value / 100;
if (policyDraft.value) policyDraft.value.safeRecruitCityPopulationRatio = value / 100;
},
});
const policySections = computed<PolicySection[]>(() => {
if (!data.value) {
return [];
}
const policyFields = computed<PolicyField[]>(() => {
if (!data.value) return [];
const statMax = data.value.defaultStatMax;
const statNpcMax = data.value.defaultStatNpcMax;
return [
{
title: '국가 재정',
fields: [
{
key: 'reqNationGold',
label: '국가 권장 금',
step: 100,
description: '이보다 많으면 포상, 적으면 몰수/헌납합니다.(긴급포상 제외)',
},
{
key: 'reqNationRice',
label: '국가 권장 쌀',
step: 100,
description: '이보다 많으면 포상, 적으면 몰수/헌납합니다.(긴급포상 제외)',
},
],
key: 'reqNationGold',
label: '국가 권장 금',
step: 100,
description: '이보다 많으면 포상, 적으면 몰수/헌납합니다.(긴급포상 제외)',
},
{
title: '유저 전투장',
fields: [
{
key: 'reqHumanWarUrgentGold',
label: '긴급포상 금',
step: 100,
description:
'유저장긴급포상시 이보다 금이 적은 장수에게 포상합니다.',
hint: `0이면 보병 6회 징병(${formatNumber(statMax * 100 * 6)}) 가능한 금을 기준으로 하며, 현재 ${formatNumber(
data.value.zeroPolicy.reqHumanWarUrgentGold
)}입니다.`,
},
{
key: 'reqHumanWarUrgentRice',
label: '긴급포상 쌀',
step: 100,
description:
'유저장긴급포상시 이보다 쌀이 적은 장수에게 포상합니다.',
hint: `0이면 기본 병종으로 ${formatNumber(statMax * 100 * 6)}명 사살 가능한 쌀을 기준으로 하며, 현재 ${formatNumber(
data.value.zeroPolicy.reqHumanWarUrgentRice
)}입니다.`,
},
{
key: 'reqHumanWarRecommandGold',
label: '권장 금',
step: 100,
description: '유저전투장에게 주는 금입니다. 이보다 적으면 포상합니다.',
hint: `0이면 긴급포상 금의 2배를 기준으로 하며, 현재 ${formatNumber(
calcPolicyValue('reqHumanWarUrgentGold') * 2
)}입니다.`,
},
{
key: 'reqHumanWarRecommandRice',
label: '권장 쌀',
step: 100,
description: '유저전투장에게 주는 쌀입니다. 이보다 적으면 포상합니다.',
hint: `0이면 긴급포상 쌀의 2배를 기준으로 하며, 현재 ${formatNumber(
calcPolicyValue('reqHumanWarUrgentRice') * 2
)}입니다.`,
},
],
key: 'reqNationRice',
label: '국가 권장 쌀',
step: 100,
description: '이보다 많으면 포상, 적으면 몰수/헌납합니다.(긴급포상 제외)',
},
{
title: '유저 내정장',
fields: [
{
key: 'reqHumanDevelGold',
label: '권장 금',
step: 100,
description: '유저내정장에게 주는 금입니다. 이보다 적으면 포상합니다.',
},
{
key: 'reqHumanDevelRice',
label: '권장 쌀',
step: 100,
description: '유저내정장에게 주는 쌀입니다. 이보다 적으면 포상합니다.',
},
],
key: 'reqHumanWarUrgentGold',
label: '유저전투장 긴급포상 금',
step: 100,
description: '유저장긴급포상시 이보다 금이 적은 장수에게 포상합니다.',
hint: `0이면 보병 6회 징병(${formatNumber(statMax * 100)} * 6) 가능한 금을 기준으로 하며, 그 수치는 현재 ${formatNumber(data.value.zeroPolicy.reqHumanWarUrgentGold)}입니다.`,
},
{
title: 'NPC 전투장',
fields: [
{
key: 'reqNPCWarGold',
label: '권장 금',
step: 100,
description: 'NPC전투장에게 주는 금입니다. 이보다 적으면 포상합니다.',
hint: `0이면 기본 병종 4회(${formatNumber(statNpcMax * 100 * 4)}) 징병비를 기준으로 하며, 현재 ${formatNumber(
data.value.zeroPolicy.reqNPCWarGold
)}입니다.`,
},
{
key: 'reqNPCWarRice',
label: '권장 쌀',
step: 100,
description: 'NPC전투장에게 주는 쌀입니다. 이보다 적으면 포상합니다.',
hint: `0이면 기본 병종으로 ${formatNumber(statNpcMax * 100 * 4)}명 사살 가능한 쌀을 기준으로 하며, 현재 ${formatNumber(
data.value.zeroPolicy.reqNPCWarRice
)}입니다.`,
},
],
key: 'reqHumanWarUrgentRice',
label: '유저전투장 긴급포상 쌀',
step: 100,
description: '유저장긴급포상시 이보다 쌀이 적은 장수에게 포상합니다.',
hint: `0이면 기본 병종으로 ${formatNumber(statMax * 100)} * 6명 사살 가능한 쌀을 기준으로 하며, 그 수치는 현재 ${formatNumber(data.value.zeroPolicy.reqHumanWarUrgentRice)}입니다.`,
},
{
title: 'NPC 내정장',
fields: [
{
key: 'reqNPCDevelGold',
label: '권장 금',
step: 100,
description: 'NPC내정장에게 주는 금입니다. 이보다 5배 더 많다면 헌납합니다.',
hint: `0이면 30턴 내정 가능한 금을 기준으로 하며, 현재 ${formatNumber(
data.value.zeroPolicy.reqNPCDevelGold
)}입니다.`,
},
{
key: 'reqNPCDevelRice',
label: '권장 쌀',
step: 100,
description: 'NPC내정장에게 주는 쌀입니다. 이보다 5배 더 많다면 헌납합니다.',
},
],
key: 'reqHumanWarRecommandGold',
label: '유저전투장 권장 금',
step: 100,
description: '유저전투장에게 주는 금입니다. 이보다 적으면 포상합니다.',
hint: `0이면 유저전투장 긴급포상 금의 2배를 기준으로 하며, 그 수치는 현재 ${formatNumber(calcPolicyValue('reqHumanWarUrgentGold') * 2)}입니다.`,
},
{
title: '자원 정책',
fields: [
{
key: 'minimumResourceActionAmount',
label: '포상/몰수/헌납 최소 단위',
step: 100,
description: '연산결과가 이 단위보다 적다면 수행하지 않습니다.',
},
{
key: 'maximumResourceActionAmount',
label: '포상/몰수/헌납 최대 단위',
step: 100,
description: '연산결과가 이 단위보다 크다면 이 값에 맞춥니다.',
},
],
key: 'reqHumanWarRecommandRice',
label: '유저전투장 권장 쌀',
step: 100,
description: '유저전투장에게 주는 쌀입니다. 이보다 적으면 포상합니다.',
hint: `0이면 유저전투장 긴급포상 쌀의 2배를 기준으로 하며, 그 수치는 현재 ${formatNumber(calcPolicyValue('reqHumanWarUrgentRice') * 2)}입니다.`,
},
{
title: '전투/징병 기준',
fields: [
{
key: 'minWarCrew',
label: '최소 전투 가능 병력 수',
step: 50,
description: '이보다 적을 때에는 징병을 시도합니다.',
},
{
key: 'minNPCRecruitCityPopulation',
label: 'NPC 최소 징병 가능 인구 수',
step: 100,
description:
'도시의 인구가 이보다 낮으면 NPC는 도시에서 징병하지 않고 후방 워프합니다.',
},
{
key: 'safeRecruitCityPopulationRatio',
label: '제자리 징병 허용 인구율(%)',
step: 0.5,
description:
'전쟁 시 후방 발령, 후방 워프의 기준 인구입니다. 이보다 많다면 충분하다고 판단합니다.',
percent: true,
},
{
key: 'minNPCWarLeadership',
label: 'NPC 전투 참여 통솔 기준',
step: 5,
description: '이 수치보다 같거나 높으면 NPC전투장으로 분류됩니다.',
},
],
key: 'reqHumanDevelGold',
label: '유저내정장 권장 금',
step: 100,
description: '유저내정장에게 주는 금입니다. 이보다 적으면 포상합니다.',
},
{
title: '상태 기준',
fields: [
{
key: 'properWarTrainAtmos',
label: '훈련/사기진작 목표치',
step: 5,
description: '훈련/사기진작 기준치입니다. 이보다 같거나 높으면 출병합니다.',
},
{
key: 'cureThreshold',
label: '요양 기준(%)',
step: 5,
description: '요양 기준입니다. 이보다 많이 부상을 입으면 요양합니다.',
},
],
key: 'reqHumanDevelRice',
label: '유저내정장 권장 쌀',
step: 100,
description: '유저내정장에게 주는 쌀입니다. 이보다 적으면 포상합니다.',
},
{
key: 'reqNPCWarGold',
label: 'NPC전투장 권장 금',
step: 100,
description: 'NPC전투장에게 주는 금입니다. 이보다 적으면 포상합니다.',
hint: `0이면 기본 병종 4회(${formatNumber(statNpcMax * 100)} * 4) 징병비를 기준으로 하며, 그 수치는 현재 ${formatNumber(data.value.zeroPolicy.reqNPCWarGold)}입니다.`,
},
{
key: 'reqNPCWarRice',
label: 'NPC전투장 권장 쌀',
step: 100,
description: 'NPC전투장에게 주는 쌀입니다. 이보다 적으면 포상합니다.',
hint: `0이면 기본 병종으로 ${formatNumber(statNpcMax * 100)} * 4명 사살 가능한 쌀을 기준으로 하며, 그 수치는 현재 ${formatNumber(data.value.zeroPolicy.reqNPCWarRice)}입니다.`,
},
{
key: 'reqNPCDevelGold',
label: 'NPC내정장 권장 금',
step: 100,
description: 'NPC내정장에게 주는 금입니다. 이보다 5배 더 많다면 헌납합니다.',
hint: `0이면 30턴 내정 가능한 금을 기준으로 하며, 그 수치는 현재 ${formatNumber(data.value.zeroPolicy.reqNPCDevelGold)}입니다.`,
},
{
key: 'reqNPCDevelRice',
label: 'NPC내정장 권장 쌀',
step: 100,
description: 'NPC내정장에게 주는 쌀입니다. 이보다 5배 더 많다면 헌납합니다.',
},
{
key: 'minimumResourceActionAmount',
label: '포상/몰수/헌납/삼/팜 최소 단위',
step: 100,
min: 100,
description: '연산결과가 이 단위보다 적다면 수행하지 않습니다.',
},
{
key: 'maximumResourceActionAmount',
label: '포상/몰수/헌납/삼/팜 최대 단위',
step: 100,
min: 100,
description: '연산결과가 이 단위보다 크다면, 이 값에 맞춥니다.',
},
{
key: 'minWarCrew',
label: '최소 전투 가능 병력 수',
step: 50,
description: '이보다 적을 때에는 징병을 시도합니다.',
},
{
key: 'minNPCRecruitCityPopulation',
label: 'NPC 최소 징병 가능 인구 수',
step: 100,
description: '도시의 인구가 이보다 낮으면 NPC는 도시에서 징병하지 않고 후방 워프합니다.',
hint: 'NPC의 최대 병력수보다 낮게 설정하면 제자리에서 정착장려를 합니다.',
},
{
key: 'safeRecruitCityPopulationRatio',
label: '제자리 징병 허용 인구율(%)',
step: 0.5,
min: 0,
max: 100,
percent: true,
description: '전쟁 시 후방 발령, 후방 워프의 기준 인구입니다. 이보다 많다면 충분하다고 판단합니다.',
hint: 'NPC의 최대 병력수보다 낮게 설정하면 제자리에서 정착장려를 합니다.',
},
{
key: 'minNPCWarLeadership',
label: 'NPC 전투 참여 통솔 기준',
step: 5,
description: '이 수치보다 같거나 높으면 NPC전투장으로 분류됩니다.',
},
{
key: 'properWarTrainAtmos',
label: '훈련/사기진작 목표치',
step: 5,
min: 20,
max: 100,
description: '훈련/사기진작 기준치입니다. 이보다 같거나 높으면 출병합니다.',
},
{
key: 'cureThreshold',
label: '요양 기준',
step: 5,
min: 10,
max: 100,
description: '요양 기준 %입니다. 이보다 많이 부상을 입으면 요양합니다.',
},
];
});
const canEdit = computed(() => (data.value?.permissionLevel ?? 0) >= 3);
const priorityPanels = computed<PriorityPanel[]>(() => {
if (!data.value || !nationPriority.value || !generalPriority.value) return [];
return [
{
key: 'nation',
title: 'NPC 사령턴 우선순위',
description: ['예턴이 없거나, 지정되어 있더라도 실패하면', '아래 순위에 따라 사령턴을 시도합니다.'],
setter: data.value.lastSetters.nation,
state: nationPriority.value,
},
{
key: 'general',
title: 'NPC 일반턴 우선순위',
description: [
'순위가 높은 것부터 시도합니다.',
'아무것도 실행할 수 없으면 물자조달이나 인재탐색을 합니다.',
],
setter: data.value.lastSetters.general,
state: generalPriority.value,
},
];
});
const resetPolicy = () => {
if (!data.value) {
return;
}
if (!window.confirm('초기 설정으로 되돌릴까요?')) {
return;
}
if (!data.value || !window.confirm('초기 설정으로 되돌릴까요?')) return;
policyDraft.value = clonePolicy(data.value.defaultNationPolicy);
notice.value = '서버 초깃값을 적용했습니다. 설정 버튼을 누르면 반영됩니다.';
};
const rollbackPolicy = () => {
if (!lastSavedPolicy.value) {
return;
}
if (!window.confirm('이전 설정으로 되돌릴까요?')) {
return;
}
if (!lastSavedPolicy.value || !window.confirm('이전 설정으로 되돌릴까요?')) return;
policyDraft.value = clonePolicy(lastSavedPolicy.value);
notice.value = '이전 설정으로 되돌렸습니다.';
};
const submitPolicy = async () => {
if (!policyDraft.value) {
return;
}
if (!window.confirm('저장할까요?')) {
return;
}
if (!policyDraft.value || !window.confirm('저장할까요?')) return;
error.value = null;
notice.value = null;
try {
await trpc.npc.setNationPolicy.mutate(policyDraft.value);
await loadPolicy();
} catch (err) {
error.value = resolveErrorMessage(err);
lastSavedPolicy.value = clonePolicy(policyDraft.value);
notice.value = 'NPC 정책이 반영되었습니다.';
} catch (caught) {
error.value = `설정하지 못했습니다: ${resolveErrorMessage(caught)}`;
}
};
const moveItem = (list: string[], from: number, to: number) => {
if (to < 0 || to >= list.length) {
return;
}
const [item] = list.splice(from, 1);
list.splice(to, 0, item);
};
const insertByOrder = (list: string[], item: string, orderMap: Map<string, number>) => {
const targetOrder = orderMap.get(item) ?? Number.MAX_SAFE_INTEGER;
const index = list.findIndex((entry) => (orderMap.get(entry) ?? Number.MAX_SAFE_INTEGER) > targetOrder);
if (index === -1) {
list.push(item);
} else {
list.splice(index, 0, item);
}
};
const togglePriority = (section: PrioritySectionKey, item: string, enable: boolean) => {
const target = section === 'nation' ? nationPriority.value : generalPriority.value;
if (!target) {
return;
}
if (enable) {
const index = target.inactive.indexOf(item);
if (index >= 0) {
target.inactive.splice(index, 1);
const orderMap = new Map(target.available.map((entry, idx) => [entry, idx]));
insertByOrder(target.active, item, orderMap);
}
return;
}
const index = target.active.indexOf(item);
if (index >= 0) {
target.active.splice(index, 1);
const orderMap = new Map(target.available.map((entry, idx) => [entry, idx]));
insertByOrder(target.inactive, item, orderMap);
}
};
const reorderPriority = (section: PrioritySectionKey, index: number, direction: number) => {
const target = section === 'nation' ? nationPriority.value : generalPriority.value;
if (!target) {
return;
}
moveItem(target.active, index, index + direction);
};
const resetPriority = (section: PrioritySectionKey) => {
if (!data.value) {
return;
}
if (!window.confirm('초기 설정으로 되돌릴까요?')) {
return;
}
if (!data.value || !window.confirm('초기 설정으로 되돌릴까요?')) return;
if (section === 'nation') {
nationPriority.value = assignPriorityState(data.value.defaultNationPriority, data.value.availableNationPriorityItems);
nationPriority.value = assignPriorityState(
data.value.defaultNationPriority,
data.value.availableNationPriorityItems
);
} else {
generalPriority.value = assignPriorityState(
data.value.defaultGeneralActionPriority,
data.value.availableGeneralActionPriorityItems
);
}
notice.value = '서버 초깃값을 적용했습니다. 설정 버튼을 누르면 반영됩니다.';
};
const rollbackPriority = (section: PrioritySectionKey) => {
if (!window.confirm('이전 설정으로 되돌릴까요?')) {
return;
}
if (section === 'nation' && data.value) {
nationPriority.value = assignPriorityState(lastSavedNationPriority.value, data.value.availableNationPriorityItems);
}
if (section === 'general' && data.value) {
if (!data.value || !window.confirm('이전 설정으로 되돌릴까요?')) return;
if (section === 'nation') {
nationPriority.value = assignPriorityState(
lastSavedNationPriority.value,
data.value.availableNationPriorityItems
);
} else {
generalPriority.value = assignPriorityState(
lastSavedGeneralPriority.value,
data.value.availableGeneralActionPriorityItems
);
}
notice.value = '이전 설정으로 되돌렸습니다.';
};
const submitPriority = async (section: PrioritySectionKey) => {
if (!data.value) {
return;
}
if (!window.confirm('저장할까요?')) {
return;
}
const state = section === 'nation' ? nationPriority.value : generalPriority.value;
if (!state || !window.confirm('저장할까요?')) return;
error.value = null;
notice.value = null;
try {
if (section === 'nation' && nationPriority.value) {
await trpc.npc.setNationPriority.mutate(nationPriority.value.active);
if (section === 'nation') {
await trpc.npc.setNationPriority.mutate(state.active);
lastSavedNationPriority.value = [...state.active];
} else {
await trpc.npc.setGeneralPriority.mutate(state.active);
lastSavedGeneralPriority.value = [...state.active];
}
if (section === 'general' && generalPriority.value) {
await trpc.npc.setGeneralPriority.mutate(generalPriority.value.active);
}
await loadPolicy();
} catch (err) {
error.value = resolveErrorMessage(err);
notice.value = 'NPC 정책이 반영되었습니다.';
} catch (caught) {
error.value = `설정하지 못했습니다: ${resolveErrorMessage(caught)}`;
}
};
const startDrag = (event: DragEvent, section: PrioritySectionKey, bucket: PriorityBucket, index: number) => {
dragState.value = { section, bucket, index };
event.dataTransfer?.setData('text/plain', `${section}:${bucket}:${index}`);
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
};
const dropPriority = (event: DragEvent, section: PrioritySectionKey, bucket: PriorityBucket, targetIndex?: number) => {
event.preventDefault();
const source = dragState.value;
const state = section === 'nation' ? nationPriority.value : generalPriority.value;
if (!source || source.section !== section || !state) return;
const sourceList = state[source.bucket];
const targetList = state[bucket];
const [item] = sourceList.splice(source.index, 1);
if (!item) return;
let index = targetIndex ?? targetList.length;
if (sourceList === targetList && source.index < index) index -= 1;
targetList.splice(Math.max(0, Math.min(index, targetList.length)), 0, item);
dragState.value = null;
};
</script>
<template>
<main class="npc-page">
<header class="page-header">
<div>
<h1 class="page-title">NPC 정책</h1>
<p class="page-subtitle">사령/일반 AI 우선순위 자원 기준</p>
<main id="npc-policy-page" class="npc-page">
<nav class="top-back-bar legacy-bg0">
<RouterLink class="back-button" to="/">돌아가기</RouterLink>
<strong>NPC 정책</strong>
</nav>
<div v-if="loading && !data" class="page-state legacy-bg0">불러오는 중...</div>
<div v-else-if="!data" class="page-state error-state legacy-bg0" role="alert">
{{ error ?? 'NPC 정책을 불러오지 못했습니다.' }}
<button type="button" @click="loadPolicy">다시 시도</button>
</div>
<section v-else-if="policyDraft" id="container" class="policy-container legacy-bg0">
<div class="section_bar legacy-bg1">국가 정책</div>
<div class="setter">
최근 설정: {{ data.lastSetters.policy.setter ?? '-없음-' }} ({{
data.lastSetters.policy.date ?? '설정 기록 없음'
}})
</div>
<div class="header-actions">
<RouterLink class="ghost" to="/">메인</RouterLink>
<button class="ghost" @click="loadPolicy">새로고침</button>
<div v-if="error" class="feedback error-feedback" role="alert">{{ error }}</div>
<div v-if="notice" class="feedback notice-feedback" role="status">{{ notice }}</div>
<div class="form_list">
<div v-for="field in policyFields" :key="field.key" class="policy-field">
<div class="field-row">
<label :for="`npc-policy-${field.key}`">{{ field.label }}</label>
<input
v-if="field.percent"
:id="`npc-policy-${field.key}`"
v-model.number="safeRecruitPercent"
type="number"
:step="field.step"
:min="field.min"
:max="field.max"
/>
<input
v-else
:id="`npc-policy-${field.key}`"
v-model.number="policyDraft[field.key]"
type="number"
:step="field.step"
:min="field.min"
:max="field.max"
/>
</div>
<p>{{ field.description }}</p>
<p v-if="field.hint">{{ field.hint }}</p>
</div>
</div>
</header>
<div v-if="error" class="error">{{ error }}</div>
<div class="work-in-progress">
전투 부대는 작업중입니다(json양식: {부대번호:[시작도시번호(아국),도착도시번호(적국)],...})
<br />후방 징병 부대는 작업중입니다(json양식: [부대번호,...]) <br />내정 부대는 작업중입니다(json양식:
[부대번호,...])
<input type="hidden" :value="JSON.stringify(policyDraft.CombatForce)" />
<input type="hidden" :value="JSON.stringify(policyDraft.SupportForce)" />
<input type="hidden" :value="JSON.stringify(policyDraft.DevelopForce)" />
</div>
<section v-if="loading && !data">
<PanelCard title="NPC 정책 로딩">
<SkeletonLines :lines="6" />
</PanelCard>
</section>
<section v-else-if="data && policyDraft" class="npc-layout">
<PanelCard title="국가 정책" subtitle="NPC 자원 기준과 전투 판단 기준">
<div class="setter">
최근 설정: {{ data.lastSetters.policy.setter ?? '-없음-' }} ({{
data.lastSetters.policy.date ?? '설정 기록 없음'
}})
<div class="control_bar">
<div class="button-group">
<button class="reset_btn" type="button" @click="resetPolicy">초깃값으로</button>
<button class="revert_btn" type="button" @click="rollbackPolicy">이전값으로</button>
</div>
<div v-if="!canEdit" class="readonly-note">권한이 부족하여 읽기 전용으로 표시됩니다.</div>
<div v-for="section in policySections" :key="section.title" class="policy-section">
<h3 class="section-title">{{ section.title }}</h3>
<div class="policy-grid">
<div
v-for="field in section.fields"
:key="field.key"
class="policy-field"
>
<label class="field-label">{{ field.label }}</label>
<input
v-if="field.percent"
v-model.number="safeRecruitPercent"
type="number"
class="field-input"
:step="field.step"
min="0"
max="100"
:disabled="!canEdit"
/>
<input
v-else
v-model.number="policyDraft[field.key as PolicyKey]"
type="number"
class="field-input"
:step="field.step"
min="0"
:disabled="!canEdit"
/>
<p class="field-desc">{{ field.description }}</p>
<p v-if="field.hint" class="field-hint">{{ field.hint }}</p>
</div>
</div>
</div>
<div class="control-bar">
<div class="btn-group">
<button class="ghost" :disabled="!canEdit" @click="resetPolicy">초깃값으로</button>
<button class="ghost" :disabled="!canEdit" @click="rollbackPolicy">이전값으로</button>
</div>
<button class="primary" :disabled="!canEdit" @click="submitPolicy">설정</button>
</div>
</PanelCard>
<button class="submit_btn" type="button" @click="submitPolicy">설정</button>
</div>
<div class="priority-grid">
<PanelCard title="NPC 사령턴 우선순위" subtitle="예턴 실패 시 우선순위대로 실행">
<div class="setter">
최근 설정: {{ data.lastSetters.nation.setter ?? '-없음-' }} ({{
data.lastSetters.nation.date ?? '설정 기록 없음'
}})
<div class="priority-sections">
<section
v-for="panel in priorityPanels"
:key="panel.key"
:class="['priority-panel', panel.key === 'nation' ? 'half_section_left' : 'half_section_right']"
>
<div class="section_bar legacy-bg1">{{ panel.title }}</div>
<div class="priority-meta">
<small>
최근 설정: {{ panel.setter.setter ?? '-없음-' }} ({{
panel.setter.date ?? '설정 기록 없음'
}})
</small>
</div>
<div class="priority-description">
<small>{{ panel.description[0] }}<br />{{ panel.description[1] }}</small>
</div>
<div v-if="!canEdit" class="readonly-note">권한이 부족하여 읽기 전용으로 표시됩니다.</div>
<div class="priority-columns">
<div class="priority-column">
<div class="column-title">비활성</div>
<div class="priority-list">
<div class="sub_bar legacy-bg2">비활성</div>
<div
class="priority-list"
@dragover.prevent
@drop="dropPriority($event, panel.key, 'inactive')"
>
<div class="inactive-header">&lt;비활성화 항목들&gt;</div>
<div
v-for="item in nationPriority?.inactive ?? []"
v-for="(item, index) in panel.state.inactive"
:key="item"
class="priority-item"
draggable="true"
@dragstart="startDrag($event, panel.key, 'inactive', index)"
@dragover.prevent
@drop.stop="dropPriority($event, panel.key, 'inactive', index)"
>
<span class="priority-name">{{ item }}</span>
<span
class="priority-help"
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
>
?
</span>
<button class="ghost" :disabled="!canEdit" @click="togglePriority('nation', item, true)">
활성
</button>
<div class="priority_info">
<span class="drag-handle"></span>
<span>{{ item }}</span>
<button
class="help-button"
type="button"
:aria-label="`${item} 설명`"
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
>
?
</button>
</div>
</div>
</div>
</div>
<div class="priority-column">
<div class="column-title">활성</div>
<div class="priority-list">
<div class="sub_bar legacy-bg2">활성</div>
<div
class="priority-list"
@dragover.prevent
@drop="dropPriority($event, panel.key, 'active')"
>
<div
v-for="(item, idx) in nationPriority?.active ?? []"
:key="item"
v-for="(item, index) in panel.state.active"
:key="`${item}-${index}`"
class="priority-item"
draggable="true"
@dragstart="startDrag($event, panel.key, 'active', index)"
@dragover.prevent
@drop.stop="dropPriority($event, panel.key, 'active', index)"
>
<div class="priority-main">
<span class="priority-name">{{ item }}</span>
<span
class="priority-help"
<div class="priority_info">
<span class="drag-handle"></span>
<span>{{ item }}</span>
<button
class="help-button"
type="button"
:aria-label="`${item} 설명`"
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
>
?
</span>
</div>
<div class="priority-actions">
<button class="ghost" :disabled="!canEdit" @click="reorderPriority('nation', idx, -1)"></button>
<button class="ghost" :disabled="!canEdit" @click="reorderPriority('nation', idx, 1)">아래</button>
<button class="ghost" :disabled="!canEdit" @click="togglePriority('nation', item, false)">
비활성
</button>
</div>
</div>
</div>
</div>
</div>
<div class="control-bar">
<div class="btn-group">
<button class="ghost" :disabled="!canEdit" @click="resetPriority('nation')">초깃값으로</button>
<button class="ghost" :disabled="!canEdit" @click="rollbackPriority('nation')">이전값으로</button>
<div class="control_bar priority-control">
<div class="button-group">
<button class="reset_btn" type="button" @click="resetPriority(panel.key)">
초깃값으로
</button>
<button class="revert_btn" type="button" @click="rollbackPriority(panel.key)">
이전값으로
</button>
</div>
<button class="primary" :disabled="!canEdit" @click="submitPriority('nation')">설정</button>
<button class="submit_btn" type="button" @click="submitPriority(panel.key)">설정</button>
</div>
</PanelCard>
<PanelCard title="NPC 일반턴 우선순위" subtitle="순위가 높은 것부터 시도">
<div class="setter">
최근 설정: {{ data.lastSetters.general.setter ?? '-없음-' }} ({{
data.lastSetters.general.date ?? '설정 기록 없음'
}})
</div>
<div v-if="!canEdit" class="readonly-note">권한이 부족하여 읽기 전용으로 표시됩니다.</div>
<div class="priority-columns">
<div class="priority-column">
<div class="column-title">비활성</div>
<div class="priority-list">
<div
v-for="item in generalPriority?.inactive ?? []"
:key="item"
class="priority-item"
>
<span class="priority-name">{{ item }}</span>
<span
class="priority-help"
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
>
?
</span>
<button class="ghost" :disabled="!canEdit" @click="togglePriority('general', item, true)">
활성
</button>
</div>
</div>
</div>
<div class="priority-column">
<div class="column-title">활성</div>
<div class="priority-list">
<div
v-for="(item, idx) in generalPriority?.active ?? []"
:key="item"
class="priority-item"
>
<div class="priority-main">
<span class="priority-name">{{ item }}</span>
<span
class="priority-help"
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
>
?
</span>
</div>
<div class="priority-actions">
<button class="ghost" :disabled="!canEdit" @click="reorderPriority('general', idx, -1)"></button>
<button class="ghost" :disabled="!canEdit" @click="reorderPriority('general', idx, 1)">아래</button>
<button class="ghost" :disabled="!canEdit" @click="togglePriority('general', item, false)">
비활성
</button>
</div>
</div>
</div>
</div>
</div>
<div class="control-bar">
<div class="btn-group">
<button class="ghost" :disabled="!canEdit" @click="resetPriority('general')">초깃값으로</button>
<button class="ghost" :disabled="!canEdit" @click="rollbackPriority('general')">이전값으로</button>
</div>
<button class="primary" :disabled="!canEdit" @click="submitPriority('general')">설정</button>
</div>
</PanelCard>
</section>
</div>
</section>
</main>
</template>
<style scoped>
:global(html),
:global(body),
:global(#app) {
min-width: 500px;
margin: 0;
background: #000;
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-size: 14px;
line-height: 21px;
}
:global(body:has(#npc-policy-page)) {
min-width: 500px;
line-height: 21px;
}
.npc-page {
min-height: 100vh;
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
color: #fff;
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-size: 14px;
line-height: 21px;
}
.page-header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid rgba(201, 164, 90, 0.4);
padding-bottom: 12px;
.legacy-bg0 {
background-image: url('/image/game/back_walnut.jpg');
}
.page-title {
font-size: 1.6rem;
font-weight: 600;
.legacy-bg1 {
background-image: url('/image/game/back_green.jpg');
}
.page-subtitle {
font-size: 0.85rem;
color: rgba(232, 221, 196, 0.7);
.legacy-bg2 {
background-image: url('/image/game/back_blue.jpg');
}
.header-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
.top-back-bar {
position: relative;
height: 32px;
max-width: 1000px;
margin: 0 auto;
text-align: center;
box-sizing: border-box;
}
.ghost {
border: 1px solid rgba(201, 164, 90, 0.4);
padding: 6px 12px;
font-size: 0.8rem;
cursor: pointer;
background: rgba(16, 16, 16, 0.6);
color: inherit;
.top-back-bar strong {
font-size: 24px;
line-height: 32px;
font-weight: 400;
}
.primary {
border: 1px solid rgba(201, 164, 90, 0.6);
padding: 6px 12px;
font-size: 0.8rem;
cursor: pointer;
background: rgba(201, 164, 90, 0.2);
color: inherit;
.back-button {
position: absolute;
inset: 0 auto 0 0;
width: 88px;
color: #fff;
background: #087f45;
border: 1px solid #0a9960;
border-radius: 0 0 4px;
font-weight: 700;
line-height: 30px;
text-decoration: none;
}
.error {
color: #f5b7b1;
font-size: 0.85rem;
.back-button:hover,
.back-button:focus-visible {
background: #0a9960;
outline: 2px solid #fff;
outline-offset: -2px;
}
.npc-layout {
display: flex;
flex-direction: column;
gap: 16px;
.policy-container,
.page-state {
width: 100%;
max-width: 1000px;
margin: 0 auto;
border: 1px solid #888;
box-sizing: border-box;
}
.setter {
font-size: 0.75rem;
color: rgba(232, 221, 196, 0.6);
margin-bottom: 8px;
.page-state {
padding: 16px;
min-height: 100px;
}
.readonly-note {
font-size: 0.75rem;
color: rgba(245, 208, 138, 0.8);
margin-bottom: 8px;
.page-state button {
margin-left: 12px;
}
.policy-section {
margin-top: 12px;
.section_bar {
min-height: 23px;
border: 0.5px solid #aaa;
box-sizing: border-box;
text-align: center;
}
.section-title {
font-size: 0.95rem;
margin-bottom: 8px;
color: rgba(232, 221, 196, 0.9);
.setter,
.priority-meta {
min-height: 21px;
padding: 0 12px;
color: #8e8e8e;
font-size: 12.25px;
line-height: 18.375px;
text-align: right;
box-sizing: border-box;
}
.policy-grid {
.feedback {
margin: 4px 12px;
padding: 5px 10px;
border: 1px solid;
border-radius: 4px;
}
.error-feedback,
.error-state {
color: #ffd4d4;
border-color: #a94442;
background-color: rgba(120, 20, 20, 0.75);
}
.notice-feedback {
color: #d9ffd9;
border-color: #3c763d;
background-color: rgba(20, 90, 20, 0.7);
}
.form_list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin: 8px;
}
.policy-field {
border: 1px solid rgba(201, 164, 90, 0.2);
background: rgba(16, 16, 16, 0.5);
padding: 10px;
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
padding: 0 10.5px;
box-sizing: border-box;
}
.field-label {
font-size: 0.8rem;
font-weight: 600;
}
.field-input {
background: rgba(12, 12, 12, 0.8);
border: 1px solid rgba(201, 164, 90, 0.3);
color: inherit;
padding: 6px 8px;
font-size: 0.8rem;
font-family: inherit;
}
.field-desc {
font-size: 0.7rem;
color: rgba(232, 221, 196, 0.6);
}
.field-hint {
font-size: 0.7rem;
color: rgba(232, 221, 196, 0.45);
white-space: pre-line;
}
.control-bar {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.btn-group {
display: flex;
gap: 8px;
}
.priority-grid {
.field-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px;
grid-template-columns: minmax(0, 1fr) 224px;
align-items: center;
min-height: 34px;
}
.field-row label {
min-width: 0;
}
.field-row input {
width: 224px;
height: 34px;
padding: 5.25px 10.5px;
color: #303030;
background: #ddd;
border: 1px solid #000;
border-radius: 4px;
box-sizing: border-box;
font: inherit;
}
.field-row input:focus {
border-color: #66afe9;
outline: 2px solid rgba(102, 175, 233, 0.7);
}
.policy-field p {
min-height: 18.375px;
margin: 0;
color: #888;
font-size: 12.25px;
line-height: 18.375px;
text-align: right;
}
.work-in-progress {
margin: 0 11px 15px;
padding: 14px;
color: #fff;
background: #444;
border: 1px solid #444;
border-radius: 4px;
}
.control_bar {
display: flex;
justify-content: flex-end;
padding: 0 10.6667px 10.6667px;
min-height: 56.8125px;
box-sizing: border-box;
}
.button-group {
display: flex;
}
.control_bar button {
width: 150px;
height: 35.5px;
margin-top: 10.6667px;
padding: 5.25px 10.5px;
color: #fff;
border: 1px solid;
font: inherit;
cursor: pointer;
}
.reset_btn {
background: #303030;
border-color: #2b2b2b !important;
border-radius: 4px 0 0 4px;
}
.revert_btn {
background: #444;
border-color: #3d3d3d !important;
border-radius: 0 4px 4px 0;
}
.submit_btn {
margin-left: 14px;
background: #375a7f;
border-color: #325172 !important;
border-radius: 4px;
}
.control_bar button:hover {
filter: brightness(1.15);
}
.control_bar button:focus-visible,
.help-button:focus-visible {
outline: 2px solid #fff;
outline-offset: -2px;
}
.priority-sections {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.priority-panel {
min-width: 0;
}
.half_section_left {
border-right: 0.5px solid #aaa;
}
.priority-meta {
float: right;
}
.priority-description {
clear: both;
min-height: 42px;
padding: 0 8px;
color: #888;
box-sizing: border-box;
}
.priority-description small {
font-size: 12.25px;
line-height: 18.375px;
}
.priority-columns {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.priority-column {
border: 1px solid rgba(201, 164, 90, 0.2);
padding: 8px;
background: rgba(12, 12, 12, 0.5);
min-width: 0;
}
.column-title {
font-size: 0.8rem;
margin-bottom: 6px;
color: rgba(232, 221, 196, 0.7);
.sub_bar {
height: 22px;
margin: 0 5px;
border: 0.5px solid #aaa;
text-align: center;
box-sizing: border-box;
}
.priority-list {
display: flex;
min-height: 37px;
margin: 0 10px;
flex-direction: column;
gap: 6px;
}
.inactive-header,
.priority-item {
height: 37px;
padding: 7px 14px;
border: 1px solid #444;
box-sizing: border-box;
}
.inactive-header {
color: #1d1d1d;
background: #d6d6d6;
}
.priority-item {
border: 1px solid rgba(201, 164, 90, 0.2);
padding: 6px;
display: flex;
flex-direction: column;
gap: 6px;
background: rgba(16, 16, 16, 0.6);
color: #fff;
background: #303030;
cursor: grab;
}
.priority-main {
display: flex;
.priority-item:active {
cursor: grabbing;
opacity: 0.8;
}
.priority_info {
display: grid;
height: 21px;
grid-template-columns: 24px minmax(0, 1fr) 24px;
align-items: center;
gap: 6px;
}
.priority-name {
font-size: 0.78rem;
.drag-handle {
font-size: 18px;
}
.priority-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.priority-help {
.help-button {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
font-size: 0.7rem;
border-radius: 999px;
border: 1px solid rgba(201, 164, 90, 0.4);
cursor: default;
width: 24px;
height: 22.375px;
padding: 0 3.5px;
color: #fff;
background: #444;
border: 1px solid #3d3d3d;
border-radius: 3px;
font-size: 12.25px;
line-height: 18.375px;
cursor: pointer;
}
.priority-help::after {
content: attr(data-text);
.help-button::after {
position: absolute;
bottom: 125%;
left: 50%;
transform: translateX(-50%);
background: rgba(16, 16, 16, 0.9);
border: 1px solid rgba(201, 164, 90, 0.4);
padding: 8px;
font-size: 0.7rem;
width: 220px;
right: 0;
bottom: calc(100% + 5px);
z-index: 10;
width: 300px;
padding: 7px;
color: #fff;
background: #111;
border: 1px solid #777;
border-radius: 4px;
content: attr(data-text);
font-size: 12px;
line-height: 18px;
text-align: left;
white-space: pre-line;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
z-index: 10;
transition: opacity 0.15s ease;
}
.priority-help:hover::after {
.help-button:hover::after,
.help-button:focus-visible::after {
opacity: 1;
}
@media (max-width: 1024px) {
.npc-page {
padding: 16px;
.priority-control {
margin-top: 0;
}
@media (max-width: 991px) {
.form_list,
.priority-sections {
grid-template-columns: 1fr;
}
.half_section_left {
border-right: 0;
}
}
</style>
@@ -108,6 +108,7 @@ onMounted(() => {
<RouterLink v-else-if="session.needsGeneral" class="ghost" to="/join">장수 생성/빙의</RouterLink>
<RouterLink v-else class="ghost" to="/">메인으로</RouterLink>
<RouterLink class="ghost" to="/npc-list">빙의일람</RouterLink>
<RouterLink class="ghost" to="/traffic">접속량정보</RouterLink>
<button class="ghost" @click="refreshPublicData">새로고침</button>
</div>
</header>
@@ -134,7 +134,7 @@ watch(
);
onMounted(() => {
loadData();
void loadData();
});
onBeforeUnmount(() => {
@@ -168,7 +168,11 @@ onBeforeUnmount(() => {
</div>
<div v-if="editing" class="editor-toolbar">
<button type="button" @click="editor?.chain().focus().toggleBold().run()" :class="{ active: editor?.isActive('bold') }">
<button
type="button"
@click="editor?.chain().focus().toggleBold().run()"
:class="{ active: editor?.isActive('bold') }"
>
굵게
</button>
<button
+343
View File
@@ -0,0 +1,343 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { trpc } from '../utils/trpc';
type TrafficData = Awaited<ReturnType<typeof trpc.public.getTraffic.query>>;
const data = ref<TrafficData | null>(null);
const loading = ref(false);
const errorMessage = ref('');
const getErrorMessage = (error: unknown): string => {
if (error instanceof Error) {
return error.message;
}
return typeof error === 'string' ? error : '요청을 처리하지 못했습니다.';
};
const load = async () => {
if (loading.value) {
return;
}
loading.value = true;
errorMessage.value = '';
try {
data.value = await trpc.public.getTraffic.query();
} catch (error) {
// Preserve the last successful graph if a later refresh fails.
errorMessage.value = getErrorMessage(error);
} finally {
loading.value = false;
}
};
const refreshRows = computed(() =>
(data.value?.history ?? []).map((entry) => ({
...entry,
value: entry.refresh,
width: Math.round((entry.refresh / Math.max(1, data.value?.maxRefresh ?? 1)) * 1_000) / 10,
}))
);
const onlineRows = computed(() =>
(data.value?.history ?? []).map((entry) => ({
...entry,
value: entry.online,
width: Math.round((entry.online / Math.max(1, data.value?.maxOnline ?? 1)) * 1_000) / 10,
}))
);
const timeLabel = (value: string): string => {
const timePart = value.includes('T') ? value.split('T')[1] : value.slice(11);
return (timePart ?? '').slice(0, 5);
};
const trafficColor = (percentage: number): string => {
const channel = (value: number): string =>
Math.floor((Math.max(0, Math.min(100, value)) * 255) / 100)
.toString(16)
.padStart(2, '0');
return `#${channel(percentage)}00${channel(100 - percentage)}`;
};
onMounted(() => {
void load();
});
</script>
<template>
<main id="traffic-container" class="traffic-page">
<table class="legacy-table title-table legacy-bg0">
<tbody>
<tr>
<td>
<br />
<RouterLink class="legacy-close" to="/">돌아가기</RouterLink>
</td>
</tr>
</tbody>
</table>
<div v-if="errorMessage" class="traffic-error" role="alert">{{ errorMessage }}</div>
<div v-if="loading && !data" class="traffic-loading">불러오는 중...</div>
<section v-if="data" class="chart-layout">
<table class="legacy-table chart-table legacy-bg0">
<thead>
<tr>
<th colspan="4" class="legacy-bg2 chart-title"> </th>
</tr>
</thead>
<tbody>
<tr v-for="(entry, index) in refreshRows" :key="`${entry.date}-${index}`" class="chart-row">
<td class="period">{{ entry.year }} {{ entry.month }}</td>
<td class="time legacy-bg2">{{ timeLabel(entry.date) }}</td>
<td class="separator legacy-bg1"></td>
<td class="bar-cell">
<div
v-if="entry.width > 0"
class="big-bar"
:style="{ width: `${entry.width}%`, backgroundColor: trafficColor(entry.width) }"
>
<span v-if="entry.width >= 10">{{ entry.value }}</span>
</div>
<span v-if="entry.width < 10" class="out-bar">{{ entry.value }}</span>
</td>
</tr>
<tr><td colspan="4" class="legacy-bg1 spacer"></td></tr>
<tr><td colspan="4" class="record">최고기록: {{ data.maxRefresh }}</td></tr>
</tbody>
</table>
<table class="legacy-table chart-table legacy-bg0">
<thead>
<tr>
<th colspan="4" class="legacy-bg2 chart-title"> </th>
</tr>
</thead>
<tbody>
<tr v-for="(entry, index) in onlineRows" :key="`${entry.date}-${index}`" class="chart-row">
<td class="period">{{ entry.year }} {{ entry.month }}</td>
<td class="time legacy-bg2">{{ timeLabel(entry.date) }}</td>
<td class="separator legacy-bg1"></td>
<td class="bar-cell">
<div
v-if="entry.width > 0"
class="big-bar"
:style="{ width: `${entry.width}%`, backgroundColor: trafficColor(entry.width) }"
>
<span v-if="entry.width >= 10">{{ entry.value }}</span>
</div>
<span v-if="entry.width < 10" class="out-bar">{{ entry.value }}</span>
</td>
</tr>
<tr><td colspan="4" class="legacy-bg1 spacer"></td></tr>
<tr><td colspan="4" class="record">최고기록: {{ data.maxOnline }}</td></tr>
</tbody>
</table>
</section>
<table v-if="data" class="legacy-table suspect-table legacy-bg0">
<thead>
<tr>
<th colspan="3" class="legacy-bg2 chart-title"> (순간과도갱신)</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in data.suspects" :key="entry.generalId ?? 'total'">
<td class="suspect-name">{{ entry.name }}</td>
<td class="suspect-score">{{ entry.refreshScoreTotal }}({{ entry.refresh }})</td>
<td class="little-bar-cell">
<div
v-if="entry.refresh > 0"
class="little-bar"
:style="{
width: `${Math.round((entry.refresh / Math.max(1, data.suspects[0]?.refresh ?? 1)) * 1_000) / 10}%`,
backgroundColor: trafficColor(
Math.round((entry.refresh / Math.max(1, data.suspects[0]?.refresh ?? 1)) * 1_000) / 10
),
}"
></div>
</td>
</tr>
</tbody>
</table>
<table class="legacy-table footer-table legacy-bg0">
<tbody>
<tr><td><RouterLink class="legacy-close" to="/">돌아가기</RouterLink></td></tr>
<tr><td class="banner">SAMMO</td></tr>
</tbody>
</table>
</main>
</template>
<style scoped>
.traffic-page {
width: 1016px;
min-width: 1016px;
min-height: 100vh;
margin: 0 auto;
padding: 0;
color: #fff;
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-size: 14px;
line-height: normal;
}
.legacy-table {
border-collapse: collapse;
padding: 0;
color: #fff;
font-size: 14px;
}
.legacy-table td,
.legacy-table th {
border: 1px solid gray;
padding: 0;
text-align: center;
font-weight: 400;
}
.legacy-bg0 {
background-color: #302016;
background-image: url('/image/game/back_walnut.jpg');
}
.legacy-bg1 {
background-color: #423226;
background-image: url('/image/game/back_sandal.jpg');
}
.legacy-bg2 {
background-color: #14241b;
background-image: url('/image/game/back_green.jpg');
}
.title-table,
.footer-table {
width: 1000px;
margin: 0 auto;
}
.title-table {
margin-bottom: 18px;
}
.title-table td {
height: 54px;
}
.chart-layout {
width: 1016px;
display: flex;
gap: 26px;
align-items: flex-start;
box-sizing: border-box;
padding: 0 12px;
}
.chart-table {
width: 483px;
table-layout: fixed;
}
.chart-title {
height: 34px;
font-size: 24px;
}
.chart-row {
height: 30px;
}
.period {
width: 100px;
}
.time {
width: 60px;
}
.separator {
width: 2px;
}
.bar-cell {
width: 320px;
text-align: left !important;
white-space: nowrap;
}
.big-bar {
float: left;
position: relative;
height: 30px;
}
.big-bar span {
float: right;
padding-right: 1ch;
line-height: 30px;
}
.out-bar {
line-height: 30px;
margin-left: 1ch;
}
.spacer {
height: 5px;
}
.record {
height: 30px;
}
.suspect-table {
margin: 18px auto;
}
.suspect-name,
.suspect-score {
width: 98px;
}
.little-bar-cell {
width: 798px;
text-align: left !important;
}
.little-bar {
height: 17px;
}
.footer-table {
margin-top: 18px;
}
.banner {
height: 24px;
}
.legacy-close {
color: #fff;
text-decoration: underline;
}
.traffic-error,
.traffic-loading {
width: 1000px;
margin: -12px auto 12px;
border: 1px solid gray;
padding: 6px;
text-align: center;
background: #302016;
}
.traffic-error {
color: #ff8080;
}
</style>
+9 -3
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import MapViewer from '../components/main/MapViewer.vue';
import { formatLog } from '../utils/formatLog';
@@ -25,12 +25,18 @@ type HistoryData = {
};
const router = useRouter();
const route = useRoute();
const loading = ref(false);
const errorMessage = ref('');
const range = ref<YearbookRange | null>(null);
const mapLayout = ref<MapLayout | null>(null);
const history = ref<HistoryData | null>(null);
const selectedYearMonth = ref<number | null>(null);
const serverID = computed(() => {
const value = route.query.serverID;
const raw = Array.isArray(value) ? value[0] : value;
return typeof raw === 'string' && raw.trim() ? raw.trim() : undefined;
});
const parseYearMonth = (value: number): { year: number; month: number } => ({
year: Math.floor(value / 12),
@@ -66,7 +72,7 @@ const loadHistory = async (): Promise<void> => {
errorMessage.value = '';
try {
const { year, month } = parseYearMonth(selectedYearMonth.value);
const result = await trpc.yearbook.getHistory.query({ year, month });
const result = await trpc.yearbook.getHistory.query({ year, month, serverID: serverID.value });
if ('data' in result) {
history.value = result.data;
}
@@ -96,7 +102,7 @@ onMounted(async () => {
loading.value = true;
try {
const [loadedRange, loadedLayout] = await Promise.all([
trpc.yearbook.getRange.query(),
trpc.yearbook.getRange.query(serverID.value ? { serverID: serverID.value } : undefined),
trpc.public.getMapLayout.query(),
]);
range.value = loadedRange;