Merge branch 'main' into feature/dynasty-list-parity
# Conflicts: # tools/frontend-legacy-parity/playwright.config.mjs
This commit is contained in:
@@ -7,6 +7,7 @@ interface Props {
|
||||
options: BattleSimOptions;
|
||||
mode: 'attacker' | 'defender';
|
||||
title: string;
|
||||
canImportServer: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
@@ -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;
|
||||
|
||||
@@ -34,8 +34,9 @@ const stateOffset = computed(() => -6 * props.mapScale);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
<RouterLink
|
||||
class="map-city"
|
||||
:to="{ name: 'current-city', query: { cityId: props.city.id } }"
|
||||
:class="[
|
||||
`state-${props.city.stateClass}`,
|
||||
{ mine: props.city.isMyCity, selected: props.city.selected, 'supply-off': !props.city.supply },
|
||||
@@ -45,20 +46,22 @@ const stateOffset = computed(() => -6 * props.mapScale);
|
||||
@mouseleave="emit('leave')"
|
||||
@click.stop="emit('select', props.city.id)"
|
||||
>
|
||||
<div
|
||||
class="city-dot"
|
||||
:style="{ backgroundColor: props.city.color, width: `${size}px`, height: `${size}px` }"
|
||||
>
|
||||
<div class="city-dot" :style="{ backgroundColor: props.city.color, width: `${size}px`, height: `${size}px` }">
|
||||
<span v-if="props.city.isCapital" class="capital" />
|
||||
</div>
|
||||
<div
|
||||
v-if="props.city.state > 0"
|
||||
class="city-state"
|
||||
:class="`state-${props.city.stateClass}`"
|
||||
:style="{ width: `${stateSize}px`, height: `${stateSize}px`, left: `${stateOffset}px`, top: `${stateOffset}px` }"
|
||||
:style="{
|
||||
width: `${stateSize}px`,
|
||||
height: `${stateSize}px`,
|
||||
left: `${stateOffset}px`,
|
||||
top: `${stateOffset}px`,
|
||||
}"
|
||||
/>
|
||||
<div v-if="props.showName" class="city-name">{{ props.city.name }}</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -71,6 +74,8 @@ const stateOffset = computed(() => -6 * props.mapScale);
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 0.65rem;
|
||||
color: rgba(232, 221, 196, 0.8);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.city-dot {
|
||||
|
||||
@@ -149,8 +149,9 @@ const cityStateStyle = computed(() => ({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
<RouterLink
|
||||
class="city-base"
|
||||
:to="{ name: 'current-city', query: { cityId: props.city.id } }"
|
||||
:class="[{ mine: props.city.isMyCity, selected: props.city.selected, 'supply-off': !props.city.supply }]"
|
||||
:style="cityBaseStyle"
|
||||
@mouseenter="emit('hover', props.city.id)"
|
||||
@@ -172,7 +173,7 @@ const cityStateStyle = computed(() => ({
|
||||
<div v-if="stateIcon" class="city-state" :style="cityStateStyle">
|
||||
<img :src="stateIcon" />
|
||||
</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -181,6 +182,8 @@ const cityStateStyle = computed(() => ({
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 0.65rem;
|
||||
color: rgba(232, 221, 196, 0.9);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.city-bg {
|
||||
|
||||
@@ -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">서신전달&갱신</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"><{{ message.time }}></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>
|
||||
@@ -10,6 +10,7 @@ import NationInfoView from '../views/NationInfoView.vue';
|
||||
import GlobalInfoView from '../views/GlobalInfoView.vue';
|
||||
import CurrentCityView from '../views/CurrentCityView.vue';
|
||||
import NationGeneralsView from '../views/NationGeneralsView.vue';
|
||||
import NationSecretView from '../views/NationSecretView.vue';
|
||||
import NationPersonnelView from '../views/NationPersonnelView.vue';
|
||||
import NationStratFinanView from '../views/NationStratFinanView.vue';
|
||||
import ChiefCenterView from '../views/ChiefCenterView.vue';
|
||||
@@ -20,7 +21,6 @@ import NotFoundView from '../views/NotFoundView.vue';
|
||||
import TournamentView from '../views/TournamentView.vue';
|
||||
import BettingView from '../views/BettingView.vue';
|
||||
import MyPageView from '../views/MyPageView.vue';
|
||||
import MySettingsView from '../views/MySettingsView.vue';
|
||||
import BoardView from '../views/BoardView.vue';
|
||||
import DiplomacyView from '../views/DiplomacyView.vue';
|
||||
import BestGeneralView from '../views/BestGeneralView.vue';
|
||||
@@ -32,6 +32,7 @@ import TroopView from '../views/TroopView.vue';
|
||||
import YearbookView from '../views/YearbookView.vue';
|
||||
import NationBettingView from '../views/NationBettingView.vue';
|
||||
import NpcListView from '../views/NpcListView.vue';
|
||||
import TrafficView from '../views/TrafficView.vue';
|
||||
import { useSessionStore } from '../stores/session';
|
||||
|
||||
const routes = [
|
||||
@@ -148,6 +149,12 @@ const routes = [
|
||||
requiresGeneral: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/nation/secret',
|
||||
name: 'nation-secret',
|
||||
component: NationSecretView,
|
||||
meta: { requiresAuth: true, requiresGeneral: true },
|
||||
},
|
||||
{
|
||||
path: '/nation/personnel',
|
||||
name: 'nation-personnel',
|
||||
@@ -190,7 +197,6 @@ const routes = [
|
||||
component: BattleSimulatorView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresGeneral: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -252,6 +258,11 @@ const routes = [
|
||||
requiresGeneral: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/traffic',
|
||||
name: 'traffic',
|
||||
component: TrafficView,
|
||||
},
|
||||
{
|
||||
path: '/npc-list',
|
||||
name: 'npc-list',
|
||||
@@ -277,8 +288,7 @@ const routes = [
|
||||
},
|
||||
{
|
||||
path: '/my-settings',
|
||||
name: 'my-settings',
|
||||
component: MySettingsView,
|
||||
redirect: '/my-page',
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresGeneral: true,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1032,6 +1129,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
|
||||
:options="options!"
|
||||
mode="attacker"
|
||||
title="출병자 설정"
|
||||
:can-import-server="hasGameGeneral"
|
||||
@import="openImportModal(attackerGeneral!)"
|
||||
@save="saveGeneral(attackerGeneral!)"
|
||||
@load="(payload) => handleGeneralLoad({ target: attackerGeneral!, file: payload.file })"
|
||||
@@ -1099,6 +1197,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
|
||||
: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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,32 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { formatOfficerLevelText, cityLevelMap, regionMap } from '../utils/nationFormat';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { cityLevelMap, formatOfficerLevelText, regionMap } from '../utils/nationFormat';
|
||||
import { getNpcColor } from '../utils/npcColor';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
type Result = Awaited<ReturnType<typeof trpc.world.getCurrentCity.query>>;
|
||||
type General = Result['generals'][number];
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const data = ref<Result | null>(null);
|
||||
const error = ref('');
|
||||
const selected = ref<number>();
|
||||
const show = (value: number | null) => (value === null ? '?' : value.toLocaleString('ko-KR'));
|
||||
let loadSequence = 0;
|
||||
|
||||
const parseCityId = (): number | undefined => {
|
||||
const raw = route.query.cityId ?? route.query.citylist;
|
||||
const value = Array.isArray(raw) ? raw[0] : raw;
|
||||
if (typeof value !== 'string' || !/^\d+$/.test(value)) return undefined;
|
||||
const cityId = Number(value);
|
||||
return Number.isSafeInteger(cityId) && cityId > 0 ? cityId : undefined;
|
||||
};
|
||||
|
||||
const load = async (cityId?: number) => {
|
||||
const sequence = ++loadSequence;
|
||||
try {
|
||||
data.value = await trpc.world.getCurrentCity.query(cityId ? { cityId } : undefined);
|
||||
selected.value = data.value.city.id;
|
||||
const result = await trpc.world.getCurrentCity.query(cityId ? { cityId } : undefined);
|
||||
if (sequence !== loadSequence) return;
|
||||
data.value = result;
|
||||
selected.value = result.city.id;
|
||||
error.value = '';
|
||||
} catch (cause) {
|
||||
if (sequence !== loadSequence) return;
|
||||
error.value = cause instanceof Error ? cause.message : '도시 정보를 불러오지 못했습니다.';
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [route.query.cityId, route.query.citylist],
|
||||
() => void load(parseCityId()),
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const selectCity = async () => {
|
||||
if (!selected.value) return;
|
||||
await router.push({ name: 'current-city', query: { cityId: selected.value } });
|
||||
};
|
||||
|
||||
const city = computed(() => data.value?.city);
|
||||
onMounted(() => void load());
|
||||
const summary = computed(() => data.value?.forceSummary);
|
||||
const show = (value: number | null) => (value === null ? '?' : value.toLocaleString('ko-KR'));
|
||||
const showPair = (crew: number, generals: number) => `${show(crew)}/${show(generals)}`;
|
||||
const populationRate = computed(() => {
|
||||
if (!city.value || city.value.population === null) return '?';
|
||||
return String(Math.round((city.value.population / city.value.populationMax) * 10_000) / 100);
|
||||
});
|
||||
const contrastColors = new Set([
|
||||
'',
|
||||
'#330000',
|
||||
'#FF0000',
|
||||
'#800000',
|
||||
'#A0522D',
|
||||
'#FF6347',
|
||||
'#808000',
|
||||
'#008000',
|
||||
'#2E8B57',
|
||||
'#008080',
|
||||
'#6495ED',
|
||||
'#0000FF',
|
||||
'#000080',
|
||||
'#483D8B',
|
||||
'#7B68EE',
|
||||
'#800080',
|
||||
'#A9A9A9',
|
||||
'#000000',
|
||||
]);
|
||||
const cityTitleStyle = computed(() => {
|
||||
const backgroundColor = city.value?.nationColor.toUpperCase() ?? '#000000';
|
||||
return {
|
||||
backgroundColor,
|
||||
color: contrastColors.has(backgroundColor) ? '#FFFFFF' : '#000000',
|
||||
};
|
||||
});
|
||||
const woundedStat = (value: number, injury: number) =>
|
||||
injury === 0 ? value : Math.floor((value * (100 - injury)) / 100);
|
||||
const defenceTrainText = (value: number | null) => {
|
||||
if (value === null) return '?';
|
||||
if (value === 999) return '×';
|
||||
if (value >= 90) return '☆';
|
||||
if (value >= 80) return '◎';
|
||||
if (value >= 60) return '○';
|
||||
return '△';
|
||||
};
|
||||
const generalImage = (general: General) => {
|
||||
const picture = general.picture ?? 'default.jpg';
|
||||
return general.imageServer ? `${import.meta.env.BASE_URL}d_pic/${picture}` : `/image/icons/${picture}`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="city-page">
|
||||
<table class="legacy-table legacy-bg0 center">
|
||||
<table class="legacy-table legacy-bg0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>도 시 정 보<br /><RouterLink to="/">돌아가기</RouterLink></td>
|
||||
<td>도 시 정 보<br /><RouterLink class="back-link" to="/">돌아가기</RouterLink></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -34,32 +112,57 @@ onMounted(() => void load());
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
도시선택 :
|
||||
<select v-model.number="selected" @change="load(selected)">
|
||||
<option v-for="option in data?.options ?? []" :key="option.id" :value="option.id">
|
||||
【{{ option.name.padEnd(4, '_') }}】{{
|
||||
option.nationId === data?.me.nationId
|
||||
? '본국'
|
||||
: option.nationId === 0
|
||||
? '공백지'
|
||||
: '타국'
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<p>명령 화면에서 도시를 클릭하셔도 됩니다.</p>
|
||||
<form @submit.prevent="selectCity">
|
||||
<div>
|
||||
도시선택 :
|
||||
<select id="citySelector" v-model.number="selected" @change="selectCity">
|
||||
<option v-for="option in data?.options ?? []" :key="option.id" :value="option.id">
|
||||
【{{ option.name.padEnd(4, '_') }}】{{
|
||||
option.nationId === data?.me.nationId
|
||||
? '본국'
|
||||
: option.nationId === 0
|
||||
? '공백지'
|
||||
: '타국'
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<p>명령 화면에서 도시를 클릭하셔도 됩니다.</p>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="error" class="error" role="alert">{{ error }}</p>
|
||||
<template v-if="data && city">
|
||||
<table class="legacy-table legacy-bg2 stats">
|
||||
<table class="legacy-table legacy-bg0 back-row">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colspan="11" class="city-title">
|
||||
<td><RouterLink class="back-link" to="/">돌아가기</RouterLink></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table class="legacy-table legacy-bg2 stats">
|
||||
<colgroup>
|
||||
<col class="label-col" />
|
||||
<col class="first-value-col" />
|
||||
<col class="label-col" />
|
||||
<col class="value-col" />
|
||||
<col class="label-col" />
|
||||
<col class="value-col" />
|
||||
<col class="label-col" />
|
||||
<col class="value-col" />
|
||||
<col class="label-col" />
|
||||
<col class="value-col" />
|
||||
<col class="label-col" />
|
||||
<col class="value-col" />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colspan="11" class="city-title" :style="cityTitleStyle">
|
||||
【 {{ regionMap[city.region] }} | {{ cityLevelMap[city.level] }} 】 {{ city.name }}
|
||||
</td>
|
||||
<td class="city-title">{{ data.lastExecute }}</td>
|
||||
<td class="city-title" :style="cityTitleStyle">{{ data.lastExecute }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>주민</th>
|
||||
@@ -79,15 +182,9 @@ onMounted(() => void load());
|
||||
<th>민심</th>
|
||||
<td>{{ show(city.trust) }}</td>
|
||||
<th>시세</th>
|
||||
<td>{{ city.trade ?? '-' }}%</td>
|
||||
<td>{{ city.trade ?? '- ' }}%</td>
|
||||
<th>인구</th>
|
||||
<td>
|
||||
{{
|
||||
city.population === null
|
||||
? '?'
|
||||
: ((city.population / city.populationMax) * 100).toFixed(2)
|
||||
}}%
|
||||
</td>
|
||||
<td>{{ populationRate }}%</td>
|
||||
<th>태수</th>
|
||||
<td>{{ city.officers[4] }}</td>
|
||||
<th>군사</th>
|
||||
@@ -95,19 +192,60 @@ onMounted(() => void load());
|
||||
<th>종사</th>
|
||||
<td>{{ city.officers[2] }}</td>
|
||||
</tr>
|
||||
<tr v-if="summary">
|
||||
<th>도시명</th>
|
||||
<td>{{ city.name }}</td>
|
||||
<th>적군</th>
|
||||
<td>
|
||||
{{ show(summary.enemyCrew) }}/{{ show(summary.enemyArmedGenerals) }}({{
|
||||
show(summary.enemyGenerals)
|
||||
}})
|
||||
</td>
|
||||
<th>병장(총)</th>
|
||||
<td>
|
||||
{{ show(summary.ownCrew) }}/{{ show(summary.ownArmedGenerals) }}({{
|
||||
show(summary.ownGenerals)
|
||||
}})
|
||||
</td>
|
||||
<th>90병장</th>
|
||||
<td>{{ showPair(summary.ready90Crew, summary.ready90Generals) }}</td>
|
||||
<th>60병장</th>
|
||||
<td>{{ showPair(summary.ready60Crew, summary.ready60Generals) }}</td>
|
||||
<th>수비○</th>
|
||||
<td>{{ showPair(summary.defenceReadyCrew, summary.defenceReadyGenerals) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>장수</th>
|
||||
<td colspan="11">
|
||||
{{
|
||||
data.visibility.detailed
|
||||
? data.generals.map((g) => g.name).join(', ') || '-'
|
||||
: '알 수 없음'
|
||||
}}
|
||||
<td colspan="11" class="general-names">
|
||||
<template v-if="data.visibility.detailed">
|
||||
<template v-if="data.generals.length">
|
||||
<template v-for="(general, index) in data.generals" :key="general.id">
|
||||
<span :style="{ color: getNpcColor(general.npcState) }">{{ general.name }}</span
|
||||
><template v-if="index < data.generals.length - 1">, </template>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>-</template>
|
||||
</template>
|
||||
<span v-else class="unknown">알 수 없음</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table v-if="data.visibility.detailed" class="legacy-table legacy-bg0 generals">
|
||||
<table v-if="data.visibility.detailed" id="general_list" class="legacy-table legacy-bg0 generals">
|
||||
<colgroup>
|
||||
<col style="width: 64px" />
|
||||
<col style="width: 128px" />
|
||||
<col style="width: 48px" />
|
||||
<col style="width: 48px" />
|
||||
<col style="width: 48px" />
|
||||
<col style="width: 78px" />
|
||||
<col style="width: 28px" />
|
||||
<col style="width: 78px" />
|
||||
<col style="width: 78px" />
|
||||
<col style="width: 48px" />
|
||||
<col style="width: 48px" />
|
||||
<col style="width: 280px" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>얼 굴</th>
|
||||
@@ -125,42 +263,53 @@ onMounted(() => void load());
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="general in data.generals" :key="general.id">
|
||||
<td>
|
||||
<img
|
||||
v-if="general.picture"
|
||||
width="64"
|
||||
height="64"
|
||||
:src="`/image/general/${general.picture}`"
|
||||
/>
|
||||
<tr
|
||||
v-for="general in data.generals"
|
||||
:key="general.id"
|
||||
:data-is-our-general="general.train !== null"
|
||||
:data-general-wounded="general.injury"
|
||||
>
|
||||
<td class="icon-cell">
|
||||
<img class="general-icon" width="64" height="64" :src="generalImage(general)" />
|
||||
</td>
|
||||
<td :style="{ color: getNpcColor(general.npcState) }">{{ general.name }}</td>
|
||||
<td :class="{ wounded: general.injury !== 0 }">
|
||||
{{ woundedStat(general.leadership, general.injury)
|
||||
}}<span v-if="general.leadershipBonus" class="leadership-bonus"
|
||||
>+{{ general.leadershipBonus }}</span
|
||||
>
|
||||
</td>
|
||||
<td :class="{ wounded: general.injury !== 0 }">
|
||||
{{ woundedStat(general.strength, general.injury) }}
|
||||
</td>
|
||||
<td :class="{ wounded: general.injury !== 0 }">
|
||||
{{ woundedStat(general.intelligence, general.injury) }}
|
||||
</td>
|
||||
<td>{{ general.name }}</td>
|
||||
<td>{{ general.leadership }}</td>
|
||||
<td>{{ general.strength }}</td>
|
||||
<td>{{ general.intelligence }}</td>
|
||||
<td>{{ formatOfficerLevelText(general.officerLevel) }}</td>
|
||||
<td>{{ general.defenceTrain ?? '?' }}</td>
|
||||
<td>{{ general.crewTypeId ?? '?' }}</td>
|
||||
<td>{{ defenceTrainText(general.defenceTrain) }}</td>
|
||||
<td>{{ general.crewTypeName ?? '?' }}</td>
|
||||
<td>{{ general.crew ?? '?' }}</td>
|
||||
<td>{{ general.train ?? '?' }}</td>
|
||||
<td>{{ general.atmos ?? '?' }}</td>
|
||||
<td class="turns">
|
||||
{{
|
||||
general.turns.length
|
||||
? general.turns.map((turn, index) => `${index + 1} : ${turn}`).join(' / ')
|
||||
: general.npcState > 1
|
||||
? 'NPC 장수'
|
||||
: `【${general.nationName}】 장수`
|
||||
}}
|
||||
<template v-if="general.turns.length">
|
||||
<span v-for="(turn, index) in general.turns" :key="index" class="turn-line"
|
||||
>{{ index + 1 }} : {{ turn }}</span
|
||||
>
|
||||
</template>
|
||||
<template v-else-if="general.npcState > 1">NPC 장수</template>
|
||||
<template v-else-if="general.nationId !== data.me.nationId">
|
||||
{{ general.nationId === 0 ? '재 야' : `【${general.nationName}】 장수` }}
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
<table class="legacy-table legacy-bg0 center footer">
|
||||
<table class="legacy-table legacy-bg0 footer">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><RouterLink to="/">돌아가기</RouterLink></td>
|
||||
<td><RouterLink class="back-link" to="/">돌아가기</RouterLink></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -170,60 +319,138 @@ onMounted(() => void load());
|
||||
<style scoped>
|
||||
.city-page {
|
||||
width: 1000px;
|
||||
margin: 0 auto;
|
||||
font-size: 14px;
|
||||
margin: 8px auto 0;
|
||||
font-family: 'Times New Roman', serif;
|
||||
font-size: 16px;
|
||||
line-height: normal;
|
||||
}
|
||||
.legacy-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border-collapse: separate;
|
||||
border-spacing: 2px;
|
||||
}
|
||||
.legacy-table td,
|
||||
.legacy-table th {
|
||||
border: 1px solid #777;
|
||||
padding: 3px;
|
||||
border: 0;
|
||||
padding: 1px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.center {
|
||||
.center,
|
||||
.selector,
|
||||
.stats td,
|
||||
.stats th,
|
||||
.generals th,
|
||||
.generals td:not(:last-child) {
|
||||
text-align: center;
|
||||
}
|
||||
.selector {
|
||||
text-align: center;
|
||||
margin-top: 0;
|
||||
}
|
||||
.selector select {
|
||||
display: inline-block;
|
||||
min-width: 400px;
|
||||
height: 19px;
|
||||
padding: 0;
|
||||
border: 1px solid #767676;
|
||||
background: #6b6b6b;
|
||||
color: #fff;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 13.3333px;
|
||||
}
|
||||
.selector {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.selector p {
|
||||
margin: 1em 0;
|
||||
}
|
||||
.back-row {
|
||||
margin-top: 14px;
|
||||
}
|
||||
.stats {
|
||||
margin-top: 14px;
|
||||
margin-top: 0;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.label-col {
|
||||
width: 48px;
|
||||
}
|
||||
.value-col {
|
||||
width: 108px;
|
||||
}
|
||||
.first-value-col {
|
||||
width: 112px;
|
||||
}
|
||||
.stats th,
|
||||
.generals th {
|
||||
background-color: #14241b;
|
||||
background-image: url('/image/game/back_green.jpg');
|
||||
text-align: center;
|
||||
}
|
||||
.stats td {
|
||||
text-align: center;
|
||||
}
|
||||
.city-title {
|
||||
text-align: center;
|
||||
}
|
||||
.generals {
|
||||
margin-top: 14px;
|
||||
.stats {
|
||||
height: 136px;
|
||||
}
|
||||
.generals td {
|
||||
text-align: center;
|
||||
.general-names {
|
||||
text-align: left !important;
|
||||
}
|
||||
.unknown {
|
||||
color: gray;
|
||||
}
|
||||
.generals {
|
||||
width: 1024px;
|
||||
margin: 18px 0 0 50%;
|
||||
table-layout: fixed;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.generals td:last-child {
|
||||
text-align: left;
|
||||
padding-left: 1em;
|
||||
}
|
||||
.icon-cell {
|
||||
height: 64px;
|
||||
padding: 0 !important;
|
||||
}
|
||||
.generals tbody tr {
|
||||
height: 72px;
|
||||
}
|
||||
.general-icon {
|
||||
display: block;
|
||||
width: 64px;
|
||||
min-width: 64px;
|
||||
height: 64px;
|
||||
object-fit: fill;
|
||||
}
|
||||
.turns {
|
||||
font-size: x-small;
|
||||
}
|
||||
.turn-line {
|
||||
display: block;
|
||||
}
|
||||
.wounded {
|
||||
color: red;
|
||||
}
|
||||
.leadership-bonus {
|
||||
color: cyan;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 14px;
|
||||
}
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
border: 1px solid #6c757d;
|
||||
border-radius: 0.2rem;
|
||||
background: #6c757d;
|
||||
color: #fff;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
}
|
||||
.back-link:hover,
|
||||
.back-link:focus,
|
||||
.back-link:active {
|
||||
border-color: #565e64;
|
||||
background: #5c636a;
|
||||
color: #fff;
|
||||
}
|
||||
.error {
|
||||
text-align: center;
|
||||
color: #ff7373;
|
||||
@@ -233,8 +460,5 @@ onMounted(() => void load());
|
||||
width: 1000px;
|
||||
transform-origin: top left;
|
||||
}
|
||||
.selector select {
|
||||
min-width: 300px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import PanelCard from '../components/ui/PanelCard.vue';
|
||||
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
type InheritStatus = Awaited<ReturnType<typeof trpc.inherit.getStatus.query>>;
|
||||
@@ -12,8 +10,8 @@ type BuffKey =
|
||||
| 'warAvoidRatio'
|
||||
| 'warCriticalRatio'
|
||||
| 'warMagicTrialProb'
|
||||
| 'success'
|
||||
| 'fail'
|
||||
| 'domesticSuccessProb'
|
||||
| 'domesticFailProb'
|
||||
| 'warAvoidRatioOppose'
|
||||
| 'warCriticalRatioOppose'
|
||||
| 'warMagicTrialProbOppose';
|
||||
@@ -22,8 +20,8 @@ const buffKeys: BuffKey[] = [
|
||||
'warAvoidRatio',
|
||||
'warCriticalRatio',
|
||||
'warMagicTrialProb',
|
||||
'success',
|
||||
'fail',
|
||||
'domesticSuccessProb',
|
||||
'domesticFailProb',
|
||||
'warAvoidRatioOppose',
|
||||
'warCriticalRatioOppose',
|
||||
'warMagicTrialProbOppose',
|
||||
@@ -33,8 +31,8 @@ const buffLabels: Record<BuffKey, string> = {
|
||||
warAvoidRatio: '회피 확률 증가',
|
||||
warCriticalRatio: '필살 확률 증가',
|
||||
warMagicTrialProb: '전투계략 시도 확률 증가',
|
||||
success: '내정 성공률 증가',
|
||||
fail: '내정 실패율 감소',
|
||||
domesticSuccessProb: '내정 성공 확률 증가',
|
||||
domesticFailProb: '내정 실패 확률 감소',
|
||||
warAvoidRatioOppose: '상대 회피 확률 감소',
|
||||
warCriticalRatioOppose: '상대 필살 확률 감소',
|
||||
warMagicTrialProbOppose: '상대 전투계략 시도 확률 감소',
|
||||
@@ -43,20 +41,21 @@ const buffLabels: Record<BuffKey, string> = {
|
||||
const pointLabels: Record<string, string> = {
|
||||
previous: '보유',
|
||||
lived_month: '생존 턴',
|
||||
max_domestic_critical: '내정 최고치',
|
||||
active_action: '활동',
|
||||
combat: '전투',
|
||||
sabotage: '계략',
|
||||
dex: '숙련',
|
||||
unifier: '통일 보상',
|
||||
max_domestic_critical: '최대 연속 내정 성공',
|
||||
active_action: '능동 행동 수',
|
||||
combat: '전투 횟수',
|
||||
sabotage: '계략 성공 횟수',
|
||||
dex: '숙련도',
|
||||
unifier: '천통 기여',
|
||||
tournament: '토너먼트',
|
||||
betting: '베팅',
|
||||
max_belong: '최대 충성',
|
||||
betting: '베팅 당첨',
|
||||
max_belong: '최대 임관년 수',
|
||||
};
|
||||
|
||||
const pointOrder = [
|
||||
'previous',
|
||||
'lived_month',
|
||||
'max_belong',
|
||||
'max_domestic_critical',
|
||||
'active_action',
|
||||
'combat',
|
||||
@@ -65,9 +64,33 @@ const pointOrder = [
|
||||
'unifier',
|
||||
'tournament',
|
||||
'betting',
|
||||
'max_belong',
|
||||
] as const;
|
||||
|
||||
const pointHelp: Record<string, string> = {
|
||||
previous: '이전에 물려받은 포인트입니다.',
|
||||
lived_month: '살아남은 기간입니다. (1개월 단위)',
|
||||
max_belong: '가장 오래 임관했던 국가의 연도입니다.',
|
||||
max_domestic_critical: '성공한 내정 중 최대 연속값입니다.',
|
||||
active_action: '장수 동향에 본인의 이름이 직접 나타난 수입니다. 일부 사령턴은 제외됩니다.',
|
||||
combat: '전투 횟수입니다.',
|
||||
sabotage: '계략 성공 횟수입니다.',
|
||||
unifier: '천통에 기여한 포인트입니다. 각 국의 군주, 천통 수뇌, 천통 군주가 받습니다.',
|
||||
dex: '총 숙련도합입니다. 최대 숙련 이후에는 상승량이 1/3로 감소합니다.',
|
||||
tournament: '토너먼트 입상 포인트입니다.',
|
||||
betting: '성공적인 베팅을 했습니다. 수익율과 베팅 성공 횟수를 따릅니다.',
|
||||
};
|
||||
|
||||
const buffHelp: Record<BuffKey, string> = {
|
||||
warAvoidRatio: '전투 시 회피 확률이 1%p ~ 5%p 증가합니다.',
|
||||
warCriticalRatio: '전투 시 필살 확률이 1%p ~ 5%p 증가합니다.',
|
||||
warMagicTrialProb: '전투 시 계략을 시도할 확률이 1%p ~ 5%p 증가합니다. 무장도 계략을 시도합니다.',
|
||||
domesticSuccessProb: '민심, 인구, 농업, 상업, 치안, 수비, 성벽, 기술 내정의 성공 확률이 증가합니다.',
|
||||
domesticFailProb: '민심, 인구, 농업, 상업, 치안, 수비, 성벽, 기술 내정의 실패 확률이 감소합니다.',
|
||||
warAvoidRatioOppose: '전투 시 상대의 회피 확률이 1%p ~ 5%p 감소합니다.',
|
||||
warCriticalRatioOppose: '전투 시 상대의 필살 확률이 1%p ~ 5%p 감소합니다.',
|
||||
warMagicTrialProbOppose: '전투 시 상대의 계략 시도 확률이 1%p ~ 5%p 감소합니다.',
|
||||
};
|
||||
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
const status = ref<InheritStatus | null>(null);
|
||||
@@ -86,8 +109,8 @@ const buffTargets = reactive<Record<BuffKey, number>>({
|
||||
warAvoidRatio: 1,
|
||||
warCriticalRatio: 1,
|
||||
warMagicTrialProb: 1,
|
||||
success: 1,
|
||||
fail: 1,
|
||||
domesticSuccessProb: 1,
|
||||
domesticFailProb: 1,
|
||||
warAvoidRatioOppose: 1,
|
||||
warCriticalRatioOppose: 1,
|
||||
warMagicTrialProbOppose: 1,
|
||||
@@ -115,7 +138,7 @@ const statRules = computed(() => joinConfig.value?.rules.stat ?? null);
|
||||
const resetStatTotal = computed(() => resetStatForm.leadership + resetStatForm.strength + resetStatForm.intel);
|
||||
const resetBonusSum = computed(() => resetStatForm.bonus.reduce((acc, value) => acc + value, 0));
|
||||
const resetStatCost = computed(() =>
|
||||
resetBonusSum.value > 0 ? status.value?.inheritConst.inheritBornStatPoint ?? 0 : 0
|
||||
resetBonusSum.value > 0 ? (status.value?.inheritConst.inheritBornStatPoint ?? 0) : 0
|
||||
);
|
||||
|
||||
const resetStatErrors = computed(() => {
|
||||
@@ -166,6 +189,9 @@ const pointEntries = computed(() => {
|
||||
}));
|
||||
});
|
||||
|
||||
const previousPoint = computed(() => status.value?.items.previous ?? 0);
|
||||
const newPoint = computed(() => (status.value?.totalPoint ?? 0) - previousPoint.value);
|
||||
|
||||
const specialNameMap = computed(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const entry of status.value?.availableSpecialWar ?? []) {
|
||||
@@ -174,29 +200,12 @@ const specialNameMap = computed(() => {
|
||||
return map;
|
||||
});
|
||||
|
||||
const currentSpecialName = computed(() => {
|
||||
if (!status.value) {
|
||||
return '-';
|
||||
}
|
||||
return specialNameMap.value.get(status.value.currentSpecialWar) ?? status.value.currentSpecialWar ?? '-';
|
||||
});
|
||||
|
||||
const buffCost = (key: BuffKey, target: number): number => {
|
||||
const points = status.value?.inheritConst.inheritBuffPoints ?? [0, 0, 0, 0, 0, 0];
|
||||
const current = status.value?.buffLevels[key] ?? 0;
|
||||
return Math.max(0, (points[target] ?? 0) - (points[current] ?? 0));
|
||||
};
|
||||
|
||||
const buffTargetOptions = (key: BuffKey): number[] => {
|
||||
const current = status.value?.buffLevels[key] ?? 0;
|
||||
const start = Math.min(5, Math.max(1, current + 1));
|
||||
const result: number[] = [];
|
||||
for (let level = start; level <= 5; level += 1) {
|
||||
result.push(level);
|
||||
}
|
||||
return result.length > 0 ? result : [5];
|
||||
};
|
||||
|
||||
const resolveErrorMessage = (value: unknown): string => {
|
||||
if (value instanceof Error) {
|
||||
return value.message;
|
||||
@@ -207,17 +216,6 @@ const resolveErrorMessage = (value: unknown): string => {
|
||||
return 'unknown_error';
|
||||
};
|
||||
|
||||
const applyResetBalanced = () => {
|
||||
const rules = statRules.value;
|
||||
if (!rules) {
|
||||
return;
|
||||
}
|
||||
const base = Math.floor(rules.total / 3);
|
||||
resetStatForm.leadership = rules.total - base * 2;
|
||||
resetStatForm.strength = base;
|
||||
resetStatForm.intel = base;
|
||||
};
|
||||
|
||||
const syncSelections = () => {
|
||||
if (!status.value) {
|
||||
return;
|
||||
@@ -235,6 +233,14 @@ const syncSelections = () => {
|
||||
if (!uniqueForm.amount) {
|
||||
uniqueForm.amount = status.value.inheritConst.inheritItemUniqueMinPoint;
|
||||
}
|
||||
if (!uniqueForm.itemId) {
|
||||
uniqueForm.itemId = status.value.availableUnique[0]?.key ?? '';
|
||||
}
|
||||
if (resetStatForm.leadership === 0 && resetStatForm.strength === 0 && resetStatForm.intel === 0) {
|
||||
resetStatForm.leadership = status.value.currentStat.leadership;
|
||||
resetStatForm.strength = status.value.currentStat.strength;
|
||||
resetStatForm.intel = status.value.currentStat.intel;
|
||||
}
|
||||
};
|
||||
|
||||
const loadStatus = async () => {
|
||||
@@ -377,7 +383,7 @@ const buyRandomUnique = async () => {
|
||||
|
||||
const openUniqueAuction = async () => {
|
||||
if (!uniqueForm.itemId.trim()) {
|
||||
actionError.value = '유니크 아이템 ID를 입력해주세요.';
|
||||
actionError.value = '유니크를 선택해주세요.';
|
||||
return;
|
||||
}
|
||||
const amount = Math.max(0, Math.floor(uniqueForm.amount));
|
||||
@@ -412,12 +418,6 @@ const checkOwner = async () => {
|
||||
});
|
||||
};
|
||||
|
||||
watch(statRules, (rules) => {
|
||||
if (rules) {
|
||||
applyResetBalanced();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
void loadStatus();
|
||||
void loadJoinConfig();
|
||||
@@ -426,464 +426,561 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="inherit-page">
|
||||
<header class="inherit-header">
|
||||
<div>
|
||||
<h1 class="inherit-title">유산 포인트</h1>
|
||||
<p class="inherit-subtitle">숨김 강화와 유산 상점 기능을 관리합니다.</p>
|
||||
</div>
|
||||
<div class="inherit-actions">
|
||||
<button class="ghost" @click="loadStatus">새로고침</button>
|
||||
<button class="ghost" @click="loadLogs(true)">로그 갱신</button>
|
||||
</div>
|
||||
</header>
|
||||
<header class="top-back-bar legacy-bg0">
|
||||
<RouterLink class="top-button legacy-button" to="/">돌아가기</RouterLink>
|
||||
<strong>유산 관리</strong>
|
||||
<button class="top-button legacy-button" type="button" :disabled="loading" @click="loadStatus">갱신</button>
|
||||
</header>
|
||||
|
||||
<div v-if="error" class="inherit-error">{{ error }}</div>
|
||||
<div v-if="actionError" class="inherit-error">{{ actionError }}</div>
|
||||
<div v-if="actionMessage" class="inherit-message">{{ actionMessage }}</div>
|
||||
<main id="container" class="inherit-page legacy-bg0">
|
||||
<div v-if="error || actionError" class="notice error" role="alert">{{ error ?? actionError }}</div>
|
||||
<div v-if="actionMessage" class="notice success">{{ actionMessage }}</div>
|
||||
<div v-if="loading" class="loading-state">불러오는 중...</div>
|
||||
|
||||
<div v-if="loading">
|
||||
<SkeletonLines :lines="4" />
|
||||
</div>
|
||||
<template v-else-if="status">
|
||||
<section id="inheritance_list" class="point-grid">
|
||||
<article id="inherit_sum" class="inherit-item">
|
||||
<label for="inherit_sum_value">총 포인트</label>
|
||||
<input id="inherit_sum_value" :value="Math.floor(status.totalPoint).toLocaleString()" readonly />
|
||||
<small>다음 플레이에서 사용할 수 있는 총 포인트입니다.</small>
|
||||
</article>
|
||||
<article id="inherit_previous" class="inherit-item">
|
||||
<label for="inherit_previous_value">기존 포인트</label>
|
||||
<input id="inherit_previous_value" :value="Math.floor(previousPoint).toLocaleString()" readonly />
|
||||
<small>이전에 물려받은 포인트입니다.</small>
|
||||
</article>
|
||||
<article id="inherit_new" class="inherit-item">
|
||||
<label for="inherit_new_value">신규 포인트</label>
|
||||
<input id="inherit_new_value" :value="Math.floor(newPoint).toLocaleString()" readonly />
|
||||
<small>이번 플레이에서 얻은 총 포인트입니다.</small>
|
||||
</article>
|
||||
<div class="divider"></div>
|
||||
<article
|
||||
v-for="entry in pointEntries.filter((item) => item.key !== 'previous')"
|
||||
:id="`inherit_${entry.key}`"
|
||||
:key="entry.key"
|
||||
class="inherit-item"
|
||||
>
|
||||
<label :for="`inherit_${entry.key}_value`">{{ entry.label }}</label>
|
||||
<input
|
||||
:id="`inherit_${entry.key}_value`"
|
||||
:value="Math.floor(entry.value).toLocaleString()"
|
||||
readonly
|
||||
/>
|
||||
<small>{{ pointHelp[entry.key] }}</small>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section v-else class="inherit-grid">
|
||||
<PanelCard title="포인트 요약" subtitle="유산 포인트 구성 현황">
|
||||
<div v-if="!status" class="muted">포인트 정보를 불러오지 못했습니다.</div>
|
||||
<div v-else class="summary-panel">
|
||||
<div class="summary-head">
|
||||
<div class="summary-total">총 {{ status.totalPoint }} 포인트</div>
|
||||
<div class="summary-state" :class="{ united: status.isUnited }">
|
||||
{{ status.isUnited ? '통일 완료' : '진행 중' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-list">
|
||||
<div v-for="entry in pointEntries" :key="entry.key" class="summary-row">
|
||||
<span>{{ entry.label }}</span>
|
||||
<span>{{ entry.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-footer">
|
||||
<div>현재 전투 특기: {{ currentSpecialName }}</div>
|
||||
<div>특기 초기화 단계: {{ status.resetLevels.resetSpecialWar }}회</div>
|
||||
<div>턴 시간 초기화 단계: {{ status.resetLevels.resetTurnTime }}회</div>
|
||||
</div>
|
||||
</div>
|
||||
</PanelCard>
|
||||
<section id="inheritance_store">
|
||||
<h2 class="section-title legacy-bg1">유산 포인트 상점</h2>
|
||||
|
||||
<PanelCard title="숨김 강화" subtitle="숨김 강화 효과를 구입합니다.">
|
||||
<div v-if="!status" class="muted">숨김 강화 정보를 불러오지 못했습니다.</div>
|
||||
<div v-else class="buff-list">
|
||||
<div v-for="key in buffKeys" :key="key" class="buff-row">
|
||||
<div class="buff-info">
|
||||
<div class="buff-name">{{ buffLabels[key] }}</div>
|
||||
<div class="buff-level">현재 {{ status.buffLevels[key] ?? 0 }} 단계</div>
|
||||
</div>
|
||||
<div class="buff-action">
|
||||
<select v-model.number="buffTargets[key]" class="form-input">
|
||||
<option
|
||||
v-for="level in buffTargetOptions(key)"
|
||||
:key="level"
|
||||
:value="level"
|
||||
>
|
||||
{{ level }} 단계
|
||||
<div class="action-grid leading-actions">
|
||||
<article class="shop-item">
|
||||
<div class="control-row">
|
||||
<label for="next-special">다음 전투 특기 선택</label>
|
||||
<select id="next-special" v-model="nextSpecialKey">
|
||||
<option v-for="entry in status.availableSpecialWar" :key="entry.key" :value="entry.key">
|
||||
{{ entry.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="buff-cost">비용 {{ buffCost(key, buffTargets[key]) }}</div>
|
||||
</div>
|
||||
<small
|
||||
>{{ specialNameMap.get(nextSpecialKey) }} 특기를 다음에 얻도록 지정합니다.<br /><b
|
||||
>필요 포인트: {{ status.inheritConst.inheritSpecificSpecialPoint }}</b
|
||||
></small
|
||||
>
|
||||
<button
|
||||
class="legacy-button buy-button"
|
||||
:disabled="isUnited || actionBusy"
|
||||
@click="reserveSpecialWar"
|
||||
>
|
||||
구입
|
||||
</button>
|
||||
</article>
|
||||
|
||||
<article class="shop-item">
|
||||
<div class="control-row">
|
||||
<label for="specific-unique">유니크 경매</label>
|
||||
<select id="specific-unique" v-model="uniqueForm.itemId">
|
||||
<option disabled value="">유니크 선택</option>
|
||||
<option v-for="item in status.availableUnique" :key="item.key" :value="item.key">
|
||||
{{ item.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="control-row">
|
||||
<label for="specific-unique-amount">입찰 포인트</label>
|
||||
<input
|
||||
id="specific-unique-amount"
|
||||
v-model.number="uniqueForm.amount"
|
||||
type="number"
|
||||
:min="status.inheritConst.inheritItemUniqueMinPoint"
|
||||
:max="previousPoint"
|
||||
/>
|
||||
</div>
|
||||
<small
|
||||
>얻고자 하는 유니크 아이템으로 경매를 시작합니다. 24턴 동안 진행됩니다.<br />{{
|
||||
status.availableUnique.find((item) => item.key === uniqueForm.itemId)?.info
|
||||
}}</small
|
||||
>
|
||||
<button
|
||||
class="legacy-button buy-button"
|
||||
:disabled="isUnited || actionBusy"
|
||||
@click="openUniqueAuction"
|
||||
>
|
||||
경매 시작
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="action-grid">
|
||||
<article class="shop-item simple-item">
|
||||
<div class="control-row">
|
||||
<span>랜덤 턴 초기화</span
|
||||
><button class="legacy-button" :disabled="isUnited || actionBusy" @click="resetTurnTime">
|
||||
구입
|
||||
</button>
|
||||
</div>
|
||||
<small
|
||||
>다다음턴부터 시간이 랜덤하게 바뀝니다. (필요 포인트가 피보나치식으로 증가합니다)<br /><b
|
||||
>필요 포인트: {{ status.resetCosts.resetTurnTime }}</b
|
||||
><template v-if="turnTimeLabel"><br />적용 시간: {{ turnTimeLabel }}</template></small
|
||||
>
|
||||
</article>
|
||||
<article class="shop-item simple-item">
|
||||
<div class="control-row">
|
||||
<span>랜덤 유니크 획득</span
|
||||
><button class="legacy-button" :disabled="isUnited || actionBusy" @click="buyRandomUnique">
|
||||
구입
|
||||
</button>
|
||||
</div>
|
||||
<small
|
||||
>다음 턴에 랜덤 유니크를 얻습니다.<br /><b
|
||||
>필요 포인트: {{ status.inheritConst.inheritItemRandomPoint }}</b
|
||||
></small
|
||||
>
|
||||
</article>
|
||||
<article class="shop-item simple-item">
|
||||
<div class="control-row">
|
||||
<span>즉시 전투 특기 초기화</span
|
||||
><button class="legacy-button" :disabled="isUnited || actionBusy" @click="resetSpecialWar">
|
||||
구입
|
||||
</button>
|
||||
</div>
|
||||
<small
|
||||
>즉시 전투 특기를 초기화합니다. (필요 포인트가 피보나치식으로 증가합니다)<br /><b
|
||||
>필요 포인트: {{ status.resetCosts.resetSpecialWar }}</b
|
||||
></small
|
||||
>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="buff-grid">
|
||||
<article v-for="key in buffKeys" :key="key" class="shop-item buff-item">
|
||||
<div class="control-row">
|
||||
<label :for="`buff-${key}`">{{ buffLabels[key] }}</label>
|
||||
<input
|
||||
:id="`buff-${key}`"
|
||||
v-model.number="buffTargets[key]"
|
||||
type="number"
|
||||
:min="status.buffLevels[key] ?? 0"
|
||||
max="5"
|
||||
/>
|
||||
</div>
|
||||
<small
|
||||
>{{ buffHelp[key] }}<br /><b>필요 포인트: {{ buffCost(key, buffTargets[key]) }}</b></small
|
||||
>
|
||||
<div class="dual-buttons">
|
||||
<button
|
||||
:disabled="isUnited || actionBusy || (status.buffLevels[key] ?? 0) >= 5"
|
||||
class="legacy-button secondary"
|
||||
:disabled="actionBusy"
|
||||
@click="buffTargets[key] = status.buffLevels[key] ?? 0"
|
||||
>
|
||||
리셋
|
||||
</button>
|
||||
<button
|
||||
class="legacy-button"
|
||||
:disabled="isUnited || actionBusy"
|
||||
@click="buyHiddenBuff(key)"
|
||||
>
|
||||
구입
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="전투 특기 제어" subtitle="다음 특기 지정 및 초기화">
|
||||
<div v-if="!status" class="muted">전투 특기 정보를 불러오지 못했습니다.</div>
|
||||
<div v-else class="action-stack">
|
||||
<div class="action-row">
|
||||
<label class="form-field">
|
||||
<span>다음 전투 특기</span>
|
||||
<select v-model="nextSpecialKey" class="form-input">
|
||||
<option v-for="special in status.availableSpecialWar" :key="special.key" :value="special.key">
|
||||
{{ special.name }}
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="action-grid bottom-actions">
|
||||
<article class="shop-item">
|
||||
<div class="control-row">
|
||||
<label for="owner-target">장수 소유자 확인</label>
|
||||
<select id="owner-target" v-model="ownerTargetId">
|
||||
<option disabled value="">장수 선택</option>
|
||||
<option
|
||||
v-for="general in status.availableTargetGenerals"
|
||||
:key="general.id"
|
||||
:value="String(general.id)"
|
||||
>
|
||||
{{ general.name }}
|
||||
</option>
|
||||
</select>
|
||||
<small class="muted">비용 {{ status.inheritConst.inheritSpecificSpecialPoint }} 포인트</small>
|
||||
</label>
|
||||
<button :disabled="isUnited || actionBusy" @click="reserveSpecialWar">예약</button>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<div>
|
||||
<div class="muted">현재 전투 특기: {{ currentSpecialName }}</div>
|
||||
<div class="muted">
|
||||
초기화 비용 {{ status.resetCosts.resetSpecialWar }} 포인트
|
||||
({{ status.resetLevels.resetSpecialWar }}회)
|
||||
</div>
|
||||
<small
|
||||
>장수의 소유자를 찾습니다. 대상에게도 알림이 전송됩니다.<br /><b
|
||||
>필요 포인트: {{ status.inheritConst.inheritCheckOwnerPoint }}</b
|
||||
></small
|
||||
>
|
||||
<button class="legacy-button buy-button" :disabled="isUnited || actionBusy" @click="checkOwner">
|
||||
소유자 찾기
|
||||
</button>
|
||||
<p v-if="ownerResult" class="owner-result">
|
||||
{{ ownerResult.targetName }}의 소유자: {{ ownerResult.ownerName }}
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="shop-item stat-reset">
|
||||
<div class="stat-layout">
|
||||
<span>능력치 초기화</span>
|
||||
<div>
|
||||
<strong>기본 능력치</strong>
|
||||
<label
|
||||
>통
|
||||
<input
|
||||
v-model.number="resetStatForm.leadership"
|
||||
type="number"
|
||||
:min="statRules?.min"
|
||||
:max="statRules?.max"
|
||||
/></label>
|
||||
<label
|
||||
>무
|
||||
<input
|
||||
v-model.number="resetStatForm.strength"
|
||||
type="number"
|
||||
:min="statRules?.min"
|
||||
:max="statRules?.max"
|
||||
/></label>
|
||||
<label
|
||||
>지
|
||||
<input
|
||||
v-model.number="resetStatForm.intel"
|
||||
type="number"
|
||||
:min="statRules?.min"
|
||||
:max="statRules?.max"
|
||||
/></label>
|
||||
<strong>추가 능력치</strong>
|
||||
<label
|
||||
>통 <input v-model.number="resetStatForm.bonus[0]" type="number" min="0" max="5"
|
||||
/></label>
|
||||
<label
|
||||
>무 <input v-model.number="resetStatForm.bonus[1]" type="number" min="0" max="5"
|
||||
/></label>
|
||||
<label
|
||||
>지 <input v-model.number="resetStatForm.bonus[2]" type="number" min="0" max="5"
|
||||
/></label>
|
||||
</div>
|
||||
</div>
|
||||
<button :disabled="isUnited || actionBusy" @click="resetSpecialWar">초기화</button>
|
||||
</div>
|
||||
<small
|
||||
>시즌 당 1회에 한 해 능력치를 초기화합니다.<br /><b>필요 포인트: {{ resetStatCost }}</b
|
||||
><br /><span v-if="resetStatErrors.length">{{ resetStatErrors[0] }}</span></small
|
||||
>
|
||||
<button
|
||||
class="legacy-button buy-button"
|
||||
:disabled="isUnited || actionBusy || resetStatErrors.length > 0"
|
||||
@click="resetStats"
|
||||
>
|
||||
능력치 초기화
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</PanelCard>
|
||||
</section>
|
||||
|
||||
<PanelCard title="턴 시간 초기화" subtitle="턴 시간대를 재설정합니다.">
|
||||
<div v-if="!status" class="muted">턴 시간 정보를 불러오지 못했습니다.</div>
|
||||
<div v-else class="action-stack">
|
||||
<div class="action-row">
|
||||
<div class="muted">
|
||||
비용 {{ status.resetCosts.resetTurnTime }} 포인트 ({{ status.resetLevels.resetTurnTime }}회)
|
||||
</div>
|
||||
<button :disabled="isUnited || actionBusy" @click="resetTurnTime">턴 시간 변경</button>
|
||||
</div>
|
||||
<div v-if="turnTimeLabel" class="muted">다음 적용 시각: {{ turnTimeLabel }}</div>
|
||||
<section class="inherit-logs">
|
||||
<h2 class="section-title legacy-bg1">유산 포인트 변경 내역</h2>
|
||||
<div v-if="logLoading && logs.length === 0" class="log-empty">불러오는 중...</div>
|
||||
<div v-else-if="logs.length === 0" class="log-empty">기록이 없습니다.</div>
|
||||
<div v-for="entry in logs" v-else :key="entry.id" class="log-row">
|
||||
<small>[{{ new Date(entry.createdAt).toLocaleString('ko-KR') }}]</small>
|
||||
<span>{{ entry.text }}</span>
|
||||
</div>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="능력치 초기화" subtitle="능력치를 다시 배분합니다.">
|
||||
<div v-if="!status" class="muted">능력치 정보를 불러오지 못했습니다.</div>
|
||||
<div v-else class="stat-panel">
|
||||
<div class="stat-grid">
|
||||
<label class="form-field">
|
||||
<span>통솔</span>
|
||||
<input v-model.number="resetStatForm.leadership" type="number" class="form-input" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>무력</span>
|
||||
<input v-model.number="resetStatForm.strength" type="number" class="form-input" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>지력</span>
|
||||
<input v-model.number="resetStatForm.intel" type="number" class="form-input" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="stat-grid">
|
||||
<label class="form-field">
|
||||
<span>보너스 통솔</span>
|
||||
<input v-model.number="resetStatForm.bonus[0]" type="number" min="0" max="5" class="form-input" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>보너스 무력</span>
|
||||
<input v-model.number="resetStatForm.bonus[1]" type="number" min="0" max="5" class="form-input" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>보너스 지력</span>
|
||||
<input v-model.number="resetStatForm.bonus[2]" type="number" min="0" max="5" class="form-input" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="stat-summary">
|
||||
<div>총합 {{ resetStatTotal }} / {{ statRules?.total ?? '-' }}</div>
|
||||
<div>보너스 합 {{ resetBonusSum }} · 비용 {{ resetStatCost }}</div>
|
||||
<div v-if="resetStatErrors.length" class="stat-errors">
|
||||
<div v-for="item in resetStatErrors" :key="item">{{ item }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-row">
|
||||
<button :disabled="isUnited || actionBusy" class="ghost" @click="applyResetBalanced">균형형</button>
|
||||
<button :disabled="isUnited || actionBusy" @click="resetStats">능력치 초기화</button>
|
||||
</div>
|
||||
</div>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="유니크 상점" subtitle="유니크 아이템 관련 기능">
|
||||
<div v-if="!status" class="muted">유니크 정보를 불러오지 못했습니다.</div>
|
||||
<div v-else class="action-stack">
|
||||
<div class="action-row">
|
||||
<div class="muted">랜덤 유니크 구매 ({{ status.inheritConst.inheritItemRandomPoint }} 포인트)</div>
|
||||
<button :disabled="isUnited || actionBusy" @click="buyRandomUnique">구입</button>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<label class="form-field">
|
||||
<span>유니크 아이템 ID</span>
|
||||
<input v-model="uniqueForm.itemId" type="text" class="form-input" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>입찰 포인트</span>
|
||||
<input v-model.number="uniqueForm.amount" type="number" class="form-input" />
|
||||
<small class="muted">최소 {{ status.inheritConst.inheritItemUniqueMinPoint }} 포인트</small>
|
||||
</label>
|
||||
<button :disabled="isUnited || actionBusy" @click="openUniqueAuction">신청</button>
|
||||
</div>
|
||||
</div>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="소유자 확인" subtitle="상대 장수의 소유자를 확인합니다.">
|
||||
<div v-if="!status" class="muted">대상 장수 목록을 불러오지 못했습니다.</div>
|
||||
<div v-else class="action-stack">
|
||||
<label class="form-field">
|
||||
<span>대상 장수</span>
|
||||
<select v-model="ownerTargetId" class="form-input">
|
||||
<option v-for="general in status.availableTargetGenerals" :key="general.id" :value="String(general.id)">
|
||||
{{ general.name }}
|
||||
</option>
|
||||
</select>
|
||||
<small class="muted">비용 {{ status.inheritConst.inheritCheckOwnerPoint }} 포인트</small>
|
||||
</label>
|
||||
<button :disabled="isUnited || actionBusy" @click="checkOwner">확인</button>
|
||||
<div v-if="ownerResult" class="muted">
|
||||
{{ ownerResult.targetName }}의 소유자: {{ ownerResult.ownerName }}
|
||||
</div>
|
||||
</div>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="유산 기록" subtitle="최근 유산 로그">
|
||||
<template #actions>
|
||||
<button class="ghost" :disabled="logLoading" @click="loadLogs(true)">갱신</button>
|
||||
</template>
|
||||
<div v-if="logLoading && logs.length === 0">
|
||||
<SkeletonLines :lines="3" />
|
||||
</div>
|
||||
<div v-else-if="logs.length === 0" class="muted">기록이 없습니다.</div>
|
||||
<div v-else class="log-list">
|
||||
<div v-for="entry in logs" :key="entry.id" class="log-entry">
|
||||
<span class="log-date">{{ entry.year }}년 {{ entry.month }}월</span>
|
||||
<span>{{ entry.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-footer">
|
||||
<button class="ghost" :disabled="logLoading || logEnd" @click="loadLogs()">더 보기</button>
|
||||
</div>
|
||||
</PanelCard>
|
||||
</section>
|
||||
<button class="legacy-button more-button" :disabled="logLoading || logEnd" @click="loadLogs()">
|
||||
더 가져오기
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inherit-page {
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.inherit-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;
|
||||
}
|
||||
|
||||
.inherit-title {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.inherit-subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
}
|
||||
|
||||
.inherit-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.inherit-error {
|
||||
border: 1px solid rgba(240, 90, 90, 0.6);
|
||||
padding: 8px 10px;
|
||||
color: rgba(240, 150, 150, 0.9);
|
||||
}
|
||||
|
||||
.inherit-message {
|
||||
border: 1px solid rgba(120, 190, 120, 0.5);
|
||||
padding: 8px 10px;
|
||||
color: rgba(180, 230, 180, 0.9);
|
||||
}
|
||||
|
||||
.inherit-grid {
|
||||
.top-back-bar {
|
||||
width: min(100%, 1000px);
|
||||
min-height: 38px;
|
||||
margin: 0 auto;
|
||||
border: 1px solid #888;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
.summary-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.summary-head {
|
||||
display: flex;
|
||||
grid-template-columns: 100px 1fr 100px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
padding: 3px 6px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.summary-total {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.summary-state {
|
||||
font-size: 0.75rem;
|
||||
.top-button {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.summary-state.united {
|
||||
border-color: rgba(240, 120, 120, 0.6);
|
||||
color: rgba(240, 150, 150, 0.9);
|
||||
.inherit-page {
|
||||
width: min(100%, 1000px);
|
||||
margin: 0 auto;
|
||||
border: 1px solid #888;
|
||||
border-top: 0;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
padding: 0 8px 10px;
|
||||
color: #fff;
|
||||
font:
|
||||
14px/1.3 Pretendard,
|
||||
'Apple SD Gothic Neo',
|
||||
'Noto Sans KR',
|
||||
'Malgun Gothic',
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
.summary-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
.notice,
|
||||
.loading-state,
|
||||
.log-empty {
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.summary-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8rem;
|
||||
color: rgba(232, 221, 196, 0.8);
|
||||
.notice.error {
|
||||
color: #ffb0b0;
|
||||
}
|
||||
|
||||
.summary-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
.notice.success {
|
||||
color: #b6efb6;
|
||||
}
|
||||
|
||||
.buff-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.buff-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
border: 1px solid rgba(201, 164, 90, 0.2);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.buff-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.buff-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.buff-level {
|
||||
font-size: 0.7rem;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
}
|
||||
|
||||
.buff-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.buff-cost {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
}
|
||||
|
||||
.action-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.stat-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-grid {
|
||||
.point-grid,
|
||||
.action-grid,
|
||||
.buff-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.stat-summary {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
.inherit-item,
|
||||
.shop-item {
|
||||
padding: 8px 16px;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stat-errors {
|
||||
color: rgba(240, 150, 150, 0.9);
|
||||
.inherit-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(100px, 1fr);
|
||||
align-items: start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
.inherit-item label {
|
||||
text-align: right;
|
||||
padding: 7px 8px 0 0;
|
||||
}
|
||||
|
||||
.inherit-item input,
|
||||
.shop-item input,
|
||||
.shop-item select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid #6c757d;
|
||||
border-radius: 4px;
|
||||
background: #212529;
|
||||
color: #fff;
|
||||
padding: 6px 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.inherit-item small,
|
||||
.shop-item small {
|
||||
grid-column: 1 / -1;
|
||||
min-height: 34px;
|
||||
text-align: right;
|
||||
color: #aeb2b6;
|
||||
}
|
||||
|
||||
.inherit-item small {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
grid-column: 1 / -1;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.22);
|
||||
margin: 4px 2px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
text-align: center;
|
||||
margin: 0 -8px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.leading-actions .shop-item:first-child {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.control-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.control-row > label,
|
||||
.control-row > span {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.shop-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||
background: rgba(10, 10, 10, 0.8);
|
||||
padding: 6px 8px;
|
||||
color: inherit;
|
||||
.shop-item .buy-button {
|
||||
width: 50%;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||
padding: 6px 12px;
|
||||
font-size: 0.8rem;
|
||||
background: rgba(16, 16, 16, 0.6);
|
||||
color: inherit;
|
||||
.simple-item small {
|
||||
min-height: 55px;
|
||||
}
|
||||
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
.buff-item small {
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
.log-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.dual-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.legacy-button.secondary {
|
||||
border-color: #51585e;
|
||||
background: #5c636a;
|
||||
}
|
||||
|
||||
.bottom-actions .shop-item:first-child {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.stat-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 0.8rem;
|
||||
.stat-layout > div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.log-date {
|
||||
font-size: 0.7rem;
|
||||
color: rgba(232, 221, 196, 0.6);
|
||||
.stat-layout label {
|
||||
display: grid;
|
||||
grid-template-columns: 22px 1fr;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.log-footer {
|
||||
margin-top: 8px;
|
||||
.stat-layout strong {
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: rgba(232, 221, 196, 0.6);
|
||||
font-size: 0.75rem;
|
||||
.owner-result {
|
||||
margin: 0;
|
||||
color: #fff;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.inherit-logs {
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
.log-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(150px, 20ch) 1fr;
|
||||
gap: 8px;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
.log-row small {
|
||||
color: #aeb2b6;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.more-button {
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
a:focus-visible {
|
||||
outline: 2px solid #f39c12;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.point-grid,
|
||||
.action-grid,
|
||||
.buff-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.leading-actions .shop-item:first-child {
|
||||
grid-column: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.top-back-bar,
|
||||
.inherit-page {
|
||||
width: 500px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.point-grid,
|
||||
.action-grid,
|
||||
.buff-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.divider {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.inherit-item,
|
||||
.shop-item {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.log-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.log-row small {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ const {
|
||||
reservedNationTurns,
|
||||
messageDraftText,
|
||||
targetMailbox,
|
||||
mailboxOptions,
|
||||
mailboxGroups,
|
||||
statusLine,
|
||||
realtimeLabel,
|
||||
} = storeToRefs(dashboard);
|
||||
@@ -104,6 +104,10 @@ watch(
|
||||
<RouterLink class="ghost" to="/global-info">중원 정보</RouterLink>
|
||||
<RouterLink class="ghost" to="/current-city">현재 도시</RouterLink>
|
||||
<RouterLink class="ghost" to="/nation/generals">세력 장수</RouterLink>
|
||||
<RouterLink v-if="(boardAccess?.permission ?? -1) >= 1" class="ghost" to="/nation/secret"
|
||||
>암행부</RouterLink
|
||||
>
|
||||
<span v-else class="ghost disabled" aria-disabled="true">암행부</span>
|
||||
<RouterLink class="ghost" to="/nation/personnel">인사부</RouterLink>
|
||||
<RouterLink class="ghost" to="/troop">부대 편성</RouterLink>
|
||||
<RouterLink class="ghost" to="/nation/finance">내무부</RouterLink>
|
||||
@@ -115,10 +119,11 @@ watch(
|
||||
<RouterLink class="ghost" to="/dynasty">왕조일람</RouterLink>
|
||||
<RouterLink class="ghost" to="/yearbook">연감</RouterLink>
|
||||
<RouterLink class="ghost" to="/nation-betting">천통국 베팅</RouterLink>
|
||||
<RouterLink class="ghost" to="/traffic">접속량정보</RouterLink>
|
||||
<RouterLink class="ghost" to="/npc-list">빙의일람</RouterLink>
|
||||
<a class="ghost" href="/xe/community" target="_blank" rel="noopener">게시판</a>
|
||||
<RouterLink class="ghost" to="/battle-simulator">전투 시뮬레이터</RouterLink>
|
||||
<RouterLink class="ghost" to="/my-page">내 정보</RouterLink>
|
||||
<RouterLink class="ghost" to="/my-page">내 정보&설정</RouterLink>
|
||||
<RouterLink class="ghost" :class="{ highlight: tournamentStage === 1 }" to="/tournament"
|
||||
>토너먼트</RouterLink
|
||||
>
|
||||
@@ -216,22 +221,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>
|
||||
|
||||
@@ -251,22 +260,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">
|
||||
@@ -302,6 +295,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>
|
||||
@@ -396,6 +409,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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,349 +1,226 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import PanelCard from '../components/ui/PanelCard.vue';
|
||||
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { formatOfficerLevelText } from '../utils/nationFormat';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
type GeneralListResponse = Awaited<ReturnType<typeof trpc.nation.getGeneralList.query>>;
|
||||
|
||||
type GeneralEntry = GeneralListResponse['generals'][number];
|
||||
|
||||
type SortKey =
|
||||
| 1
|
||||
| 2
|
||||
| 3
|
||||
| 4
|
||||
| 5
|
||||
| 6
|
||||
| 7
|
||||
| 8
|
||||
| 9
|
||||
| 10
|
||||
| 11
|
||||
| 12
|
||||
| 13
|
||||
| 14
|
||||
| 15;
|
||||
|
||||
const sortOptions: Array<{ key: SortKey; label: string }> = [
|
||||
{ key: 1, label: '관직' },
|
||||
{ key: 2, label: '공헌' },
|
||||
{ key: 3, label: '경험' },
|
||||
{ key: 4, label: '통솔' },
|
||||
{ key: 5, label: '무력' },
|
||||
{ key: 6, label: '지력' },
|
||||
{ key: 7, label: '자금' },
|
||||
{ key: 8, label: '군량' },
|
||||
{ key: 9, label: '병사' },
|
||||
{ key: 10, label: '벌점' },
|
||||
{ key: 11, label: '성격' },
|
||||
{ key: 12, label: '내특' },
|
||||
{ key: 13, label: '전특' },
|
||||
{ key: 14, label: '사관' },
|
||||
{ key: 15, label: 'NPC' },
|
||||
];
|
||||
|
||||
type Result = Awaited<ReturnType<typeof trpc.nation.getGeneralList.query>>;
|
||||
type General = Result['generals'][number];
|
||||
type Sort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15;
|
||||
const data = ref<Result | null>(null);
|
||||
const error = ref('');
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const data = ref<GeneralListResponse | null>(null);
|
||||
const sortKey = ref<SortKey>(1);
|
||||
const filterText = ref('');
|
||||
|
||||
const resolveErrorMessage = (value: unknown): string => {
|
||||
if (value instanceof Error) {
|
||||
return value.message;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
return 'unknown_error';
|
||||
};
|
||||
|
||||
const loadGenerals = async () => {
|
||||
if (loading.value) {
|
||||
return;
|
||||
}
|
||||
const sort = ref<Sort>(1);
|
||||
const options = [
|
||||
'관직',
|
||||
'계급',
|
||||
'명성',
|
||||
'통솔',
|
||||
'무력',
|
||||
'지력',
|
||||
'자금',
|
||||
'군량',
|
||||
'병사',
|
||||
'벌점',
|
||||
'성격',
|
||||
'내특',
|
||||
'전특',
|
||||
'사관',
|
||||
'NPC',
|
||||
];
|
||||
const visibleCrew = (general: General): number | null => ('crew' in general ? general.crew : null);
|
||||
const load = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
error.value = '';
|
||||
try {
|
||||
data.value = await trpc.nation.getGeneralList.query();
|
||||
} catch (err) {
|
||||
error.value = resolveErrorMessage(err);
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : '세력 장수를 불러오지 못했습니다.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const sortGenerals = (list: GeneralEntry[]): GeneralEntry[] => {
|
||||
const key = sortKey.value;
|
||||
const sorted = [...list].sort((lhs, rhs) => {
|
||||
switch (key) {
|
||||
case 1:
|
||||
return rhs.officerLevel - lhs.officerLevel;
|
||||
case 2:
|
||||
return rhs.dedication - lhs.dedication;
|
||||
case 3:
|
||||
return rhs.experience - lhs.experience;
|
||||
case 4:
|
||||
return rhs.stats.leadership - lhs.stats.leadership;
|
||||
case 5:
|
||||
return rhs.stats.strength - lhs.stats.strength;
|
||||
case 6:
|
||||
return rhs.stats.intelligence - lhs.stats.intelligence;
|
||||
case 7:
|
||||
return rhs.gold - lhs.gold;
|
||||
case 8:
|
||||
return rhs.rice - lhs.rice;
|
||||
case 9:
|
||||
return rhs.crew - lhs.crew;
|
||||
case 10:
|
||||
return 0;
|
||||
case 11:
|
||||
return (lhs.personality?.name ?? '').localeCompare(rhs.personality?.name ?? '');
|
||||
case 12:
|
||||
return (lhs.specialDomestic?.name ?? '').localeCompare(rhs.specialDomestic?.name ?? '');
|
||||
case 13:
|
||||
return (lhs.specialWar?.name ?? '').localeCompare(rhs.specialWar?.name ?? '');
|
||||
case 14:
|
||||
return rhs.belong - lhs.belong;
|
||||
case 15:
|
||||
return rhs.npcState - lhs.npcState;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
if (key === 11 || key === 12 || key === 13) {
|
||||
return sorted;
|
||||
}
|
||||
|
||||
return sorted;
|
||||
};
|
||||
|
||||
const filteredGenerals = computed(() => {
|
||||
const list = data.value?.generals ?? [];
|
||||
const keyword = filterText.value.trim().toLowerCase();
|
||||
const filtered = keyword
|
||||
? list.filter((general) => {
|
||||
return (
|
||||
general.name.toLowerCase().includes(keyword) ||
|
||||
(general.cityName ?? '').toLowerCase().includes(keyword) ||
|
||||
(general.officerCityName ?? '').toLowerCase().includes(keyword)
|
||||
);
|
||||
})
|
||||
: list;
|
||||
|
||||
return sortGenerals(filtered);
|
||||
});
|
||||
|
||||
const nationLevel = computed(() => data.value?.nation.level ?? 0);
|
||||
|
||||
const formatSpecial = (general: GeneralEntry): string => {
|
||||
const domestic = general.specialDomestic?.name ?? '-';
|
||||
const war = general.specialWar?.name ?? '-';
|
||||
return `${domestic} / ${war}`;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
void loadGenerals();
|
||||
});
|
||||
const generals = computed(() =>
|
||||
[...(data.value?.generals ?? [])].sort((a, b) => {
|
||||
if (sort.value === 1) return b.officerLevel - a.officerLevel || a.id - b.id;
|
||||
if (sort.value === 2) return b.dedicationLevel - a.dedicationLevel || a.id - b.id;
|
||||
if (sort.value === 3) return b.experienceLevel - a.experienceLevel || a.id - b.id;
|
||||
if (sort.value === 4) return b.stats.leadership - a.stats.leadership || a.id - b.id;
|
||||
if (sort.value === 5) return b.stats.strength - a.stats.strength || a.id - b.id;
|
||||
if (sort.value === 6) return b.stats.intelligence - a.stats.intelligence || a.id - b.id;
|
||||
if (sort.value === 7) return b.gold - a.gold || a.id - b.id;
|
||||
if (sort.value === 8) return b.rice - a.rice || a.id - b.id;
|
||||
if (sort.value === 9) return (visibleCrew(b) ?? -1) - (visibleCrew(a) ?? -1) || a.id - b.id;
|
||||
if (sort.value === 10) return b.refreshScoreTotal - a.refreshScoreTotal || a.id - b.id;
|
||||
if (sort.value === 11) return (a.personality?.name ?? '').localeCompare(b.personality?.name ?? '');
|
||||
if (sort.value === 12) return (a.specialDomestic?.name ?? '').localeCompare(b.specialDomestic?.name ?? '');
|
||||
if (sort.value === 13) return (a.specialWar?.name ?? '').localeCompare(b.specialWar?.name ?? '');
|
||||
if (sort.value === 14) return b.belong - a.belong || a.id - b.id;
|
||||
if (sort.value === 15) return b.npcState - a.npcState || a.id - b.id;
|
||||
return a.id - b.id;
|
||||
})
|
||||
);
|
||||
const special = (general: General) => `${general.specialDomestic?.name ?? '-'} / ${general.specialWar?.name ?? '-'}`;
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="nation-page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">세력 장수</h1>
|
||||
<p class="page-subtitle">세력 내 장수 현황 및 정렬</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<RouterLink class="ghost" to="/">메인</RouterLink>
|
||||
<RouterLink class="ghost" to="/nation/cities">세력 도시</RouterLink>
|
||||
<RouterLink class="ghost" to="/nation/personnel">인사부</RouterLink>
|
||||
<button class="ghost" @click="loadGenerals">새로고침</button>
|
||||
</div>
|
||||
<main class="general-page legacy-bg0">
|
||||
<header>
|
||||
<strong>세력 장수</strong>
|
||||
<span
|
||||
><RouterLink to="/">돌아가기</RouterLink>
|
||||
<button :disabled="loading" @click="load">새로고침</button></span
|
||||
>
|
||||
</header>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
|
||||
<PanelCard title="세력 장수 목록" subtitle="국가 소속 장수들을 확인합니다.">
|
||||
<template #actions>
|
||||
<div class="toolbar-actions">
|
||||
<select v-model.number="sortKey" class="select-input">
|
||||
<option v-for="option in sortOptions" :key="option.key" :value="option.key">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<input v-model="filterText" class="filter-input" placeholder="이름/도시 검색" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="list-meta">총 {{ filteredGenerals.length }}명</div>
|
||||
|
||||
<SkeletonLines v-if="loading" :lines="6" />
|
||||
<div v-else class="table-scroll">
|
||||
<table class="nation-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>이름</th>
|
||||
<th>관직</th>
|
||||
<th>공헌</th>
|
||||
<th>경험</th>
|
||||
<th>통솔</th>
|
||||
<th>무력</th>
|
||||
<th>지력</th>
|
||||
<th>자금</th>
|
||||
<th>군량</th>
|
||||
<th>병사</th>
|
||||
<th>성격</th>
|
||||
<th>특기</th>
|
||||
<th>사관</th>
|
||||
<th>현재 도시</th>
|
||||
<th>관직 도시</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="general in filteredGenerals" :key="general.id">
|
||||
<td>
|
||||
<span v-if="general.npcState > 0" class="npc-tag">NPC</span>
|
||||
{{ general.name }}
|
||||
</td>
|
||||
<td>{{ formatOfficerLevelText(general.officerLevel, nationLevel) }}</td>
|
||||
<td>{{ general.dedication }}</td>
|
||||
<td>{{ general.experience }}</td>
|
||||
<td>{{ general.stats.leadership }}</td>
|
||||
<td>{{ general.stats.strength }}</td>
|
||||
<td>{{ general.stats.intelligence }}</td>
|
||||
<td>{{ general.gold }}</td>
|
||||
<td>{{ general.rice }}</td>
|
||||
<td>{{ general.crew }}</td>
|
||||
<td>{{ general.personality?.name ?? '-' }}</td>
|
||||
<td>{{ formatSpecial(general) }}</td>
|
||||
<td>{{ general.belong > 0 ? general.belong : '-' }}</td>
|
||||
<td>{{ general.cityName ?? '-' }}</td>
|
||||
<td>{{ general.officerCityName ?? '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</PanelCard>
|
||||
<section class="sort">
|
||||
정렬순서 :
|
||||
<select v-model.number="sort" aria-label="세력 장수 정렬">
|
||||
<option v-for="(label, index) in options" :key="label" :value="index + 1">{{ label }}</option>
|
||||
</select>
|
||||
<button>정렬하기</button>
|
||||
<small v-if="data">열람 등급 {{ data.viewer.permission }}</small>
|
||||
</section>
|
||||
<p v-if="error" class="state error" role="alert">{{ error }}</p>
|
||||
<p v-else-if="loading" class="state">불러오는 중...</p>
|
||||
<div v-else class="scroll">
|
||||
<table id="nation-general-list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>이 름</th>
|
||||
<th>관 직</th>
|
||||
<th>통무지</th>
|
||||
<th>명성/계급</th>
|
||||
<th>자금</th>
|
||||
<th>군량</th>
|
||||
<th>도시</th>
|
||||
<th>부대</th>
|
||||
<th>병사</th>
|
||||
<th>성격</th>
|
||||
<th>특기</th>
|
||||
<th>사관</th>
|
||||
<th>벌점</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="general in generals" :key="general.id">
|
||||
<td :class="`npc-${general.npcState}`">{{ general.name }}</td>
|
||||
<td>{{ formatOfficerLevelText(general.officerLevel, data?.nation.level) }}</td>
|
||||
<td>
|
||||
{{ general.stats.leadership }}∥{{ general.stats.strength }}∥{{ general.stats.intelligence }}
|
||||
</td>
|
||||
<td>
|
||||
Lv {{ general.experienceLevel }}<br />{{
|
||||
general.dedicationLevel ? `${11 - general.dedicationLevel}품관` : '무품관'
|
||||
}}
|
||||
</td>
|
||||
<td>{{ general.gold.toLocaleString() }}</td>
|
||||
<td>{{ general.rice.toLocaleString() }}</td>
|
||||
<td>{{ general.cityName ?? '?' }}</td>
|
||||
<td>{{ general.troopName ?? '?' }}</td>
|
||||
<td>{{ visibleCrew(general)?.toLocaleString() ?? '?' }}</td>
|
||||
<td :title="general.personality?.info ?? ''">{{ general.personality?.name ?? '-' }}</td>
|
||||
<td
|
||||
:title="
|
||||
[general.specialDomestic?.info, general.specialWar?.info].filter(Boolean).join('\n')
|
||||
"
|
||||
>
|
||||
{{ special(general) }}
|
||||
</td>
|
||||
<td>{{ general.belong }}</td>
|
||||
<td>{{ general.refreshScoreTotal }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<footer><RouterLink to="/">돌아가기</RouterLink></footer>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.nation-page {
|
||||
.general-page {
|
||||
width: 1000px;
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
margin: 8px auto 0;
|
||||
font:
|
||||
16px 'Times New Roman',
|
||||
serif;
|
||||
color: #fff;
|
||||
}
|
||||
header,
|
||||
.sort,
|
||||
footer,
|
||||
.state {
|
||||
position: relative;
|
||||
border: 1px solid #777;
|
||||
padding: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
header {
|
||||
min-height: 39px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid rgba(201, 164, 90, 0.4);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||
padding: 6px 12px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
background: rgba(16, 16, 16, 0.6);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #f5b7b1;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.select-input {
|
||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||
background: rgba(16, 16, 16, 0.8);
|
||||
color: rgba(232, 221, 196, 0.9);
|
||||
padding: 6px 8px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.filter-input {
|
||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||
background: rgba(16, 16, 16, 0.8);
|
||||
color: rgba(232, 221, 196, 0.9);
|
||||
padding: 6px 8px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.list-meta {
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(232, 221, 196, 0.6);
|
||||
}
|
||||
|
||||
.table-scroll {
|
||||
overflow-x: auto;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.nation-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.nation-table th,
|
||||
.nation-table td {
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid rgba(201, 164, 90, 0.2);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nation-table thead th {
|
||||
font-size: 0.7rem;
|
||||
color: rgba(232, 221, 196, 0.6);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.npc-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.6rem;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
header span {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
}
|
||||
button,
|
||||
select {
|
||||
border: 1px solid #888;
|
||||
border-radius: 2px;
|
||||
background: #222;
|
||||
color: #fff;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
.sort small {
|
||||
float: right;
|
||||
margin-right: 6px;
|
||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||
color: rgba(232, 221, 196, 0.8);
|
||||
color: #ccc;
|
||||
}
|
||||
.scroll {
|
||||
width: 1030px;
|
||||
margin-left: -15px;
|
||||
min-height: calc(100vh - 112px);
|
||||
overflow: auto;
|
||||
}
|
||||
table {
|
||||
width: 1030px;
|
||||
min-width: 1030px;
|
||||
border-collapse: separate;
|
||||
table-layout: fixed;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
border: 1px solid #777;
|
||||
padding: 3px;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
th {
|
||||
height: 30px;
|
||||
background: #14241b url('/image/game/back_green.jpg');
|
||||
font-weight: 400;
|
||||
}
|
||||
tbody tr {
|
||||
height: 66px;
|
||||
background: rgb(0 0 0 / 18%);
|
||||
}
|
||||
.npc-1 {
|
||||
color: cyan;
|
||||
}
|
||||
.npc-2,
|
||||
.npc-3,
|
||||
.npc-4,
|
||||
.npc-5 {
|
||||
color: #aaa;
|
||||
}
|
||||
.error {
|
||||
color: #ff7373;
|
||||
}
|
||||
@media (max-width: 1000px) {
|
||||
.general-page {
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
type Result = Awaited<ReturnType<typeof trpc.nation.getSecretGeneralList.query>>;
|
||||
type Sort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
|
||||
const data = ref<Result | null>(null);
|
||||
const error = ref('');
|
||||
const loading = ref(false);
|
||||
const sort = ref<Sort>(7);
|
||||
const options = ['자금', '군량', '도시', '병종', '병사', '삭제턴', '턴', '부대'];
|
||||
const load = async () => {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
data.value = await trpc.nation.getSecretGeneralList.query();
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : '암행부를 불러오지 못했습니다.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
const generals = computed(() =>
|
||||
[...(data.value?.generals ?? [])].sort((a, b) => {
|
||||
if (sort.value === 1) return b.gold - a.gold || a.id - b.id;
|
||||
if (sort.value === 2) return b.rice - a.rice || a.id - b.id;
|
||||
if (sort.value === 3) return a.cityId - b.cityId || a.id - b.id;
|
||||
if (sort.value === 4) return b.crewTypeId - a.crewTypeId || a.id - b.id;
|
||||
if (sort.value === 5) return b.crew - a.crew || a.id - b.id;
|
||||
if (sort.value === 6) return a.killTurn - b.killTurn || a.id - b.id;
|
||||
if (sort.value === 7) return a.turnTime.localeCompare(b.turnTime) || a.id - b.id;
|
||||
return b.troopId - a.troopId || a.id - b.id;
|
||||
})
|
||||
);
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="secret-page">
|
||||
<table class="layout legacy-bg0 title">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>암 행 부<br /><RouterLink to="/">창 닫기</RouterLink></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
정렬순서 :
|
||||
<select v-model.number="sort" aria-label="암행부 정렬">
|
||||
<option v-for="(label, index) in options" :key="label" :value="index + 1">
|
||||
{{ label }}
|
||||
</option>
|
||||
</select>
|
||||
<button>정렬하기</button> <button :disabled="loading" @click="load">새로고침</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-if="error" class="state error legacy-bg0" role="alert">{{ error }}</p>
|
||||
<p v-else-if="loading" class="state legacy-bg0">불러오는 중...</p>
|
||||
<template v-else-if="data">
|
||||
<table class="layout summary legacy-bg0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>전체 금</th>
|
||||
<td>{{ data.summary.gold.toLocaleString() }}</td>
|
||||
<th>전체 쌀</th>
|
||||
<td>{{ data.summary.rice.toLocaleString() }}</td>
|
||||
<th>평균 금</th>
|
||||
<td>{{ data.summary.averageGold.toFixed(2) }}</td>
|
||||
<th>평균 쌀</th>
|
||||
<td>{{ data.summary.averageRice.toFixed(2) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>전체 병력/장수</th>
|
||||
<td>{{ data.summary.crew.toLocaleString() }}/{{ data.summary.generalCount }}</td>
|
||||
<template v-for="level in [90, 80, 60] as const" :key="level"
|
||||
><th>훈사 {{ level }} 병력/장수</th>
|
||||
<td>
|
||||
{{ data.summary.readiness[level].crew.toLocaleString() }}/{{
|
||||
data.summary.readiness[level].generals
|
||||
}}
|
||||
</td></template
|
||||
>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table id="secret-general-list" class="layout list legacy-bg0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>이 름</th>
|
||||
<th>통무지</th>
|
||||
<th>부 대</th>
|
||||
<th>자 금</th>
|
||||
<th>군 량</th>
|
||||
<th>도시</th>
|
||||
<th>守</th>
|
||||
<th>병 종</th>
|
||||
<th>병 사</th>
|
||||
<th>훈련</th>
|
||||
<th>사기</th>
|
||||
<th class="commands">명 령</th>
|
||||
<th>삭턴</th>
|
||||
<th>턴</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="general in generals" :key="general.id">
|
||||
<td>{{ general.name }}<br />Lv {{ general.experienceLevel }}</td>
|
||||
<td>
|
||||
{{ general.stats.leadership }}∥{{ general.stats.strength }}∥{{ general.stats.intelligence }}
|
||||
</td>
|
||||
<td>{{ general.troopName ?? '-' }}</td>
|
||||
<td>{{ general.gold }}</td>
|
||||
<td>{{ general.rice }}</td>
|
||||
<td>{{ general.cityName ?? '-' }}</td>
|
||||
<td>{{ general.defenceTrainText }}</td>
|
||||
<td>{{ general.crewTypeId }}</td>
|
||||
<td>{{ general.crew }}</td>
|
||||
<td>{{ general.train }}</td>
|
||||
<td>{{ general.atmos }}</td>
|
||||
<td class="turns">
|
||||
<template v-if="general.npcState >= 2">NPC 장수</template
|
||||
><template v-else
|
||||
><div v-for="(command, index) in general.reservedCommands" :key="index">
|
||||
{{ index + 1 }} : {{ command }}
|
||||
</div></template
|
||||
>
|
||||
</td>
|
||||
<td>{{ general.killTurn }}</td>
|
||||
<td>{{ general.turnTime.slice(11, 16) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
<table class="layout legacy-bg0 footer">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><RouterLink to="/">창 닫기</RouterLink></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.secret-page {
|
||||
width: 1000px;
|
||||
margin: 8px auto 0;
|
||||
font:
|
||||
16px 'Times New Roman',
|
||||
serif;
|
||||
color: #fff;
|
||||
}
|
||||
.layout {
|
||||
width: 1000px;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
td,
|
||||
th,
|
||||
.state {
|
||||
border: 1px solid #777;
|
||||
padding: 3px;
|
||||
text-align: center;
|
||||
font-weight: 400;
|
||||
}
|
||||
button,
|
||||
select {
|
||||
border: 1px solid #888;
|
||||
border-radius: 2px;
|
||||
background: #222;
|
||||
color: #fff;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
.summary {
|
||||
margin: 5px auto;
|
||||
}
|
||||
.summary th,
|
||||
.list th {
|
||||
background: #14241b url('/image/game/back_green.jpg');
|
||||
}
|
||||
.summary th {
|
||||
width: 120px;
|
||||
}
|
||||
.list {
|
||||
width: 1030px;
|
||||
margin-left: -15px;
|
||||
border-collapse: separate;
|
||||
}
|
||||
.list tbody tr {
|
||||
height: 39px;
|
||||
}
|
||||
.commands {
|
||||
width: 213px;
|
||||
}
|
||||
.turns {
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
}
|
||||
.error {
|
||||
color: #ff7373;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 5px;
|
||||
}
|
||||
@media (max-width: 1000px) {
|
||||
.secret-page {
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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"><비활성화 항목들></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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user