Merge branch 'main' into audit/lint-test-baseline-20260726

# Conflicts:
#	app/game-api/src/battleSim/worker.ts
#	app/game-api/src/router/general/index.ts
This commit is contained in:
2026-07-26 05:57:45 +00:00
80 changed files with 9334 additions and 1428 deletions
@@ -6,6 +6,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>
@@ -366,6 +379,11 @@ const officerLevelOptions = [
color: #f0b6b6;
}
.action:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.form-block {
display: flex;
flex-direction: column;
@@ -1,13 +1,25 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import SkeletonLines from '../ui/SkeletonLines.vue';
import { computed, reactive } from 'vue';
import type { MessageType } from '@sammo-ts/logic';
import SkeletonLines from '../ui/SkeletonLines.vue';
import MessagePlate from './MessagePlate.vue';
interface MessageTarget {
generalId: number;
generalName: string;
nationId: number;
nationName: string;
color: string;
icon: string;
}
interface MessageEntry {
id: number;
text: string;
time: string;
msgType: MessageType;
src: MessageTarget;
dest: MessageTarget | null;
option?: Record<string, unknown> | null;
}
@@ -16,6 +28,22 @@ interface MessageBucket {
public: MessageEntry[];
national: MessageEntry[];
diplomacy: MessageEntry[];
permission: number;
latestRead: {
private: number;
diplomacy: number;
};
}
interface MailboxGroup {
label: string;
color?: string;
options: Array<{
label: string;
value: number;
disabled?: boolean;
color?: string;
}>;
}
const props = defineProps<{
@@ -23,7 +51,10 @@ const props = defineProps<{
loading: boolean;
targetMailbox: number;
draftText: string;
mailboxOptions: Array<{ label: string; value: number; disabled?: boolean }>;
mailboxGroups: MailboxGroup[];
generalId: number;
generalName: string;
nationId: number;
canRespondDiplomacy: boolean;
}>();
@@ -34,213 +65,390 @@ const emit = defineEmits<{
(event: 'refresh'): void;
(event: 'load-older', type: MessageType): void;
(event: 'respond', messageId: number, response: boolean): void;
(event: 'read-latest', type: 'private' | 'diplomacy', messageId: number): void;
(event: 'delete', messageId: number): void;
}>();
const messageTabs: Array<{ key: MessageType; label: string }> = [
{ key: 'public', label: '전체' },
{ key: 'national', label: '국가' },
{ key: 'private', label: '개인' },
{ key: 'diplomacy', label: '외교' },
const sections: Array<{ type: MessageType; label: string; className: string }> = [
{ type: 'public', label: '전체 메시지', className: 'PublicTalk' },
{ type: 'national', label: '국가 메시지', className: 'NationalTalk' },
{ type: 'private', label: '개인 메시지', className: 'PrivateTalk' },
{ type: 'diplomacy', label: '외교 메시지', className: 'DiplomacyTalk' },
];
const activeTab = ref<MessageType>('public');
const activeMessages = computed(() => {
if (!props.messages) {
return [] as MessageEntry[];
}
return props.messages[activeTab.value] ?? [];
const visibleLimits = reactive<Record<MessageType, number>>({
public: Number.POSITIVE_INFINITY,
national: Number.POSITIVE_INFINITY,
private: Number.POSITIVE_INFINITY,
diplomacy: Number.POSITIVE_INFINITY,
});
const bucket = (type: MessageType): MessageEntry[] => props.messages?.[type] ?? [];
const visibleMessages = (type: MessageType): MessageEntry[] => bucket(type).slice(0, visibleLimits[type]);
const permission = computed(() => props.messages?.permission ?? -1);
const setMailbox = (value: string) => {
const parsed = Number(value);
emit('update:targetMailbox', Number.isFinite(parsed) ? parsed : 0);
};
const isDiplomacyPrompt = (message: MessageEntry): boolean =>
message.msgType === 'diplomacy' &&
(message.option?.action === 'noAggression' ||
message.option?.action === 'cancelNA' ||
message.option?.action === 'stopWar');
const respond = (messageId: number, response: boolean) => {
if (!window.confirm(response ? '수락하시겠습니까?' : '거절하시겠습니까?')) {
const submit = () => {
if (!props.draftText.trim()) {
emit('refresh');
return;
}
emit('send');
};
const newestIncomingId = (type: 'private' | 'diplomacy'): number =>
bucket(type)
.filter((message) => message.src.generalId !== props.generalId)
.reduce((latest, message) => Math.max(latest, message.id), 0);
const canMarkRead = (type: 'private' | 'diplomacy'): boolean => {
if (!props.messages) {
return false;
}
const newest = newestIncomingId(type);
return newest > props.messages.latestRead[type];
};
const markRead = (type: 'private' | 'diplomacy') => {
const messageId = newestIncomingId(type);
if (messageId > 0) {
emit('read-latest', type, messageId);
}
};
const setSectionMailbox = (type: MessageType) => {
if (type === 'public') {
emit('update:targetMailbox', 9999);
} else if (type === 'national') {
emit('update:targetMailbox', 9000 + props.nationId);
}
};
const setReplyTarget = (type: MessageType, target: MessageTarget) => {
const mailbox =
(type === 'diplomacy' || type === 'national') && target.nationId !== props.nationId
? 9000 + target.nationId
: target.generalId;
if (mailbox > 0) {
emit('update:targetMailbox', mailbox);
}
};
const fold = (type: MessageType) => {
if (bucket(type).length >= 10) {
visibleLimits[type] = 10;
}
};
const forwardResponse = (messageId: number, response: boolean) => {
emit('respond', messageId, response);
};
</script>
<template>
<div class="message-panel">
<div class="message-input">
<select
class="message-select"
:value="targetMailbox"
@change="setMailbox(($event.target as HTMLSelectElement).value)"
>
<option
v-for="option in mailboxOptions"
:key="option.label"
:value="option.value"
:disabled="option.disabled"
<div class="MessagePanel">
<div class="MessageInputForm">
<div id="mailbox_list-col">
<select
id="mailbox_list"
class="message-select"
:value="targetMailbox"
aria-label="메시지 수신 대상"
@change="setMailbox(($event.target as HTMLSelectElement).value)"
>
{{ option.label }}
</option>
</select>
<input
class="message-text"
type="text"
maxlength="99"
:value="draftText"
placeholder="메시지 입력"
@input="emit('update:draftText', ($event.target as HTMLInputElement).value)"
@keydown.enter="emit('send')"
/>
<button class="message-send" @click="emit('send')">전송</button>
</div>
<div class="message-tabs">
<button
v-for="tab in messageTabs"
:key="tab.key"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key"
>
{{ tab.label }}
</button>
<button class="refresh" @click="emit('refresh')">갱신</button>
</div>
<div v-if="props.loading">
<SkeletonLines :lines="4" />
</div>
<div v-else-if="!props.messages" class="empty">메시지를 불러오지 못했습니다.</div>
<div v-else class="message-list">
<div v-if="activeMessages.length === 0" class="empty">메시지가 없습니다.</div>
<div v-else>
<div v-for="message in activeMessages" :key="message.id" class="message-item">
<div class="text">{{ message.text }}</div>
<div v-if="isDiplomacyPrompt(message)" class="message-response">
<button class="accept" :disabled="!canRespondDiplomacy" @click="respond(message.id, true)">
수락
</button>
<button class="decline" :disabled="!canRespondDiplomacy" @click="respond(message.id, false)">
거절
</button>
</div>
<div class="time">{{ message.time }}</div>
</div>
<button class="load-older" @click="emit('load-older', activeTab)">이전 메시지</button>
<optgroup
v-for="group in mailboxGroups"
:key="group.label"
:label="group.label"
:style="{ backgroundColor: group.color ?? '#000000', color: '#ffffff' }"
>
<option
v-for="option in group.options"
:key="`${group.label}-${option.value}`"
:value="option.value"
:disabled="option.disabled"
:style="{ backgroundColor: option.color ?? '#000000', color: '#ffffff' }"
>
{{ option.label }}
</option>
</optgroup>
</select>
</div>
<div id="msg_input-col">
<input
class="message-text"
type="text"
maxlength="99"
:value="draftText"
aria-label="메시지 입력"
@input="emit('update:draftText', ($event.target as HTMLInputElement).value)"
@keydown.enter="submit"
/>
</div>
<div id="msg_submit-col">
<button class="message-send" type="button" @click="submit">서신전달&amp;갱신</button>
</div>
</div>
<div v-if="loading && !messages" class="message-loading">
<SkeletonLines :lines="4" />
</div>
<template v-else>
<section
v-for="section in sections"
:key="section.type"
:class="['message-section', section.className]"
:data-message-type="section.type"
>
<div class="stickyAnchor"></div>
<header class="BoardHeader">
<div class="header-label">{{ section.label }}</div>
<button
v-if="section.type === 'public' || section.type === 'national'"
class="btn-more-small action-primary"
type="button"
@click="setSectionMailbox(section.type)"
>
여기로
</button>
<button
v-else
class="btn-more-small action-secondary"
type="button"
:disabled="!canMarkRead(section.type)"
@click="markRead(section.type)"
>
모두 읽음
</button>
</header>
<div v-if="bucket(section.type).length === 0" class="empty-message">메시지가 없습니다.</div>
<div v-else class="MessageList">
<MessagePlate
v-for="message in visibleMessages(section.type)"
:key="message.id"
:message="message"
:general-id="generalId"
:general-name="generalName"
:nation-id="nationId"
:permission="permission"
:can-respond-diplomacy="canRespondDiplomacy"
@set-target="setReplyTarget"
@delete="emit('delete', $event)"
@respond="forwardResponse"
/>
<div class="Actions">
<button class="fold-message" type="button" @click="fold(section.type)">접기</button>
<button class="load-older" type="button" @click="emit('load-older', section.type)">
이전 메시지 불러오기
</button>
</div>
</div>
</section>
</template>
</div>
</template>
<style scoped>
.message-panel {
display: flex;
flex-direction: column;
gap: 12px;
.MessagePanel {
color: #fff;
font-size: 14px;
}
.message-input {
.MessageInputForm {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 4fr) minmax(0, 1fr);
grid-template-areas: 'mailbox input submit';
background-color: #302016;
background-image: url('/image/game/back_walnut.jpg');
}
#mailbox_list-col {
grid-area: mailbox;
}
#msg_input-col {
grid-area: input;
}
#msg_submit-col {
grid-area: submit;
}
#mailbox_list-col,
#msg_input-col,
#msg_submit-col {
display: grid;
grid-template-columns: minmax(90px, 120px) 1fr auto;
gap: 6px;
}
.message-select,
.message-text,
.message-send {
height: 35.5px;
border: 1px solid #6c757d;
border-radius: 4px;
font: inherit;
}
.message-select {
width: 100%;
background-color: #212529;
padding: 4px 30px 4px 12px;
color: #fff;
font-weight: 700;
}
.message-text {
background: rgba(16, 16, 16, 0.8);
border: 1px solid rgba(201, 164, 90, 0.4);
color: inherit;
padding: 6px;
font-size: 0.75rem;
width: 100%;
background-color: #fff;
padding: 4px 8px;
color: #212529;
}
.message-send,
.action-primary {
background-color: #337ab7;
color: #fff;
}
.message-send {
border: 1px solid rgba(201, 164, 90, 0.4);
padding: 6px 10px;
font-size: 0.75rem;
cursor: pointer;
}
.message-tabs {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.message-tabs button {
border: 1px solid rgba(201, 164, 90, 0.4);
padding: 4px 8px;
font-size: 0.7rem;
cursor: pointer;
}
.message-tabs button.active {
background: rgba(201, 164, 90, 0.2);
.message-send:hover {
background-color: #375a7f;
}
.message-tabs .refresh {
margin-left: auto;
.message-send:focus,
.message-send:focus-visible {
outline: none !important;
outline-width: 0 !important;
box-shadow: none !important;
}
.message-list {
.message-loading {
padding: 8px;
}
.message-section {
min-width: 0;
}
.BoardHeader {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 25px;
align-items: center;
outline: 1px solid gray;
background-color: #302016;
background-image: url('/image/game/back_walnut.jpg');
color: #fff;
}
.message-item {
border: 1px solid rgba(201, 164, 90, 0.2);
padding: 6px;
font-size: 0.75rem;
.header-label {
flex: 1;
}
.message-item .time {
margin-top: 4px;
font-size: 0.65rem;
color: rgba(232, 221, 196, 0.6);
}
.message-response {
display: flex;
justify-content: flex-end;
gap: 4px;
margin-top: 5px;
margin-right: 5px;
}
.message-response button {
border: 1px solid rgba(201, 164, 90, 0.4);
padding: 3px 10px;
font-size: 0.7rem;
.btn-more-small {
margin: 1px;
border: 1px solid transparent;
border-radius: 3px;
padding: 2px 6px;
font-size: 11.2px;
line-height: 1.5;
cursor: pointer;
}
.message-response .accept {
color: #8fd18f;
.action-secondary {
border-color: #6c757d;
background-color: #6c757d;
color: #fff;
}
.message-response .decline {
color: #e09a9a;
.btn-more-small:disabled {
cursor: default;
opacity: 0.65;
}
.message-response button:disabled {
cursor: not-allowed;
opacity: 0.5;
.empty-message {
min-height: 22px;
}
.MessageList {
overflow-x: hidden;
}
.Actions {
display: grid;
}
.fold-message,
.load-older {
border: 1px solid transparent;
padding: 6px 12px;
color: #fff;
font: inherit;
cursor: pointer;
}
.fold-message {
background-color: #212529;
}
.load-older {
border: 1px dashed rgba(201, 164, 90, 0.3);
padding: 6px;
font-size: 0.7rem;
cursor: pointer;
background-color: #6c757d;
}
.empty {
color: rgba(232, 221, 196, 0.6);
@media (min-width: 940px) {
.MessagePanel {
display: grid;
grid-template-columns: 1fr 1fr;
}
.MessageInputForm,
.message-loading {
grid-column: 1 / 3;
}
.PublicTalk,
.PrivateTalk {
border-right: 1px solid gray;
}
.fold-message {
display: none;
}
.MessageList {
overflow-y: auto;
}
}
@media (max-width: 939.98px) {
.MessageInputForm {
position: sticky;
z-index: 5;
top: 0;
grid-template-columns: 1fr 1fr;
grid-template-areas:
'mailbox submit'
'input input';
}
.message-text {
height: 33.5px;
}
.BoardHeader {
position: sticky;
z-index: 4;
top: 62px;
}
}
</style>
@@ -0,0 +1,431 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import type { MessageType } from '@sammo-ts/logic';
interface MessageTarget {
generalId: number;
generalName: string;
nationId: number;
nationName: string;
color: string;
icon: string;
}
interface MessageEntry {
id: number;
text: string;
time: string;
msgType: MessageType;
src: MessageTarget;
dest: MessageTarget | null;
option?: Record<string, unknown> | null;
}
const props = defineProps<{
message: MessageEntry;
generalId: number;
generalName: string;
nationId: number;
permission: number;
canRespondDiplomacy: boolean;
}>();
const emit = defineEmits<{
(event: 'set-target', type: MessageType, target: MessageTarget): void;
(event: 'delete', messageId: number): void;
(event: 'respond', messageId: number, response: boolean): void;
}>();
const now = ref(Date.now());
let deleteTimer: number | null = null;
const destination = computed<MessageTarget>(
() =>
props.message.dest ?? {
generalId: 0,
generalName: '',
nationId: 0,
nationName: '재야',
color: '#000000',
icon: '/image/icons/default.jpg',
}
);
const invalid = computed(() => props.message.option?.invalid === true);
const hasAction = computed(() => typeof props.message.option?.action === 'string');
const nationDirection = computed(() => {
if (props.message.src.nationId === destination.value.nationId) {
return 'local';
}
return props.message.src.nationId === props.nationId ? 'src' : 'dest';
});
const parseMessageTime = (): number => {
const normalized = props.message.time.includes('T')
? props.message.time
: `${props.message.time.replace(' ', 'T')}Z`;
return Date.parse(normalized);
};
const deletable = computed(() => {
if (invalid.value || hasAction.value || props.message.src.generalId !== props.generalId) {
return false;
}
if (props.message.option?.deletable === false) {
return false;
}
const sentAt = parseMessageTime();
return Number.isFinite(sentAt) && sentAt + 5 * 60 * 1000 > now.value;
});
const scheduleDeleteExpiry = () => {
const sentAt = parseMessageTime();
if (!Number.isFinite(sentAt)) {
return;
}
const delay = sentAt + 5 * 60 * 1000 - Date.now();
if (delay <= 0) {
now.value = Date.now();
return;
}
deleteTimer = window.setTimeout(() => {
now.value = Date.now();
}, delay);
};
const isBright = (color: string): boolean => {
const match = /^#([0-9a-f]{6})$/i.exec(color);
if (!match) {
return false;
}
const value = Number.parseInt(match[1]!, 16);
const red = (value >> 16) & 0xff;
const green = (value >> 8) & 0xff;
const blue = value & 0xff;
return red * 0.299 + green * 0.587 + blue * 0.114 > 160;
};
const iconUrl = computed(() => {
const icon = props.message.src.icon?.trim();
if (!icon) {
return '/image/icons/default.jpg';
}
if (icon.startsWith('/') || /^https?:\/\//i.test(icon)) {
return icon;
}
return `${import.meta.env.BASE_URL}${icon.replace(/^\/+/, '')}`;
});
const targetClass = (target: MessageTarget) => ({
'msg-target': true,
'msg-bright': isBright(target.color),
'msg-dark': !isBright(target.color),
});
const setTarget = (target: MessageTarget) => {
emit('set-target', props.message.msgType, target);
};
const requestDelete = () => {
if (!window.confirm('삭제하시겠습니까?')) {
return;
}
emit('delete', props.message.id);
};
const respond = (response: boolean) => {
if (!window.confirm(response ? '수락하시겠습니까?' : '거절하시겠습니까?')) {
return;
}
emit('respond', props.message.id, response);
};
onMounted(scheduleDeleteExpiry);
onBeforeUnmount(() => {
if (deleteTimer !== null) {
window.clearTimeout(deleteTimer);
}
});
</script>
<template>
<article
:id="`msg_${message.id}`"
:class="['msg-plate', `msg-plate-${message.msgType}`, `msg-plate-${nationDirection}`]"
:data-id="message.id"
>
<div class="msg-icon">
<img class="general-icon" width="64" height="64" :src="iconUrl" :alt="message.src.generalName" />
</div>
<div class="msg-body">
<div class="msg-header">
<button v-if="deletable" class="delete-message" type="button" @click="requestDelete"></button>
<template v-if="message.msgType === 'private'">
<template v-if="message.src.generalId === generalId">
<span :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }"
></span
>
<span class="msg-from-to"></span>
<button
:class="targetClass(destination)"
:style="{ backgroundColor: destination.color }"
type="button"
@click="setTarget(destination)"
>
{{ destination.generalName }}:{{ destination.nationName }} |
</button>
</template>
<template v-else>
<button
:class="targetClass(message.src)"
:style="{ backgroundColor: message.src.color }"
type="button"
@click="setTarget(message.src)"
>
{{ message.src.generalName }}:{{ message.src.nationName }} |
</button>
<span class="msg-from-to"></span>
<span :class="targetClass(destination)" :style="{ backgroundColor: destination.color }"
></span
>
</template>
</template>
<template v-else-if="message.msgType === 'national' && message.src.nationId === destination.nationId">
<span :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
{{ message.src.generalName }}
</span>
</template>
<template
v-else-if="(message.msgType === 'national' || message.msgType === 'diplomacy') && permission >= 4"
>
<template v-if="message.src.nationId === nationId">
<span :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
{{ message.src.generalName }}
</span>
<span class="msg-from-to"></span>
<button
:class="targetClass(destination)"
:style="{ backgroundColor: destination.color }"
type="button"
@click="setTarget(destination)"
>
{{ destination.nationName }} |
</button>
</template>
<button
v-else
:class="targetClass(message.src)"
:style="{ backgroundColor: message.src.color }"
type="button"
@click="setTarget(message.src)"
>
{{ message.src.generalName }}:{{ message.src.nationName }} |
</button>
</template>
<template v-else-if="message.msgType === 'national' || message.msgType === 'diplomacy'">
<template v-if="message.src.nationId === nationId">
<span :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
{{ message.src.generalName }}
</span>
<span class="msg-from-to"></span>
<span :class="targetClass(destination)" :style="{ backgroundColor: destination.color }">
{{ destination.nationName }}
</span>
</template>
<span v-else :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
{{ message.src.generalName }}:{{ message.src.nationName }}
</span>
</template>
<button
v-else-if="message.src.generalId !== generalId"
:class="targetClass(message.src)"
:style="{ backgroundColor: message.src.color }"
type="button"
@click="setTarget(message.src)"
>
{{ message.src.generalName }}:{{ message.src.nationName }} |
</button>
<span v-else :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
{{ message.src.generalName }}
</span>
<span class="msg-time">&lt;{{ message.time }}&gt;</span>
</div>
<div :class="['msg-content', invalid ? 'msg-invalid' : 'msg-valid']">
{{ invalid ? '삭제된 메시지입니다' : message.text }}
</div>
<div v-if="hasAction" class="message-response">
<button
class="prompt-yes"
type="button"
:disabled="message.msgType === 'diplomacy' && !canRespondDiplomacy"
@click="respond(true)"
>
수락
</button>
<button
class="prompt-no"
type="button"
:disabled="message.msgType === 'diplomacy' && !canRespondDiplomacy"
@click="respond(false)"
>
거절
</button>
</div>
</div>
</article>
</template>
<style scoped>
.msg-plate {
display: grid;
grid-template-columns: 64px minmax(0, 1fr);
width: 100%;
min-height: 64px;
outline: 1px solid gray;
color: #fff;
font-size: 12.5px;
word-break: break-all;
}
.msg-plate-private {
background-color: #5d1e1a;
}
.msg-plate-private.msg-plate-dest {
background-color: #5d461a;
}
.msg-plate-public {
background-color: #141c65;
}
.msg-plate-national,
.msg-plate-diplomacy {
background-color: #00582c;
}
.msg-plate-national.msg-plate-dest,
.msg-plate-diplomacy.msg-plate-dest {
background-color: #704615;
}
.msg-plate-national.msg-plate-src,
.msg-plate-diplomacy.msg-plate-src {
background-color: #70153b;
}
.msg-icon {
width: 64px;
height: 64px;
border-right: 1px solid gray;
}
.general-icon {
display: block;
width: 64px;
max-width: none;
height: 64px;
object-fit: fill;
}
.msg-body {
min-width: 0;
padding-left: 0;
}
.msg-header {
position: relative;
margin-bottom: 3px;
color: #fff;
font-weight: 700;
}
.msg-target {
display: inline-block;
margin: 2px 2px 0;
border: 0;
border-radius: 3px;
padding: 2px 3px;
box-shadow: 2px 2px #000;
font: inherit;
font-weight: inherit;
}
button.msg-target {
cursor: pointer;
}
.msg-bright {
color: #000;
}
.msg-dark {
color: #fff;
}
.msg-from-to {
display: inline-block;
}
.msg-time {
font-size: 0.75em;
font-weight: 400;
}
.delete-message {
position: absolute;
z-index: 1;
top: 0;
right: 0;
margin: 2px 2px 0;
border: 1px solid #ffc107;
border-radius: 3px;
background: transparent;
padding: 2px 4px;
color: #ffc107;
font-size: 8px;
cursor: pointer;
}
.msg-content {
overflow: hidden;
margin-right: 5px;
margin-left: 10px;
white-space: pre-wrap;
}
.msg-invalid {
color: rgba(255, 255, 255, 0.5);
}
.message-response {
display: flex;
justify-content: flex-end;
gap: 0;
margin-top: 5px;
margin-right: 5px;
}
.message-response button {
min-width: 42px;
border: 1px outset buttonborder;
background: buttonface;
padding: 1px 6px;
color: buttontext;
font-size: 12.5px;
cursor: pointer;
}
.message-response button:disabled {
cursor: not-allowed;
opacity: 0.65;
}
</style>
+7 -4
View File
@@ -21,7 +21,6 @@ import NotFoundView from '../views/NotFoundView.vue';
import TournamentView from '../views/TournamentView.vue';
import BettingView from '../views/BettingView.vue';
import MyPageView from '../views/MyPageView.vue';
import MySettingsView from '../views/MySettingsView.vue';
import BoardView from '../views/BoardView.vue';
import DiplomacyView from '../views/DiplomacyView.vue';
import BestGeneralView from '../views/BestGeneralView.vue';
@@ -33,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 = [
@@ -197,7 +197,6 @@ const routes = [
component: BattleSimulatorView,
meta: {
requiresAuth: true,
requiresGeneral: true,
},
},
{
@@ -258,6 +257,11 @@ const routes = [
requiresGeneral: true,
},
},
{
path: '/traffic',
name: 'traffic',
component: TrafficView,
},
{
path: '/npc-list',
name: 'npc-list',
@@ -283,8 +287,7 @@ const routes = [
},
{
path: '/my-settings',
name: 'my-settings',
component: MySettingsView,
redirect: '/my-page',
meta: {
requiresAuth: true,
requiresGeneral: true,
+140 -22
View File
@@ -23,6 +23,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
type MapLayout = Awaited<ReturnType<typeof trpc.world.getMapLayout.query>>;
type CommandTable = Awaited<ReturnType<typeof trpc.turns.getCommandTable.query>>;
type MessageBundle = Awaited<ReturnType<typeof trpc.messages.getRecent.query>>;
type MessageContacts = Awaited<ReturnType<typeof trpc.messages.getContacts.query>>;
type BoardAccess = Awaited<ReturnType<typeof trpc.board.getAccess.query>>;
type ReservedTurnView = Awaited<ReturnType<typeof trpc.turns.reserved.getGeneral.query>>[number];
@@ -37,12 +38,14 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
const mapLayout = ref<MapLayout | null>(null);
const commandTable = ref<CommandTable | null>(null);
const messages = ref<MessageBundle | null>(null);
const messageContacts = ref<MessageContacts | null>(null);
const boardAccess = ref<BoardAccess | null>(null);
const reservedGeneralTurns = ref<ReservedTurnView[] | null>(null);
const reservedNationTurns = ref<ReservedTurnView[] | null>(null);
const messageDraftText = ref('');
const targetMailbox = ref<number>(MESSAGE_MAILBOX_PUBLIC);
let initializedMailboxGeneralId: number | null = null;
const general = computed(() => generalContext.value?.general ?? null);
const city = computed(() => generalContext.value?.city ?? null);
@@ -86,18 +89,85 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
} as const;
});
const mailboxOptions = computed(() => {
const options: Array<{ label: string; value: number; disabled?: boolean }> = [
{ label: '공공', value: MESSAGE_MAILBOX_PUBLIC },
const mailboxGroups = computed(() => {
type MailboxOption = {
label: string;
value: number;
disabled?: boolean;
color?: string;
};
type MailboxGroup = {
label: string;
color?: string;
options: MailboxOption[];
};
const ownNationId = general.value?.nationId ?? 0;
const ownMailbox = MESSAGE_MAILBOX_NATIONAL_BASE + ownNationId;
const permission = messages.value?.permission ?? -1;
const contacts = messageContacts.value?.nation ?? [];
const ownNation = contacts.find((nation) => nation.mailbox === ownMailbox);
const groups: MailboxGroup[] = [
{
label: '즐겨찾기',
color: '#000000',
options: [
{
label: '【 아국 메세지 】',
value: ownMailbox,
color: ownNation?.color ?? '#000000',
},
{
label: '【 전체 메세지 】',
value: MESSAGE_MAILBOX_PUBLIC,
color: '#000000',
},
],
},
];
if (nationId.value) {
options.push({ label: '국가', value: MESSAGE_MAILBOX_NATIONAL_BASE + nationId.value });
} else {
options.push({ label: '국가', value: -1, disabled: true });
if (permission >= 4) {
groups.push({
label: '외교메시지',
color: '#000000',
options: contacts
.filter((nation) => nation.mailbox !== ownMailbox && nation.nationId > 0)
.map((nation) => ({
label: nation.name,
value: nation.mailbox,
color: nation.color,
})),
});
}
options.push({ label: '외교', value: -2, disabled: true });
options.push({ label: '개인', value: -3, disabled: true });
return options;
const sortedContacts = [...contacts].sort((left, right) => {
if (left.mailbox === ownMailbox) return -1;
if (right.mailbox === ownMailbox) return 1;
return left.mailbox - right.mailbox;
});
for (const nation of sortedContacts) {
const options = [...nation.general]
.filter(([id]) => id !== generalId.value)
.sort((left, right) => left[1].localeCompare(right[1], 'ko'))
.map(([id, name, flags]) => {
const ruler = Boolean(flags & 1);
const ambassador = Boolean(flags & 4);
return {
label: ruler ? `*${name}*` : ambassador ? `#${name}#` : name,
value: id,
disabled: permission === 4 && ambassador && nation.mailbox !== ownMailbox,
color: nation.color,
};
});
if (options.length > 0) {
groups.push({
label: nation.name,
color: nation.color,
options,
});
}
}
return groups;
});
const statusLine = computed(() => {
@@ -147,25 +217,32 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
context.general.nationId > 0 && context.general.officerLevel >= 5
? trpc.turns.reserved.getNation.query({ generalId: id })
: Promise.resolve(null);
const [layout, lobby, map, commands, messageData, access, generalTurns, nationTurns] = await Promise.all([
layoutPromise,
trpc.lobby.info.query(),
trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }),
trpc.turns.getCommandTable.query({ generalId: id }),
trpc.messages.getRecent.query({ generalId: id }),
trpc.board.getAccess.query(),
generalTurnsPromise,
nationTurnsPromise,
]);
const [layout, lobby, map, commands, messageData, contacts, access, generalTurns, nationTurns] =
await Promise.all([
layoutPromise,
trpc.lobby.info.query(),
trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }),
trpc.turns.getCommandTable.query({ generalId: id }),
trpc.messages.getRecent.query({ generalId: id }),
trpc.messages.getContacts.query({ generalId: id }),
trpc.board.getAccess.query(),
generalTurnsPromise,
nationTurnsPromise,
]);
mapLayout.value = layout;
lobbyInfo.value = lobby;
worldMap.value = map;
commandTable.value = commands;
messages.value = messageData;
messageContacts.value = contacts;
boardAccess.value = access;
reservedGeneralTurns.value = generalTurns;
reservedNationTurns.value = nationTurns;
if (initializedMailboxGeneralId !== id) {
targetMailbox.value = MESSAGE_MAILBOX_NATIONAL_BASE + context.general.nationId;
initializedMailboxGeneralId = id;
}
} catch (err) {
error.value = resolveErrorMessage(err);
} finally {
@@ -200,12 +277,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
}
try {
messageDraftText.value = '';
await trpc.messages.send.mutate({
generalId: id,
mailbox,
text,
});
messageDraftText.value = '';
await refreshMessages();
} catch (err) {
error.value = resolveErrorMessage(err);
@@ -260,6 +337,44 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
}
};
const readLatestMessage = async (type: 'private' | 'diplomacy', messageId: number) => {
const id = generalId.value;
if (!id || messageId <= 0) {
return;
}
try {
await trpc.messages.readLatest.mutate({
generalId: id,
type,
messageId,
});
if (messages.value) {
messages.value = {
...messages.value,
latestRead: {
...messages.value.latestRead,
[type]: Math.max(messages.value.latestRead[type], messageId),
},
};
}
} catch (err) {
error.value = resolveErrorMessage(err);
}
};
const deleteMessage = async (messageId: number) => {
const id = generalId.value;
if (!id) {
return;
}
try {
await trpc.messages.delete.mutate({ generalId: id, messageId });
await refreshMessages();
} catch (err) {
error.value = resolveErrorMessage(err);
}
};
const setGeneralTurn = async (turnIndex: number, action: string) => {
const id = generalId.value;
if (!id) {
@@ -484,12 +599,13 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
selectedCity,
commandTable,
messages,
messageContacts,
boardAccess,
reservedGeneralTurns,
reservedNationTurns,
messageDraftText,
targetMailbox,
mailboxOptions,
mailboxGroups,
statusLine,
realtimeLabel,
setRealtimeEnabled,
@@ -498,6 +614,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
sendMessage,
loadOlderMessages,
respondToMessage,
readLatestMessage,
deleteMessage,
setGeneralTurn,
shiftGeneralTurns,
setNationTurn,
+688 -201
View File
@@ -1,8 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import { formatLog } from '../utils/formatLog';
import { trpc } from '../utils/trpc';
@@ -11,7 +10,8 @@ type ResourceAuction = AuctionOverview['resourceAuctions'][number];
type UniqueAuction = AuctionOverview['uniqueAuctions'][number];
type UniqueDetail = Awaited<ReturnType<typeof trpc.auction.getUniqueDetail.query>>;
const activeTab = ref<'resource' | 'unique'>('resource');
const route = useRoute();
const activeTab = ref<'resource' | 'unique'>(route.query.type === 'unique' ? 'unique' : 'resource');
const loading = ref(false);
const actionBusy = ref(false);
const error = ref<string | null>(null);
@@ -38,22 +38,57 @@ const resolveErrorMessage = (value: unknown): string => {
};
const formatNumber = (value: number | null | undefined): string => (value ?? 0).toLocaleString();
const formatDate = (value: string): string =>
new Intl.DateTimeFormat('ko-KR', {
const cutDateTime = (value: string | null | undefined, showSecond = false): string => {
if (!value) {
return '-';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value.slice(5, showSecond ? 19 : 16);
}
const parts = new Intl.DateTimeFormat('ko-KR', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(new Date(value));
...(showSecond ? { second: '2-digit' } : {}),
hour12: false,
}).formatToParts(date);
const part = (type: Intl.DateTimeFormatPartTypes): string =>
parts.find((entry) => entry.type === type)?.value ?? '';
return `${part('month')}-${part('day')} ${part('hour')}:${part('minute')}${showSecond ? `:${part('second')}` : ''}`;
};
const resourceTitle = (auction: ResourceAuction): string =>
auction.type === 'BUY_RICE' ? '쌀 구매' : '쌀 판매';
const hostResource = (auction: ResourceAuction): string => (auction.type === 'BUY_RICE' ? '쌀' : '금');
const bidResource = (auction: ResourceAuction): string => (auction.type === 'BUY_RICE' ? '금' : '쌀');
const buyRice = computed(() =>
(overview.value?.resourceAuctions ?? []).filter((auction) => auction.type === 'BUY_RICE')
);
const sellRice = computed(() =>
(overview.value?.resourceAuctions ?? []).filter((auction) => auction.type === 'SELL_RICE')
);
const ongoingUnique = computed(() =>
(overview.value?.uniqueAuctions ?? []).filter((auction) => auction.status === 'OPEN')
);
const finishedUnique = computed(() =>
(overview.value?.uniqueAuctions ?? []).filter((auction) => auction.status !== 'OPEN')
);
const resourceAuctions = computed(() => overview.value?.resourceAuctions ?? []);
const uniqueAuctions = computed(() => overview.value?.uniqueAuctions ?? []);
const selectResource = (auction: ResourceAuction): void => {
selectedResource.value = auction;
bidAmount.value = auction.highestBid?.amount ?? auction.detail.startBidAmount ?? 0;
};
const selectUnique = async (auction: UniqueAuction): Promise<void> => {
selectedUnique.value = auction;
uniqueDetail.value = null;
error.value = null;
try {
uniqueDetail.value = await trpc.auction.getUniqueDetail.query({ auctionId: auction.id });
const highest = uniqueDetail.value.bids[0]?.amount ?? auction.detail.startBidAmount ?? 0;
bidAmount.value = Math.max(Math.ceil(highest * 1.01), highest + 10);
} catch (err) {
error.value = resolveErrorMessage(err);
}
};
const loadOverview = async (): Promise<void> => {
loading.value = true;
@@ -65,8 +100,14 @@ const loadOverview = async (): Promise<void> => {
overview.value.resourceAuctions.find((auction) => auction.id === selectedResource.value?.id) ?? null;
}
if (selectedUnique.value) {
selectedUnique.value =
const updated =
overview.value.uniqueAuctions.find((auction) => auction.id === selectedUnique.value?.id) ?? null;
selectedUnique.value = updated;
if (updated) {
await selectUnique(updated);
}
} else if (activeTab.value === 'unique' && ongoingUnique.value[0]) {
await selectUnique(ongoingUnique.value[0]);
}
} catch (err) {
error.value = resolveErrorMessage(err);
@@ -75,23 +116,6 @@ const loadOverview = async (): Promise<void> => {
}
};
const selectResource = (auction: ResourceAuction): void => {
selectedResource.value = auction;
bidAmount.value = auction.highestBid?.amount ?? auction.detail.startBidAmount ?? 0;
};
const selectUnique = async (auction: UniqueAuction): Promise<void> => {
selectedUnique.value = auction;
error.value = null;
try {
uniqueDetail.value = await trpc.auction.getUniqueDetail.query({ auctionId: auction.id });
const highest = uniqueDetail.value.bids[0]?.amount ?? auction.detail.startBidAmount ?? 0;
bidAmount.value = Math.max(Math.ceil(highest * 1.01), highest + 10);
} catch (err) {
error.value = resolveErrorMessage(err);
}
};
const runAction = async (action: () => Promise<void>): Promise<void> => {
if (actionBusy.value) {
return;
@@ -138,7 +162,7 @@ const bidResourceAuction = (): Promise<void> =>
} else {
await trpc.auction.bidSellRice.mutate({ auctionId: auction.id, amount: bidAmount.value });
}
message.value = `${auction.id}번 경매에 입찰했습니다.`;
message.value = '입찰했습니다.';
});
const bidUniqueAuction = (): Promise<void> =>
@@ -147,206 +171,669 @@ const bidUniqueAuction = (): Promise<void> =>
if (!auction) {
return;
}
if (!window.confirm(`${auction.detail.title ?? auction.targetCode ?? '유니크'}${bidAmount.value} 포인트를 입찰하시겠습니까?`)) {
if (
!window.confirm(
`${auction.detail.title ?? auction.targetCode ?? '유니크'}${bidAmount.value}유산포인트를 입찰하시겠습니까?`
)
) {
return;
}
await trpc.auction.bidUnique.mutate({
auctionId: auction.id,
amount: bidAmount.value,
tryExtendCloseDate: true,
tryExtendCloseDate: false,
});
message.value = `${auction.id}번 유니크 경매에 입찰했습니다.`;
await selectUnique(auction);
message.value = '입찰이 완료되었습니다.';
});
const closeWindow = (): void => window.close();
watch(activeTab, (tab) => {
error.value = null;
message.value = null;
if (tab === 'unique' && !selectedUnique.value && ongoingUnique.value[0]) {
void selectUnique(ongoingUnique.value[0]);
}
});
onMounted(() => {
void loadOverview();
});
</script>
<template>
<main class="auction-page">
<header class="page-header">
<div>
<h1>거래장</h1>
<p>· 거래와 유니크 아이템 경매를 확인합니다.</p>
</div>
<button class="ghost" :disabled="loading" @click="loadOverview">새로고침</button>
<main id="container" class="legacy-auction-page bg0">
<header class="top-back-bar bg0">
<button class="legacy-button close-button" type="button" @click="closeWindow"> 닫기</button>
<button class="legacy-button reload-button" type="button" :disabled="loading" @click="loadOverview">
갱신
</button>
<h1>{{ activeTab === 'resource' ? '경매장' : '유니크 경매장' }}</h1>
<button
class="legacy-button tab-button"
:aria-pressed="activeTab === 'resource'"
@click="activeTab = 'resource'"
>
/
</button>
<button
class="legacy-button tab-button"
:aria-pressed="activeTab === 'unique'"
@click="activeTab = 'unique'"
>
유니크
</button>
</header>
<nav class="tabs" aria-label="경매 종류">
<button :class="{ active: activeTab === 'resource' }" @click="activeTab = 'resource'">· 경매</button>
<button :class="{ active: activeTab === 'unique' }" @click="activeTab = 'unique'">유니크 경매</button>
</nav>
<p v-if="error" class="auction-notice error" role="alert">{{ error }}</p>
<p v-if="message" class="auction-notice success" role="status">{{ message }}</p>
<div v-if="loading && !overview" class="loading-state">불러오는 중...</div>
<p v-if="error" class="notice error">{{ error }}</p>
<p v-if="message" class="notice success">{{ message }}</p>
<SkeletonLines v-if="loading && !overview" :lines="8" />
<section v-else-if="activeTab === 'resource'" class="resource-auction bg0">
<h2 class="section-title bg2">거래장</h2>
<template v-else-if="activeTab === 'resource'">
<PanelCard title="진행 중인 금·쌀 경매" subtitle="행을 선택하면 아래에서 입찰할 수 있습니다.">
<div class="auction-table resource-table">
<div class="table-head">
<span>번호</span><span>종류</span><span>판매자</span><span>수량</span><span>입찰</span>
<span>현재</span><span>마감가</span><span>종료</span>
</div>
<button
v-for="auction in resourceAuctions"
:key="auction.id"
class="table-row"
:class="{ selected: selectedResource?.id === auction.id }"
@click="selectResource(auction)"
>
<span>{{ auction.id }}</span>
<span>{{ resourceTitle(auction) }}</span>
<span>{{ auction.hostName }}</span>
<span>{{ hostResource(auction) }} {{ formatNumber(auction.detail.amount) }}</span>
<span>{{ auction.highestBid?.bidderName ?? '-' }}</span>
<span>{{ bidResource(auction) }} {{ formatNumber(auction.highestBid?.amount ?? auction.detail.startBidAmount) }}</span>
<span>{{ bidResource(auction) }} {{ formatNumber(auction.detail.finishBidAmount) }}</span>
<span>{{ formatDate(auction.closeAt) }}</span>
</button>
<p v-if="resourceAuctions.length === 0" class="empty">진행 중인 경매가 없습니다.</p>
<section class="resource-section" aria-labelledby="buy-rice-heading">
<h3 id="buy-rice-heading" class="resource-kind buy-rice"> 구매</h3>
<div class="resource-row resource-header">
<span class="idx">번호</span><span class="host">판매자</span><span class="amount">수량</span>
<span class="highest-bidder">입찰자</span><span class="highest-bid">입찰</span>
<span class="bid-ratio"></span><span class="finish-bid">마감가</span>
<span class="close-date">거래 종료</span>
</div>
<button
v-for="auction in buyRice"
:key="auction.id"
class="resource-row clickable-row"
:class="{ selected: selectedResource?.id === auction.id }"
@click="selectResource(auction)"
>
<span class="idx tnum">{{ auction.id }}</span>
<span class="host">{{ auction.hostName }}</span>
<span class="amount tnum"> {{ formatNumber(auction.detail.amount) }}</span>
<span class="highest-bidder">{{ auction.highestBid?.bidderName ?? '-' }}</span>
<span class="highest-bid tnum" :class="{ 'no-bid': !auction.highestBid }">
{{ formatNumber(auction.highestBid?.amount ?? auction.detail.startBidAmount) }}
</span>
<span class="bid-ratio tnum">
{{
auction.highestBid && auction.detail.amount
? (auction.highestBid.amount / auction.detail.amount).toFixed(2)
: '-'
}}
</span>
<span class="finish-bid tnum"> {{ formatNumber(auction.detail.finishBidAmount) }}</span>
<span class="close-date tnum">{{ cutDateTime(auction.closeAt) }}</span>
</button>
<p v-if="buyRice.length === 0" class="empty-row">진행 중인 구매 경매가 없습니다.</p>
</section>
<form v-if="selectedResource" class="bid-form" @submit.prevent="bidResourceAuction">
<strong>{{ selectedResource.id }} {{ resourceTitle(selectedResource) }}</strong>
<label>
<span>입찰가 ({{ bidResource(selectedResource) }})</span>
<input v-model.number="bidAmount" type="number" min="1" step="10" required />
</label>
<button :disabled="actionBusy || selectedResource.isCallerHost">입찰</button>
</form>
</PanelCard>
<PanelCard title="경매 등록" subtitle="레거시와 동일하게 한 장수는 자원 경매를 한 건만 진행할 수 있습니다.">
<form class="open-form" @submit.prevent="openResourceAuction">
<label>
<span>매물</span>
<select v-model="openForm.type">
<option value="BUY_RICE"></option>
<option value="SELL_RICE"></option>
</select>
</label>
<label><span>수량</span><input v-model.number="openForm.amount" type="number" min="100" max="10000" step="10" /></label>
<label><span>기간()</span><input v-model.number="openForm.closeTurnCnt" type="number" min="1" max="24" /></label>
<label><span>시작가</span><input v-model.number="openForm.startBidAmount" type="number" min="1" step="10" /></label>
<label><span>마감가</span><input v-model.number="openForm.finishBidAmount" type="number" min="1" step="10" /></label>
<button :disabled="actionBusy">등록</button>
</form>
</PanelCard>
<PanelCard title="이전 경매" subtitle="최근 경매 기록 20건">
<ol class="log-list">
<!-- eslint-disable vue/no-v-html -->
<li v-for="log in overview?.recentLogs ?? []" :key="log.id" v-html="formatLog(log.text)" />
<!-- eslint-enable vue/no-v-html -->
<li v-if="(overview?.recentLogs.length ?? 0) === 0" class="empty">경매 기록이 없습니다.</li>
</ol>
</PanelCard>
</template>
<template v-else>
<PanelCard title="유니크 경매" :subtitle="`내 가명: ${overview?.callerAlias ?? '-'}`">
<div class="auction-table unique-table">
<div class="table-head">
<span>번호</span><span>경매명</span><span>주최자</span><span>종료</span><span>1순위</span><span>포인트</span>
</div>
<button
v-for="auction in uniqueAuctions"
:key="auction.id"
class="table-row"
:class="{ selected: selectedUnique?.id === auction.id }"
@click="selectUnique(auction)"
>
<span>{{ auction.id }}</span>
<span>{{ auction.detail.title ?? auction.targetCode }}</span>
<span :class="{ me: auction.isCallerHost }">{{ auction.hostName }}</span>
<span>{{ formatDate(auction.closeAt) }}</span>
<span :class="{ me: auction.highestBid?.isCaller }">{{ auction.highestBid?.bidderName ?? '-' }}</span>
<span>{{ formatNumber(auction.highestBid?.amount ?? auction.detail.startBidAmount) }}</span>
</button>
<p v-if="uniqueAuctions.length === 0" class="empty">유니크 경매가 없습니다.</p>
<section class="resource-section" aria-labelledby="sell-rice-heading">
<h3 id="sell-rice-heading" class="resource-kind sell-rice"> 판매</h3>
<div class="resource-row resource-header">
<span class="idx">번호</span><span class="host">판매자</span><span class="amount">수량</span>
<span class="highest-bidder">입찰자</span><span class="highest-bid">입찰가</span>
<span class="bid-ratio">단가</span><span class="finish-bid">마감가</span>
<span class="close-date">거래 종료</span>
</div>
</PanelCard>
<button
v-for="auction in sellRice"
:key="auction.id"
class="resource-row clickable-row"
:class="{ selected: selectedResource?.id === auction.id }"
@click="selectResource(auction)"
>
<span class="idx tnum">{{ auction.id }}</span>
<span class="host">{{ auction.hostName }}</span>
<span class="amount tnum"> {{ formatNumber(auction.detail.amount) }}</span>
<span class="highest-bidder">{{ auction.highestBid?.bidderName ?? '-' }}</span>
<span class="highest-bid tnum" :class="{ 'no-bid': !auction.highestBid }">
{{ formatNumber(auction.highestBid?.amount ?? auction.detail.startBidAmount) }}
</span>
<span class="bid-ratio tnum">
{{
auction.highestBid && auction.detail.amount
? (auction.highestBid.amount / auction.detail.amount).toFixed(2)
: '-'
}}
</span>
<span class="finish-bid tnum"> {{ formatNumber(auction.detail.finishBidAmount) }}</span>
<span class="close-date tnum">{{ cutDateTime(auction.closeAt) }}</span>
</button>
<p v-if="sellRice.length === 0" class="empty-row">진행 중인 판매 경매가 없습니다.</p>
</section>
<PanelCard v-if="uniqueDetail" title="유니크 경매 상세">
<form v-if="selectedResource" class="resource-bid-form" @submit.prevent="bidResourceAuction">
<span class="bid-description">
{{ selectedResource.id }} {{ selectedResource.type === 'BUY_RICE' ? '쌀' : '금' }}
{{ formatNumber(selectedResource.detail.amount) }} 경매에
{{ selectedResource.type === 'BUY_RICE' ? '금' : '쌀' }}
</span>
<input
v-model.number="bidAmount"
:aria-label="`${selectedResource.id} 경매 입찰가`"
type="number"
:min="selectedResource.detail.startBidAmount ?? 1"
:max="selectedResource.detail.finishBidAmount ?? undefined"
step="10"
required
/>
<button class="legacy-button" :disabled="actionBusy || selectedResource.isCallerHost">입찰</button>
</form>
<h3 class="subsection-title">경매 등록</h3>
<form class="open-form" @submit.prevent="openResourceAuction">
<fieldset>
<legend>매물</legend>
<div class="item-toggle">
<button
class="legacy-button"
type="button"
:aria-pressed="openForm.type === 'BUY_RICE'"
@click="openForm.type = 'BUY_RICE'"
>
</button>
<button
class="legacy-button"
type="button"
:aria-pressed="openForm.type === 'SELL_RICE'"
@click="openForm.type = 'SELL_RICE'"
>
</button>
</div>
</fieldset>
<label>
<span>수량 ({{ openForm.type === 'BUY_RICE' ? '쌀' : '금' }})</span>
<input v-model.number="openForm.amount" type="number" min="100" max="10000" step="10" />
</label>
<label
><span>기간()</span><input v-model.number="openForm.closeTurnCnt" type="number" min="3" max="24"
/></label>
<label>
<span>시작가 ({{ openForm.type === 'BUY_RICE' ? '금' : '쌀' }})</span>
<input v-model.number="openForm.startBidAmount" type="number" min="100" max="10000" step="10" />
</label>
<label>
<span>마감가 ({{ openForm.type === 'BUY_RICE' ? '금' : '쌀' }})</span>
<input v-model.number="openForm.finishBidAmount" type="number" min="100" max="10000" step="10" />
</label>
<button class="legacy-button register-button" :disabled="actionBusy">등록</button>
</form>
<h3 class="subsection-title">이전 경매(최근 20)</h3>
<div class="recent-logs">
<!-- eslint-disable vue/no-v-html -->
<div v-for="log in overview?.recentLogs ?? []" :key="log.id" v-html="formatLog(log.text)" />
<!-- eslint-enable vue/no-v-html -->
<div v-if="(overview?.recentLogs.length ?? 0) === 0" class="empty-row">경매 기록이 없습니다.</div>
</div>
</section>
<section v-else class="unique-auction bg0">
<div class="caller-alias">
가명: <strong>{{ overview?.callerAlias ?? '-' }}</strong>
</div>
<section v-if="uniqueDetail" class="unique-detail">
<h2 class="section-title bg2">경매 {{ uniqueDetail.auction.id }} 상세</h2>
<dl class="detail-grid">
<dt>경매명</dt><dd>{{ uniqueDetail.auction.detail.title ?? uniqueDetail.auction.targetCode }}</dd>
<dt>주최자(익명)</dt><dd :class="{ me: uniqueDetail.auction.isCallerHost }">{{ uniqueDetail.auction.hostName }}</dd>
<dt>종료일시</dt><dd>{{ formatDate(uniqueDetail.auction.closeAt) }}</dd>
<dt>잔여 포인트</dt><dd>{{ formatNumber(uniqueDetail.remainPoint) }}</dd>
<dt class="bg1">경매명</dt>
<dd>{{ uniqueDetail.auction.detail.title ?? uniqueDetail.auction.targetCode }}</dd>
<dt class="bg1">주최자(익명)</dt>
<dd :class="{ 'is-me': uniqueDetail.auction.isCallerHost }">{{ uniqueDetail.auction.hostName }}</dd>
<dt class="bg1">종료일시</dt>
<dd class="tnum">{{ cutDateTime(uniqueDetail.auction.closeAt, true) }}</dd>
<dt class="bg1">최대지연</dt>
<dd class="tnum">
{{ cutDateTime(uniqueDetail.auction.detail.availableLatestBidCloseDate, true) }}
</dd>
</dl>
<div class="bid-history">
<div v-for="bid in uniqueDetail.bids" :key="bid.id" class="bid-entry">
<span :class="{ me: bid.isCaller }">{{ bid.bidderName }}</span>
<strong>{{ formatNumber(bid.amount) }}</strong>
<time>{{ formatDate(bid.eventAt) }}</time>
</div>
<h3 class="subsection-title bg1">입찰자 목록</h3>
<div class="bid-row bid-header"><span>입찰자</span><span>입찰포인트</span><span>시각</span></div>
<div v-for="bid in uniqueDetail.bids" :key="bid.id" class="bid-row">
<span :class="{ 'is-me': bid.isCaller }">{{ bid.bidderName }}</span>
<span class="tnum">{{ formatNumber(bid.amount) }}</span>
<time class="tnum">{{ cutDateTime(bid.eventAt) }}</time>
</div>
<form v-if="uniqueDetail.auction.status === 'OPEN'" class="bid-form" @submit.prevent="bidUniqueAuction">
<label><span>유산 포인트</span><input v-model.number="bidAmount" type="number" min="1" required /></label>
<button :disabled="actionBusy">입찰</button>
</form>
</PanelCard>
</template>
<template v-if="uniqueDetail.auction.status === 'OPEN'">
<h3 class="subsection-title bg1">입찰하기</h3>
<form class="unique-bid-form" @submit.prevent="bidUniqueAuction">
<label for="unique-bid">
유산포인트 (잔여: {{ formatNumber(uniqueDetail.remainPoint) }}포인트)
</label>
<input id="unique-bid" v-model.number="bidAmount" type="number" min="1" required />
<button class="legacy-button" :disabled="actionBusy">입찰</button>
</form>
</template>
</section>
<section class="unique-list-section">
<h2 class="subsection-title bg1">진행중인 경매 목록</h2>
<div class="unique-row unique-header">
<span>번호</span><span>경매명</span><span>주최자</span><span>종료일시</span> <span>연장</span
><span>1순위</span><span>포인트</span>
</div>
<button
v-for="auction in ongoingUnique"
:key="auction.id"
class="unique-row clickable-row"
:class="{ selected: selectedUnique?.id === auction.id }"
@click="selectUnique(auction)"
>
<span>{{ auction.id }}</span
><span>{{ auction.detail.title ?? auction.targetCode }}</span>
<span :class="{ 'is-me': auction.isCallerHost }">{{ auction.hostName }}</span>
<span class="tnum">{{ cutDateTime(auction.closeAt) }}</span>
<span>{{ (auction.detail.remainCloseDateExtensionCnt ?? 0) > 0 ? '남음' : '소진' }}</span>
<span :class="{ 'is-me': auction.highestBid?.isCaller }">{{
auction.highestBid?.bidderName ?? '-'
}}</span>
<span class="tnum">{{
formatNumber(auction.highestBid?.amount ?? auction.detail.startBidAmount)
}}</span>
</button>
<p v-if="ongoingUnique.length === 0" class="empty-row">진행중인 유니크 경매가 없습니다.</p>
</section>
<section class="unique-list-section">
<h2 class="subsection-title bg1">종료된 경매 목록</h2>
<div class="unique-row unique-header">
<span>번호</span><span>경매명</span><span>주최자</span><span>종료일시</span> <span>연장</span
><span>1순위</span><span>포인트</span>
</div>
<button
v-for="auction in finishedUnique"
:key="auction.id"
class="unique-row clickable-row"
:class="{ selected: selectedUnique?.id === auction.id }"
@click="selectUnique(auction)"
>
<span>{{ auction.id }}</span
><span>{{ auction.detail.title ?? auction.targetCode }}</span>
<span :class="{ 'is-me': auction.isCallerHost }">{{ auction.hostName }}</span>
<span class="tnum">{{ cutDateTime(auction.closeAt) }}</span>
<span>{{ (auction.detail.remainCloseDateExtensionCnt ?? 0) > 0 ? '남음' : '소진' }}</span>
<span :class="{ 'is-me': auction.highestBid?.isCaller }">{{
auction.highestBid?.bidderName ?? '-'
}}</span>
<span class="tnum">{{
formatNumber(auction.highestBid?.amount ?? auction.detail.startBidAmount)
}}</span>
</button>
<p v-if="finishedUnique.length === 0" class="empty-row">종료된 유니크 경매가 없습니다.</p>
</section>
</section>
<footer class="bottom-bar bg0">
<button class="legacy-button close-button" type="button" @click="closeWindow"> 닫기</button>
</footer>
</main>
</template>
<style scoped>
.auction-page {
min-height: 100%;
padding: 18px;
color: #e8ddc4;
background: radial-gradient(circle at top, rgba(93, 57, 26, 0.25), transparent 42%), #080807;
.legacy-auction-page {
width: 100%;
max-width: 1000px;
box-sizing: border-box;
margin: 0 auto;
color: #fff;
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-size: 14px;
line-height: 21px;
}
.page-header, .tabs, .bid-form, .open-form, .detail-grid, .bid-entry {
display: flex;
.bg0 {
background-color: #302016;
background-image: url('/image/game/back_walnut.jpg');
}
.bg1 {
background-color: #14241b;
background-image: url('/image/game/back_green.jpg');
}
.bg2 {
background-color: #172a52;
background-image: url('/image/game/back_blue.jpg');
}
.top-back-bar {
width: 100%;
height: 32px;
display: grid;
grid-template-columns: 90px 90px 1fr 90px 90px;
}
.top-back-bar h1 {
margin: 0;
font-size: 24px;
font-weight: 500;
line-height: 32px;
text-align: center;
}
.legacy-button {
box-sizing: border-box;
border: solid #3d3d3d;
border-width: 0 1px 4px;
border-radius: 5.25px;
padding: 5.25px 10.5px;
color: #fff;
background: #444;
font: inherit;
font-weight: 700;
line-height: 21px;
cursor: pointer;
}
.legacy-button:hover,
.legacy-button:focus {
border-color: #353535;
background: #393939;
}
.legacy-button:focus-visible {
outline: 2px solid #8ab4f8;
outline-offset: -2px;
}
.legacy-button:active,
.legacy-button[aria-pressed='true'] {
border-color: #303030;
background: #333;
}
.legacy-button:disabled {
cursor: default;
opacity: 0.65;
}
.close-button,
.reload-button {
margin-right: 2px;
border-color: #004f28;
background: #00582c;
}
.close-button:hover,
.close-button:focus,
.reload-button:hover,
.reload-button:focus {
border-color: #004523;
background: #004a25;
}
.top-back-bar .close-button,
.top-back-bar .reload-button {
height: 32px;
}
.tab-button {
border-color: #3d3d3d;
background: #444;
}
.tab-button[aria-pressed='true'] {
border-color: #3d3d3d;
background: #444;
}
.tab-button:hover,
.tab-button:focus {
border-color: #353535;
background: #393939;
}
.section-title,
.subsection-title,
.resource-kind {
margin: 0;
min-height: 18px;
font: inherit;
font-weight: 400;
}
.resource-kind.buy-rice {
color: #000;
background: orange;
}
.resource-kind.sell-rice {
color: #000;
background: skyblue;
}
.resource-row {
width: 100%;
min-height: 22px;
display: grid;
grid-template-columns: 1fr 2fr 2fr 2fr 2fr 1fr 3fr 2fr;
align-items: center;
box-sizing: border-box;
border: 0;
border-bottom: 1px solid gray;
padding: 0;
color: inherit;
background: transparent;
font: inherit;
text-align: center;
}
.page-header { justify-content: space-between; gap: 16px; margin-bottom: 12px; }
.page-header h1 { margin: 0; font-size: 1.45rem; }
.page-header p { margin: 4px 0 0; color: rgba(232, 221, 196, 0.7); }
.tabs { gap: 6px; margin-bottom: 12px; }
button, input, select {
border: 1px solid rgba(201, 164, 90, 0.55);
background: rgba(20, 17, 12, 0.95);
color: #e8ddc4;
padding: 8px 10px;
.clickable-row {
cursor: pointer;
}
button { cursor: pointer; }
button:hover, button:focus-visible, button.active { background: rgba(201, 164, 90, 0.22); }
button:disabled { cursor: not-allowed; opacity: 0.45; }
.ghost { background: transparent; }
.notice { padding: 9px 12px; border: 1px solid; }
.notice.error { color: #ffb3a9; border-color: rgba(255, 90, 70, 0.45); }
.notice.success { color: #b9e6af; border-color: rgba(94, 177, 75, 0.45); }
.auction-page :deep(.panel-card) { margin-bottom: 12px; }
.auction-table { overflow-x: auto; }
.table-head, .table-row { display: grid; min-width: 820px; align-items: center; text-align: center; }
.resource-table .table-head, .resource-table .table-row { grid-template-columns: 52px 84px 1fr 1fr 1fr 1fr 1fr 150px; }
.unique-table .table-head, .unique-table .table-row { grid-template-columns: 52px 2fr 1fr 150px 1fr 110px; }
.table-head { border-bottom: 1px solid rgba(232, 221, 196, 0.4); padding: 7px; color: rgba(232, 221, 196, 0.7); }
.table-row { width: 100%; border: 0; border-bottom: 1px solid rgba(232, 221, 196, 0.12); background: transparent; }
.table-row.selected { background: rgba(201, 164, 90, 0.18); }
.table-row > span { padding: 8px 5px; }
.bid-form { justify-content: center; gap: 12px; margin-top: 14px; flex-wrap: wrap; }
.bid-form label, .open-form label { display: grid; gap: 5px; }
.open-form { align-items: end; gap: 10px; flex-wrap: wrap; }
.open-form label { min-width: 110px; flex: 1; }
.empty { padding: 14px; text-align: center; color: rgba(232, 221, 196, 0.6); }
.log-list { margin: 0; padding-left: 24px; }
.log-list li { padding: 4px 0; }
.detail-grid { display: grid; grid-template-columns: 130px 1fr 130px 1fr; gap: 1px; background: rgba(232, 221, 196, 0.18); }
.detail-grid dt, .detail-grid dd { margin: 0; padding: 9px; background: #11100d; }
.detail-grid dt { color: rgba(232, 221, 196, 0.65); }
.bid-history { margin-top: 12px; }
.bid-entry { justify-content: space-between; gap: 12px; padding: 7px 10px; border-bottom: 1px solid rgba(232, 221, 196, 0.14); }
.bid-entry time { color: rgba(232, 221, 196, 0.65); }
.me { color: aquamarine; font-weight: 700; }
@media (max-width: 720px) {
.auction-page { padding: 10px; }
.page-header { align-items: flex-start; }
.detail-grid { grid-template-columns: 110px 1fr; }
.clickable-row:hover,
.clickable-row:focus-visible,
.clickable-row.selected {
background-color: rgb(255 255 255 / 12%);
outline: 0;
}
.no-bid {
color: #ccc;
}
.tnum {
font-variant-numeric: tabular-nums;
}
.empty-row {
min-height: 24px;
margin: 0;
padding: 4px 8px;
text-align: center;
}
.resource-bid-form {
min-height: 42px;
display: grid;
grid-template-columns: 2fr 2fr 1fr;
align-items: center;
gap: 4px;
padding-right: 33.3333%;
padding-left: 25%;
}
.bid-description {
text-align: right;
}
input {
width: 100%;
min-width: 0;
box-sizing: border-box;
border: 1px solid #000;
border-radius: 5.25px;
padding: 5.25px 10.5px;
color: #303030;
background: #ddd;
font: inherit;
}
input:focus-visible {
outline: 2px solid #8ab4f8;
outline-offset: -2px;
}
.open-form {
min-height: 76px;
display: grid;
grid-template-columns: 1fr 2fr 1fr 2fr 2fr 1fr;
align-items: end;
gap: 4px;
box-sizing: border-box;
padding-right: 8.3333%;
padding-left: 16.6667%;
}
.open-form fieldset,
.open-form label {
min-width: 0;
margin: 0;
border: 0;
padding: 0;
}
.open-form legend {
padding: 0;
}
.open-form label {
display: grid;
gap: 2px;
}
.item-toggle {
display: flex;
}
.item-toggle .legacy-button {
flex: 1;
}
.register-button {
height: 100%;
align-self: stretch;
}
.recent-logs {
min-height: 24px;
}
.caller-alias {
min-height: 20px;
}
.caller-alias strong,
.is-me {
color: aqua;
font-weight: 700;
}
.detail-grid {
display: grid;
grid-template-columns: 1fr 2fr 1fr 2fr 1fr 2fr 1fr 2fr;
margin: 0;
text-align: center;
}
.detail-grid dt,
.detail-grid dd {
min-width: 0;
min-height: 18px;
display: grid;
align-content: center;
margin: 0;
}
.bid-row {
min-height: 22px;
display: grid;
grid-template-columns: 3fr 2fr 3fr;
align-items: center;
padding: 0 20%;
text-align: center;
}
.bid-header {
border-bottom: 1px solid #fff;
}
.bid-row > :nth-child(2) {
padding-right: 20px;
text-align: right;
}
.unique-bid-form {
min-height: 40px;
display: grid;
grid-template-columns: 3fr 2fr 1fr;
align-items: center;
padding: 0 25%;
}
.unique-bid-form label {
text-align: center;
}
.unique-row {
width: 100%;
min-height: 22px;
display: grid;
grid-template-columns: 1fr 4fr 1fr 2fr 1fr 1fr 2fr;
align-items: center;
box-sizing: border-box;
border: 0;
padding: 0;
color: inherit;
background: transparent;
font: inherit;
text-align: center;
}
.unique-header {
border-bottom: 1px solid #fff;
}
.unique-row > :last-child {
padding-right: 8px;
text-align: right;
}
.auction-notice {
min-height: 28px;
box-sizing: border-box;
margin: 0;
padding: 5px 8px;
}
.auction-notice.error {
background: #842029;
}
.auction-notice.success {
background: #0f5132;
}
.loading-state {
min-height: 120px;
display: grid;
place-items: center;
}
.bottom-bar {
padding-top: 20px;
}
@media (max-width: 991px) {
.legacy-auction-page {
max-width: none;
}
}
@media (max-width: 500px) {
.resource-row {
min-height: 43px;
grid-template-columns: 1fr 3fr 3fr 1fr 2fr 2fr;
grid-template-rows: 1fr 1fr;
}
.resource-row .idx {
grid-column: 1;
grid-row: 1 / 3;
}
.resource-row .host {
grid-column: 2;
grid-row: 1;
}
.resource-row .amount {
grid-column: 2;
grid-row: 2;
}
.resource-row .highest-bidder {
grid-column: 3;
grid-row: 1;
}
.resource-row .highest-bid {
grid-column: 3;
grid-row: 2;
}
.resource-row .bid-ratio {
grid-column: 4;
grid-row: 1 / 3;
}
.resource-row .finish-bid {
grid-column: 5;
grid-row: 1 / 3;
}
.resource-row .close-date {
grid-column: 6;
grid-row: 1 / 3;
}
.resource-bid-form {
grid-template-columns: 4fr 3fr 2fr;
padding-right: 16.6667%;
padding-left: 8.3333%;
}
.open-form {
grid-template-columns: 2fr 2.3333fr 2fr 2.3333fr 2.3333fr 1fr;
padding: 0;
}
.detail-grid {
grid-template-columns: 2fr 4fr 2fr 4fr;
}
.bid-row {
padding: 0;
grid-template-columns: 4fr 4fr 4fr;
}
.unique-bid-form {
padding: 0;
grid-template-columns: 5fr 4fr 3fr;
}
}
</style>
+180 -56
View File
@@ -3,7 +3,6 @@ import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
import { trpc } from '../utils/trpc';
import { getNpcColor } from '../utils/npcColor';
import { formatLog } from '../utils/formatLog';
@@ -269,7 +268,26 @@ onMounted(() => {
</PanelCard>
<PanelCard title="장수 정보">
<GeneralBasicCard :general="selectedGeneral" :loading="loading" />
<SkeletonLines v-if="loading" :lines="5" />
<div v-else-if="selectedGeneral" class="battle-general-card">
<div class="battle-general-name">
{{ selectedGeneral.name }} (관직 {{ selectedGeneral.officerLevel }})
</div>
<div class="battle-general-grid">
<span>통솔</span><strong>{{ selectedGeneral.stats.leadership }}</strong> <span>무력</span
><strong>{{ selectedGeneral.stats.strength }}</strong> <span>지력</span
><strong>{{ selectedGeneral.stats.intelligence }}</strong> <span>자금</span
><strong>{{ selectedGeneral.gold }}</strong> <span>군량</span
><strong>{{ selectedGeneral.rice }}</strong> <span>병력</span
><strong>{{ selectedGeneral.crew }}</strong> <span>훈련</span
><strong>{{ selectedGeneral.train }}</strong> <span>사기</span
><strong>{{ selectedGeneral.atmos }}</strong> <span>부상</span
><strong>{{ selectedGeneral.injury }}</strong> <span>경험</span
><strong>{{ selectedGeneral.experience }}</strong> <span>공헌</span
><strong>{{ selectedGeneral.dedication }}</strong> <span>전투</span
><strong>{{ selectedGeneral.warnum }}</strong>
</div>
</div>
<div v-if="selectedGeneral" class="general-meta">
<div>최근 : {{ selectedGeneral.turnTime ? selectedGeneral.turnTime.slice(-5) : '-' }}</div>
<div>최근 전투: {{ selectedGeneral.recentWar || '-' }}</div>
@@ -286,12 +304,7 @@ onMounted(() => {
<SkeletonLines v-if="loading || logLoading" :lines="3" />
<template v-else>
<div v-if="logs[type].length === 0" class="empty">기록이 없습니다.</div>
<div
v-for="entry in logs[type]"
:key="entry.id"
class="log-line"
v-html="entry.html"
/>
<div v-for="entry in logs[type]" :key="entry.id" class="log-line" v-html="entry.html" />
</template>
</div>
</div>
@@ -303,126 +316,237 @@ onMounted(() => {
<style scoped>
.battle-page {
width: 100%;
min-width: 500px;
max-width: 1000px;
min-height: 100vh;
padding: 24px;
margin: 0 auto;
padding: 0;
display: flex;
flex-direction: column;
gap: 24px;
gap: 0;
color: #fff;
background-color: #302016;
background-image: url('/image/game/back_walnut.jpg');
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-size: 14px;
line-height: 1.5;
}
.page-header {
position: relative;
min-height: 32px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
padding: 0 8px;
border: 1px solid #666;
background-color: #302016;
background-image: url('/image/game/back_walnut.jpg');
}
.page-title {
font-size: 1.6rem;
font-weight: 700;
font-size: 17px;
font-weight: 500;
}
.page-subtitle {
color: rgba(232, 221, 196, 0.7);
margin-top: 6px;
display: none;
}
.header-actions {
position: absolute;
left: 0;
top: 0;
display: flex;
flex-wrap: wrap;
gap: 10px;
gap: 4px;
}
.layout-grid {
display: grid;
grid-template-columns: minmax(0, 320px) minmax(0, 1fr);
gap: 18px;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0;
}
.stack {
display: flex;
flex-direction: column;
gap: 18px;
display: contents;
}
.selector-row {
display: grid;
grid-template-columns: auto minmax(140px, 1fr) minmax(180px, 2fr) auto;
gap: 8px;
grid-template-columns: 8.333% 33.333% 50% 8.333%;
gap: 0;
align-items: center;
}
.select-input {
min-width: 0;
padding: 6px 8px;
border: 1px solid rgba(201, 164, 90, 0.4);
background: rgba(12, 12, 12, 0.7);
height: 36px;
padding: 4px 6px;
border: 1px solid #777;
border-radius: 0;
background: #303030;
color: inherit;
font-size: 0.85rem;
font: inherit;
}
.ghost {
border: 1px solid rgba(201, 164, 90, 0.5);
background: transparent;
min-height: 32px;
border: 1px solid #777;
border-radius: 0;
background: #303030;
color: inherit;
padding: 6px 10px;
font-size: 0.8rem;
padding: 4px 8px;
font: inherit;
cursor: pointer;
}
.general-meta {
margin-top: 10px;
font-size: 0.85rem;
color: rgba(232, 221, 196, 0.75);
margin: 0;
padding: 6px 8px;
color: #ccc;
display: grid;
gap: 4px;
}
.log-grid {
.battle-general-card {
min-height: 292px;
background-color: #172a52;
background-image: url('/image/game/back_blue.jpg');
}
.battle-general-name {
min-height: 24px;
padding: 2px 6px;
text-align: center;
border-bottom: 1px solid #777;
background: rgba(220, 220, 220, 0.85);
color: #111;
font-weight: 700;
}
.battle-general-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
grid-template-columns: repeat(6, 1fr);
}
.battle-general-grid > * {
min-height: 24px;
padding: 2px 5px;
border-right: 1px solid #777;
border-bottom: 1px solid #777;
}
.battle-general-grid > span {
background-color: rgba(20, 75, 42, 0.7);
color: #fff;
text-align: center;
}
.battle-general-grid > strong {
text-align: right;
font-weight: 500;
}
.log-grid {
display: contents;
}
.log-block {
border: 1px solid rgba(201, 164, 90, 0.3);
padding: 8px;
background: rgba(12, 12, 12, 0.6);
min-height: 160px;
border: 1px solid #666;
padding: 0;
background: #111;
min-height: 180px;
}
.log-title {
font-weight: 600;
margin-bottom: 6px;
font-size: 0.9rem;
min-height: 34px;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid #666;
color: orange;
background: #252525;
font-size: 1.3em;
font-weight: 500;
}
.log-line {
padding: 4px 0;
border-bottom: 1px dashed rgba(201, 164, 90, 0.2);
}
.log-line:last-child {
border-bottom: none;
padding: 2px 8px;
border-bottom: 0;
}
.empty {
color: rgba(232, 221, 196, 0.6);
font-size: 0.85rem;
padding: 2px 8px;
color: #999;
}
.error {
color: #f08a5d;
font-size: 0.9rem;
padding: 5px 8px;
color: #ff7777;
border: 1px solid #a33;
text-align: center;
}
@media (max-width: 1024px) {
/* PanelCard is retained as a data wrapper, but its presentation follows the
flat bootstrap rows used by the reference page. */
:deep(.panel-card) {
height: 100%;
border: 1px solid #666;
border-radius: 0;
background-color: #302016;
background-image: url('/image/game/back_walnut.jpg');
box-shadow: none;
}
.stack:first-child :deep(.panel-card:first-child) {
grid-column: 1 / -1;
border: 0;
}
.stack:first-child :deep(.panel-card:first-child .panel-header) {
display: none;
}
.stack:first-child :deep(.panel-card:first-child .panel-body) {
padding: 0;
}
.stack:nth-child(2) :deep(.panel-card),
.stack:nth-child(2) :deep(.panel-body) {
display: contents;
}
.stack:nth-child(2) :deep(.panel-header) {
display: none;
}
:deep(.panel-header) {
min-height: 29px;
justify-content: center;
padding: 0;
}
:deep(.panel-title) {
color: skyblue;
font-size: 18px;
font-weight: 500;
}
:deep(.panel-header),
.log-title {
background-image: url('/image/game/back_green.jpg');
}
@media (max-width: 991px) {
.battle-page {
width: 500px;
}
.layout-grid {
grid-template-columns: 1fr;
}
.selector-row {
grid-template-columns: 16.666% 25% 41.666% 16.666%;
}
.log-grid {
grid-template-columns: 1fr;
}
}
@@ -26,6 +26,7 @@ type BattleExport = {
type ExportedInfo = { objType: 'general'; data: GeneralExport } | { objType: 'battle'; data: BattleExport };
type GeneralListResponse = Awaited<ReturnType<typeof trpc.battle.getGeneralList.query>>;
type GeneralMeResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
const loading = ref(true);
const error = ref<string | null>(null);
@@ -72,6 +73,7 @@ const importTarget = ref<GeneralDraft | null>(null);
const generalList = ref<GeneralListResponse | null>(null);
const generalListLoading = ref(false);
const selectedGeneralId = ref<number | null>(null);
const gameDefaults = ref<GeneralMeResponse>(null);
let generalIdSeed = 0;
@@ -250,6 +252,7 @@ const initializeDefaults = async () => {
try {
const [context, me] = await Promise.all([trpc.battle.getSimulatorContext.query(), trpc.general.me.query()]);
options.value = context;
gameDefaults.value = me;
year.value = context.world.currentYear;
month.value = context.world.currentMonth;
repeatCnt.value = 1;
@@ -283,6 +286,47 @@ const initializeDefaults = async () => {
}
};
const hasGameGeneral = computed(() => !!gameDefaults.value?.general?.id);
const applyGameEnvironment = () => {
if (!options.value) {
return;
}
const me = gameDefaults.value;
const nationTypeDefault = options.value.nationTypes[0]?.key ?? 'che_중립';
year.value = options.value.world.currentYear;
month.value = options.value.world.currentMonth;
attackerNation.type = me?.nation?.typeCode ?? nationTypeDefault;
defenderNation.type = attackerNation.type;
attackerNation.level = me?.nation?.level ?? 0;
defenderNation.level = attackerNation.level;
attackerNation.tech = me?.nation?.tech ? Math.floor(me.nation.tech / 1000) : 1;
defenderNation.tech = attackerNation.tech;
attackerCity.level = me?.city?.level ?? 5;
defenderCity.level = attackerCity.level;
defenderCity.def = me?.city?.defence ?? 1000;
defenderCity.wall = me?.city?.wall ?? 1000;
attackerNation.isCapital = !!me?.city && me.nation?.capitalCityId === me.city.id;
defenderNation.isCapital = attackerNation.isCapital;
error.value = null;
};
const applyIndependentEnvironment = () => {
if (!options.value) {
return;
}
const nationTypeDefault = options.value.nationTypes[0]?.key ?? 'che_중립';
year.value = options.value.world.startYear;
month.value = 1;
seed.value = '';
repeatCnt.value = 1;
Object.assign(attackerNation, { type: nationTypeDefault, tech: 1, level: 0, isCapital: false });
Object.assign(defenderNation, { type: nationTypeDefault, tech: 1, level: 0, isCapital: false });
attackerCity.level = 5;
Object.assign(defenderCity, { level: 5, def: 1000, wall: 1000 });
error.value = null;
};
onMounted(() => {
void initializeDefaults();
});
@@ -536,6 +580,10 @@ const runSimulation = async (action: BattleSimRequestPayload['action']) => {
}
isSimulating.value = true;
error.value = null;
if (action === 'battle') {
battleResult.value = null;
}
statusMessage.value = action === 'battle' ? '전투를 진행 중입니다.' : '수비자 순서를 계산 중입니다.';
try {
@@ -788,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) {
@@ -803,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 {
@@ -941,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>
@@ -1036,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 })"
@@ -1103,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 })"
@@ -1176,6 +1271,8 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
display: flex;
flex-direction: column;
gap: 18px;
width: min(100%, 1000px);
margin: 0 auto;
padding-bottom: 30px;
background:
radial-gradient(circle at top left, rgba(201, 164, 90, 0.15), transparent 45%),
@@ -1207,6 +1304,34 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
gap: 8px;
}
.independence-notice {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 10px 12px;
border: 1px solid rgba(112, 170, 141, 0.45);
background: rgba(18, 52, 40, 0.35);
}
.independence-notice strong {
color: #bfe2cd;
font-size: 0.85rem;
}
.independence-notice p {
margin: 4px 0 0;
color: rgba(221, 239, 228, 0.75);
font-size: 0.75rem;
}
.notice-actions {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
gap: 6px;
}
button {
font-family: inherit;
background: none;
@@ -1230,6 +1355,11 @@ button {
background: rgba(16, 16, 16, 0.6);
}
button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.error {
color: #f5b7b1;
font-size: 0.85rem;
@@ -1369,5 +1499,10 @@ button {
flex-direction: column;
align-items: flex-start;
}
.independence-notice {
align-items: flex-start;
flex-direction: column;
}
}
</style>
+292 -61
View File
@@ -1,5 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { trpc } from '../utils/trpc';
type RankEntry = {
@@ -11,7 +13,6 @@ type RankEntry = {
fgColor: string;
picture: string | null;
imageServer: number;
value: number;
printValue: string;
};
@@ -21,18 +22,17 @@ type RankSection = {
entries: RankEntry[];
};
type UniqueOwner = {
id: number;
name: string;
nationName: string;
bgColor: string;
fgColor: string;
type UniqueItemEntry = {
itemKey: string;
itemName: string;
itemInfo: string;
owner: Omit<RankEntry, 'ownerName' | 'printValue'>;
};
type UniqueItemSection = {
title: string;
slot: string;
owners: UniqueOwner[];
entries: UniqueItemEntry[];
};
type BestGeneralPayload = {
@@ -41,12 +41,26 @@ type BestGeneralPayload = {
uniqueItems: UniqueItemSection[];
};
const router = useRouter();
const viewMode = ref<'user' | 'npc'>('user');
const loading = ref(false);
const errorMessage = ref('');
const data = ref<BestGeneralPayload | null>(null);
const refresh = async () => {
const imageUrl = (entry: { picture: string | null; imageServer: number }): string => {
const picture = entry.picture?.trim() || 'default.jpg';
return entry.imageServer ? `${import.meta.env.BASE_URL}d_pic/${picture}` : `/image/icons/${picture}`;
};
const closePage = async (): Promise<void> => {
if (window.opener) {
window.close();
return;
}
await router.push('/');
};
const refresh = async (): Promise<void> => {
loading.value = true;
errorMessage.value = '';
try {
@@ -58,8 +72,6 @@ const refresh = async () => {
}
};
const emptyLabel = computed(() => (loading.value ? '불러오는 중...' : '표시할 데이터가 없습니다.'));
onMounted(() => {
void refresh();
});
@@ -70,61 +82,280 @@ watch(viewMode, () => {
</script>
<template>
<main class="main-page">
<header class="page-header">
<div>
<h1 class="page-title">명장일람</h1>
<p class="page-subtitle">전장 기록을 기준으로 장수 순위를 확인합니다.</p>
</div>
<div class="header-actions">
<button class="ghost" :class="{ active: viewMode === 'user' }" @click="viewMode = 'user'">
유저 보기
</button>
<button class="ghost" :class="{ active: viewMode === 'npc' }" @click="viewMode = 'npc'">
NPC 보기
</button>
<button class="ghost" @click="refresh">새로고침</button>
</div>
</header>
<main id="best-general-container" class="legacy-ranking-page legacy-bg0">
<div class="legacy-ranking-title">
<br />
<button class="legacy-button" type="button" @click="closePage"> 닫기</button>
</div>
<div v-if="errorMessage" class="error">{{ errorMessage }}</div>
<div v-else-if="!data" class="placeholder">{{ emptyLabel }}</div>
<div class="view-selector" role="group" aria-label="장수 유형">
<button
class="legacy-button"
type="button"
:aria-pressed="viewMode === 'user'"
@click="viewMode = 'user'"
>
유저 보기
</button>
<button
class="legacy-button"
type="button"
:aria-pressed="viewMode === 'npc'"
@click="viewMode = 'npc'"
>
NPC 보기
</button>
</div>
<section v-if="data" class="grid gap-4">
<div v-for="section in data.sections" :key="section.title" class="bg-zinc-900 border border-zinc-800 rounded p-4">
<h2 class="text-base font-semibold mb-3">{{ section.title }}</h2>
<div v-if="section.entries.length === 0" class="text-xs text-zinc-500">{{ emptyLabel }}</div>
<ul v-else class="space-y-2">
<li
v-for="entry in section.entries"
:key="entry.id"
class="flex items-center justify-between bg-zinc-950 border border-zinc-800 rounded px-3 py-2 text-sm"
>
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full" :style="{ backgroundColor: entry.bgColor }" />
<span class="font-semibold">{{ entry.name }}</span>
<span class="text-xs text-zinc-400">{{ entry.nationName }}</span>
<div v-if="errorMessage" class="legacy-message error" role="alert">{{ errorMessage }}</div>
<div v-else-if="loading && !data" class="legacy-message">불러오는 중...</div>
<section v-if="data" class="ranking-sections" :aria-busy="loading">
<article v-for="section in data.sections" :key="section.title" class="rankView legacy-bg0">
<h2 class="rankType legacy-bg1">{{ section.title }}</h2>
<ul>
<li v-for="(entry, rank) in section.entries" :key="`${section.title}:${entry.id}:${rank}`">
<div class="hall-rank legacy-bg2">{{ rank + 1 }}</div>
<div class="hall-img">
<img class="generalIcon" :src="imageUrl(entry)" width="64" height="64" :alt="entry.name" />
</div>
<div class="text-xs text-zinc-200">{{ entry.printValue }}</div>
<div class="hall-nation" :style="{ backgroundColor: entry.bgColor, color: entry.fgColor }">
{{ entry.nationName || '-' }}
</div>
<div class="hall-name" :style="{ backgroundColor: entry.bgColor, color: entry.fgColor }">
<span>{{ entry.name || '-' }}</span>
<small v-if="entry.ownerName">({{ entry.ownerName }})</small>
</div>
<div class="hall-value">{{ entry.printValue }}</div>
</li>
</ul>
</div>
</article>
<article v-for="section in data.uniqueItems" :key="section.slot" class="rankView legacy-bg0">
<h2 class="rankType legacy-bg1">{{ section.title }}</h2>
<ul>
<li
v-for="(entry, index) in section.entries"
:key="`${entry.itemKey}:${index}`"
class="no-value"
>
<div class="hall-rank legacy-bg2 item-name" :title="entry.itemInfo">{{ entry.itemName }}</div>
<div class="hall-img">
<img
class="generalIcon"
:src="imageUrl(entry.owner)"
width="64"
height="64"
:alt="entry.owner.name"
/>
</div>
<div
class="hall-nation"
:style="{ backgroundColor: entry.owner.bgColor, color: entry.owner.fgColor }"
>
{{ entry.owner.nationName || '-' }}
</div>
<div
class="hall-name"
:style="{ backgroundColor: entry.owner.bgColor, color: entry.owner.fgColor }"
>
<span>{{ entry.owner.name || '-' }}</span>
</div>
</li>
</ul>
</article>
</section>
<section v-if="data" class="mt-6 bg-zinc-900 border border-zinc-800 rounded p-4">
<h2 class="text-base font-semibold mb-3">유니크 아이템 소유자</h2>
<div class="grid md:grid-cols-2 gap-4">
<div v-for="item in data.uniqueItems" :key="item.title" class="bg-zinc-950 border border-zinc-800 rounded p-3">
<h3 class="text-sm font-semibold mb-2">{{ item.title }}</h3>
<ul class="space-y-1 text-xs">
<li v-for="owner in item.owners" :key="owner.id" class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full" :style="{ backgroundColor: owner.bgColor }" />
<span>{{ owner.name }}</span>
<span class="text-zinc-500">{{ owner.nationName }}</span>
</li>
</ul>
</div>
</div>
</section>
<div class="legacy-ranking-bottom">
<button class="legacy-button" type="button" @click="closePage"> 닫기</button>
</div>
<footer class="legacy-banner">
삼국지 모의전투 HiDCHe core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD /
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a>
</footer>
</main>
</template>
<style scoped>
:global(body) {
min-width: 500px;
overflow-x: hidden;
}
.legacy-ranking-page {
width: 500px;
min-height: 100vh;
margin: 0 auto 100px;
color: #fff;
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-size: 14px;
}
.legacy-ranking-title,
.legacy-ranking-bottom {
text-align: left;
}
.view-selector {
text-align: center;
}
.legacy-ranking-title {
padding-top: 2px;
}
.view-selector {
padding: 2px 0;
}
.view-selector .legacy-button + .legacy-button {
margin-left: 4px;
}
.view-selector .legacy-button[aria-pressed='true'] {
border-style: inset;
}
.legacy-button {
border: 0;
border-radius: 5.25px;
background: #375a7f;
padding: 5.25px 10.5px;
font-weight: 700;
line-height: 21px;
}
.legacy-button:hover,
.legacy-button:focus,
.legacy-button:active {
background: #6b6b6b;
}
.legacy-button:focus-visible {
outline: revert;
outline-offset: 0;
}
.legacy-message {
border: 1px solid gray;
padding: 12px;
text-align: center;
}
.legacy-message.error {
color: #ff6b6b;
}
.legacy-banner {
font-size: 13px;
}
.legacy-banner a {
color: #fff;
text-decoration: underline;
}
.ranking-sections {
display: block;
}
.rankView {
position: relative;
margin: auto;
outline: 1px solid gray;
}
.rankType {
margin: 0;
border-bottom: 1px solid gray;
padding: 2px;
font-size: calc(19px + 0.784615vw);
font-weight: 500;
line-height: 1.2;
text-align: center;
}
.rankView ul {
display: flex;
flex-wrap: wrap;
box-sizing: border-box;
margin: -1px 0;
padding: 0;
list-style: none;
}
.rankView li {
box-sizing: border-box;
flex: 0 0 100px;
width: 100px;
min-height: 149px;
margin: 0;
border-top: 1px solid gray;
border-right: 1px solid gray;
text-align: center;
vertical-align: top;
}
.rankView li.no-value {
min-height: 128px;
}
.hall-rank,
.hall-nation,
.hall-value {
border-bottom: 1px solid gray;
}
.hall-rank.item-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hall-img {
height: 64px;
}
.generalIcon {
display: inline-block;
width: 64px;
height: 64px;
object-fit: fill;
}
.hall-nation,
.hall-name {
font-size: 11px;
}
.hall-name {
display: flex;
height: 28px;
flex-direction: column;
justify-content: center;
}
.hall-name small {
font-size: 95%;
}
.hall-value {
box-sizing: border-box;
padding: 3px 0;
line-height: 13px;
}
@media (min-width: 1000px) {
:global(body) {
min-width: 1000px;
}
.legacy-ranking-page {
width: 1000px;
}
.rankType {
font-size: 28px;
}
}
</style>
+45 -5
View File
@@ -135,7 +135,7 @@ onMounted(async () => {
</select>
</label>
<div v-if="errorMessage" class="legacy-message error">{{ errorMessage }}</div>
<div v-if="errorMessage" class="legacy-message error" role="alert">{{ errorMessage }}</div>
<div v-else-if="loading" class="legacy-message">불러오는 중...</div>
<div v-else-if="!data" class="legacy-message">표시할 데이터가 없습니다.</div>
@@ -171,6 +171,10 @@ onMounted(async () => {
<div class="legacy-hall-bottom">
<button class="legacy-button" type="button" @click="closePage"> 닫기</button>
</div>
<footer class="legacy-banner">
삼국지 모의전투 HiDCHe core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD /
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a>
</footer>
</main>
</template>
@@ -191,7 +195,7 @@ onMounted(async () => {
.legacy-hall-title,
.legacy-hall-bottom {
text-align: center;
text-align: left;
}
.legacy-hall-title {
@@ -205,12 +209,33 @@ onMounted(async () => {
}
.scenario-search select {
min-width: 220px;
width: 189px;
height: 20px;
border: 1px solid #555;
background: #ddd;
color: #303030;
}
.legacy-button {
border: 0;
border-radius: 5.25px;
background: #375a7f;
padding: 5.25px 10.5px;
font-weight: 700;
line-height: 21px;
}
.legacy-button:hover,
.legacy-button:focus,
.legacy-button:active {
background: #6b6b6b;
}
.legacy-button:focus-visible {
outline: revert;
outline-offset: 0;
}
.legacy-message {
border: 1px solid gray;
padding: 12px;
@@ -221,6 +246,15 @@ onMounted(async () => {
color: #ff6b6b;
}
.legacy-banner {
font-size: 13px;
}
.legacy-banner a {
color: #fff;
text-decoration: underline;
}
.hall-sections {
display: block;
}
@@ -235,7 +269,9 @@ onMounted(async () => {
margin: 0;
border-bottom: 1px solid gray;
padding: 2px;
font-size: 1.17em;
font-size: calc(19px + 0.784615vw);
font-weight: 500;
line-height: 1.2;
text-align: center;
}
@@ -275,7 +311,7 @@ onMounted(async () => {
display: inline-block;
width: 64px;
height: 64px;
object-fit: cover;
object-fit: fill;
}
.hall-server,
@@ -309,5 +345,9 @@ onMounted(async () => {
.legacy-hall-page {
width: 1000px;
}
.rankType {
font-size: 28px;
}
}
</style>
+13 -10
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { RouterLink, useRouter } from 'vue-router';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import { trpc } from '../utils/trpc';
@@ -281,6 +281,7 @@ onMounted(() => {
<p class="join-subtitle">로그인 완료, 아직 장수가 없는 상태입니다.</p>
</div>
<div class="join-tabs">
<RouterLink class="simulator-link" to="/battle-simulator">전투 시뮬레이터</RouterLink>
<button :class="{ active: activeTab === 'create' }" @click="activeTab = 'create'">장수 생성</button>
<button :class="{ active: activeTab === 'possess' }" @click="activeTab = 'possess'">NPC 빙의</button>
</div>
@@ -464,17 +465,13 @@ onMounted(() => {
<section v-else class="join-grid">
<PanelCard title="빙의 가능한 NPC 목록" subtitle="NPC 타입2 장수를 선택해 빙의합니다.">
<template #actions>
<button class="ghost" :disabled="npcLoading" @click="loadNpcCandidates(true)">
목록 새로고침
</button>
<button class="ghost" :disabled="npcLoading" @click="loadNpcCandidates(true)">목록 새로고침</button>
</template>
<div v-if="npcError" class="muted">{{ npcError }}</div>
<div v-if="npcLoading && npcCandidates.length === 0">
<SkeletonLines :lines="3" />
</div>
<div v-else-if="npcCandidates.length === 0" class="muted">
빙의 가능한 NPC가 없습니다.
</div>
<div v-else-if="npcCandidates.length === 0" class="muted">빙의 가능한 NPC가 없습니다.</div>
<div v-else class="npc-list">
<div v-for="npc in npcCandidates" :key="npc.id" class="npc-card">
<div class="npc-header">
@@ -490,9 +487,7 @@ onMounted(() => {
<div>나이 {{ npc.age }}</div>
<div>도시 {{ npc.city?.name ?? '-' }}</div>
</div>
<button class="npc-action" :disabled="submitting" @click="possessGeneral(npc.id)">
빙의
</button>
<button class="npc-action" :disabled="submitting" @click="possessGeneral(npc.id)">빙의</button>
</div>
</div>
<div class="npc-footer">
@@ -542,6 +537,14 @@ onMounted(() => {
font-size: 0.8rem;
}
.simulator-link {
border: 1px solid rgba(112, 170, 141, 0.55);
padding: 6px 10px;
color: #bfe2cd;
font-size: 0.8rem;
text-decoration: none;
}
.join-tabs button.active {
background: rgba(201, 164, 90, 0.2);
}
+53 -34
View File
@@ -50,7 +50,7 @@ const {
reservedNationTurns,
messageDraftText,
targetMailbox,
mailboxOptions,
mailboxGroups,
statusLine,
realtimeLabel,
} = storeToRefs(dashboard);
@@ -119,10 +119,11 @@ watch(
<RouterLink class="ghost" to="/dynasty">왕조일람</RouterLink>
<RouterLink class="ghost" to="/yearbook">연감</RouterLink>
<RouterLink class="ghost" to="/nation-betting">천통국 베팅</RouterLink>
<RouterLink class="ghost" to="/traffic">접속량정보</RouterLink>
<RouterLink class="ghost" to="/npc-list">빙의일람</RouterLink>
<a class="ghost" href="/xe/community" target="_blank" rel="noopener">게시판</a>
<RouterLink class="ghost" to="/battle-simulator">전투 시뮬레이터</RouterLink>
<RouterLink class="ghost" to="/my-page"> 정보</RouterLink>
<RouterLink class="ghost" to="/my-page"> 정보&amp;설정</RouterLink>
<RouterLink class="ghost" :class="{ highlight: tournamentStage === 1 }" to="/tournament"
>토너먼트</RouterLink
>
@@ -220,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>
@@ -255,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">
@@ -306,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>
@@ -400,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;
+543 -467
View File
@@ -1,19 +1,16 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useMediaQuery } from '@vueuse/core';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
import CityBasicCard from '../components/main/CityBasicCard.vue';
import NationBasicCard from '../components/main/NationBasicCard.vue';
import { trpc } from '../utils/trpc';
import { formatLog } from '../utils/formatLog';
const SCREEN_MODE_KEY = 'sammo-screen-mode';
const SCREEN_MODE_KEY = 'sam.screenMode';
const CUSTOM_CSS_KEY = 'sam_customCSS';
type ScreenMode = 'auto' | '500px' | '1000px';
type LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction';
type ItemSlotKey = 'horse' | 'weapon' | 'book' | 'item';
type MyGeneralResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
type WorldStateSnapshot = {
type WorldSnapshot = {
currentYear: number;
currentMonth: number;
tickSeconds: number;
@@ -21,48 +18,54 @@ type WorldStateSnapshot = {
meta: Record<string, unknown>;
} | null;
type LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction';
type LogLine = {
id: number;
html: string;
};
type ItemSlotKey = 'horse' | 'weapon' | 'book' | 'item';
type ItemSlot = {
key: ItemSlotKey;
label: string;
code: string | null;
};
const logTypes: LogType[] = ['generalHistory', 'battleDetail', 'battleResult', 'generalAction'];
const logLabels: Record<LogType, string> = {
generalHistory: '장수 열전',
battleDetail: '전투 기록',
battleResult: '전투 결과',
generalAction: '개인 기록',
type SettingForm = {
tnmt: number;
defence_train: number;
use_treatment: number;
use_auto_nation_turn: number;
};
const data = ref<MyGeneralResponse | null>(null);
const world = ref<WorldSnapshot>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const data = ref<MyGeneralResponse | null>(null);
const worldState = ref<WorldStateSnapshot>(null);
const screenMode = ref<ScreenMode>('auto');
const customCss = ref('');
const cssSaving = ref(false);
let cssTimer: number | null = null;
const logs = reactive<Record<LogType, LogLine[]>>({
const form = reactive<SettingForm>({
tnmt: 1,
defence_train: 80,
use_treatment: 10,
use_auto_nation_turn: 1,
});
const logTypes: LogType[] = ['generalAction', 'battleDetail', 'generalHistory', 'battleResult'];
const logLabels: Record<LogType, string> = {
generalAction: '개인 기록',
battleDetail: '전투 기록',
generalHistory: '장수 열전',
battleResult: '전투 결과',
};
const logColors: Record<LogType, string> = {
generalAction: 'skyblue',
battleDetail: 'orange',
generalHistory: 'skyblue',
battleResult: 'orange',
};
const logs = reactive<Record<LogType, Array<{ id: number; html: string }>>>({
generalHistory: [],
battleDetail: [],
battleResult: [],
generalAction: [],
});
const logLoading = reactive<Record<LogType, boolean>>({
generalHistory: false,
battleDetail: false,
battleResult: false,
generalAction: false,
});
const logHasMore = reactive<Record<LogType, boolean>>({
generalHistory: true,
battleDetail: true,
@@ -70,520 +73,593 @@ const logHasMore = reactive<Record<LogType, boolean>>({
generalAction: true,
});
const activeLogTab = ref<LogType>('generalAction');
const isMobile = useMediaQuery('(max-width: 1024px)');
const screenMode = ref<'auto' | '500px' | '1000px'>('auto');
const errorText = (value: unknown): string =>
value instanceof Error ? value.message : typeof value === 'string' ? value : 'unknown_error';
const resolveErrorMessage = (value: unknown): string => {
if (value instanceof Error) {
return value.message;
}
if (typeof value === 'string') {
return value;
}
return 'unknown_error';
const asRecord = (value: unknown): Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
const numberValue = (value: unknown, fallback: number): number => {
const parsed = typeof value === 'number' ? value : Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
};
const resolveNumber = (value: unknown, fallback: number): number => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return fallback;
};
const statusLine = computed(() =>
world.value
? `${world.value.currentYear}${world.value.currentMonth}월 · ${Math.max(
1,
Math.round(world.value.tickSeconds / 60)
)}분 턴`
: '내 정보를 불러오는 중'
);
const statusLine = computed(() => {
if (!worldState.value) {
return '내 정보를 불러오는 중';
}
const turnTerm = resolveNumber((worldState.value.config as Record<string, unknown>)?.turnTermMinutes, 0);
const termLabel = turnTerm > 0 ? ` · 턴 ${turnTerm}` : '';
return `${worldState.value.currentYear}${worldState.value.currentMonth}${termLabel}`;
});
const itemSlots = computed<ItemSlot[]>(() => {
const items = data.value?.general?.items;
return [
{ key: 'horse', label: '말', code: items?.horse ?? null },
{ key: 'weapon', label: '무기', code: items?.weapon ?? null },
{ key: 'book', label: '서적', code: items?.book ?? null },
{ key: 'item', label: '아이템', code: items?.item ?? null },
];
});
const canSave = computed(() => (data.value?.settings.myset ?? 1) > 0);
const penalties = computed(() => Object.entries(data.value?.penalties ?? {}));
const items = computed<Array<{ key: ItemSlotKey; name: string; code: string | null }>>(() => [
{ key: 'horse', name: '말', code: data.value?.general.items.horse ?? null },
{ key: 'weapon', name: '무기', code: data.value?.general.items.weapon ?? null },
{ key: 'book', name: '서적', code: data.value?.general.items.book ?? null },
{ key: 'item', name: '도구', code: data.value?.general.items.item ?? null },
]);
const autorunUser = computed(() => asRecord(world.value?.meta.autorun_user));
const showAutoNationTurn = computed(() => Boolean(asRecord(autorunUser.value.options).chief));
const showVacation = computed(() => !autorunUser.value.limit_minutes);
const actionAvailability = computed(() => {
const general = data.value?.general;
const meta = (worldState.value?.meta ?? {}) as Record<string, unknown>;
const config = (worldState.value?.config ?? {}) as Record<string, unknown>;
const autorunUser = (meta.autorun_user ?? {}) as Record<string, unknown>;
const turntime = meta.turntime ? new Date(String(meta.turntime)) : null;
const opentime = meta.opentime ? new Date(String(meta.opentime)) : null;
const preopen = Boolean(turntime && opentime && turntime.getTime() <= opentime.getTime());
const npcMode = resolveNumber(config.npcMode, 0);
const meta = world.value?.meta ?? {};
const config = world.value?.config ?? {};
const constConfig = asRecord(config.const);
const availableInstantAction = asRecord(constConfig.availableInstantAction ?? config.availableInstantAction);
const turnTime = meta.turntime ? new Date(String(meta.turntime)) : null;
const openTime = meta.opentime ? new Date(String(meta.opentime)) : null;
const preopen = Boolean(turnTime && openTime && turnTime.getTime() <= openTime.getTime());
const npcMode = numberValue(config.npcMode ?? config.npcmode, 0);
return {
canDieOnPrestart: Boolean(preopen && general && general.npcState === 0 && general.nationId === 0),
canBuildNationCandidate: Boolean(preopen && general && general.nationId === 0),
canVacation: !(autorunUser.limit_minutes ?? false),
canInstantRetreat: Boolean(general && general.nationId > 0),
canSelectOtherGeneral: Boolean(npcMode === 2 && general && general.npcState === 0),
dieOnPrestart: Boolean(preopen && general?.npcState === 0 && general.nationId === 0),
buildNationCandidate: Boolean(preopen && general?.nationId === 0),
instantRetreat: Boolean(availableInstantAction.instantRetreat),
selectOtherGeneral: Boolean(npcMode === 2 && general?.npcState === 0),
};
});
const loadLogs = async () => {
if (!data.value?.general?.id) {
return;
const applyCustomCss = (text: string) => {
let style = document.getElementById('sammo-custom-css') as HTMLStyleElement | null;
if (!style) {
style = document.createElement('style');
style.id = 'sammo-custom-css';
document.head.appendChild(style);
}
await Promise.all(
logTypes.map((type) => loadLog(type))
);
style.textContent = text;
};
const loadLog = async (type: LogType, beforeId?: number) => {
if (logLoading[type]) {
return;
}
if (logLoading[type]) return;
logLoading[type] = true;
try {
const response = await trpc.general.getMyLog.query({ type, beforeId });
const formatted = response.logs.map((entry) => ({
id: entry.id,
html: formatLog(entry.text),
}));
if (beforeId) {
logs[type].push(...formatted);
} else {
logs[type] = formatted;
}
logHasMore[type] = formatted.length >= 24;
} catch (err) {
error.value = resolveErrorMessage(err);
const next = response.logs.map((entry) => ({ id: entry.id, html: formatLog(entry.text) }));
logs[type] = beforeId ? [...logs[type], ...next] : next;
logHasMore[type] = next.length >= 24;
} catch (cause) {
error.value = errorText(cause);
} finally {
logLoading[type] = false;
}
};
const loadMyPage = async () => {
if (loading.value) {
return;
}
const loadPage = async () => {
if (loading.value) return;
loading.value = true;
error.value = null;
try {
const general = await trpc.general.me.query();
const world = await (trpc.world.getState.query as unknown as () => Promise<WorldStateSnapshot>)();
const [general, state] = await Promise.all([
trpc.general.me.query(),
trpc.world.getState.query() as Promise<WorldSnapshot>,
]);
data.value = general;
worldState.value = world ?? null;
await loadLogs();
} catch (err) {
error.value = resolveErrorMessage(err);
world.value = state;
if (general) {
Object.assign(form, general.settings);
}
await Promise.all(logTypes.map((type) => loadLog(type)));
} catch (cause) {
error.value = errorText(cause);
} finally {
loading.value = false;
}
};
const confirmAction = async (message: string, action: () => Promise<void>) => {
if (!confirm(message)) {
return;
}
const saveSettings = async () => {
if (!canSave.value) return;
try {
await action();
await loadMyPage();
} catch (err) {
alert(`실패했습니다: ${resolveErrorMessage(err)}`);
await trpc.general.setMySetting.mutate({ ...form });
await loadPage();
} catch (cause) {
alert(`실패했습니다: ${errorText(cause)}`);
}
};
const handleDieOnPrestart = () =>
confirmAction('정말로 삭제하시겠습니까?', async () => {
await trpc.general.dieOnPrestart.mutate();
window.location.reload();
});
const handleBuildNationCandidate = () =>
confirmAction('거병 이후 장수를 삭제할 수 없습니다. 거병하시겠습니까?', async () => {
await trpc.general.buildNationCandidate.mutate();
});
const handleInstantRetreat = () =>
confirmAction('아군 접경으로 이동할까요?', async () => {
await trpc.general.instantRetreat.mutate();
});
const handleVacation = () =>
confirmAction('휴가 기능을 신청할까요?', async () => {
await trpc.general.vacation.mutate();
});
const handleDropItem = (slot: ItemSlot) =>
confirmAction(`${slot.label}(${slot.code ?? '-'})을(를) 파기하시겠습니까?`, async () => {
await trpc.general.dropItem.mutate({ itemType: slot.key });
});
const refreshScreenMode = () => {
if (typeof window === 'undefined') {
return;
}
const mode = window.localStorage.getItem(SCREEN_MODE_KEY);
if (mode === '500px' || mode === '1000px') {
screenMode.value = mode;
} else {
screenMode.value = 'auto';
const confirmMutation = async (message: string, mutation: () => Promise<unknown>) => {
if (!confirm(message)) return;
try {
await mutation();
await loadPage();
} catch (cause) {
alert(`실패했습니다: ${errorText(cause)}`);
}
};
watch(
() => isMobile.value,
(value) => {
if (value && !logTypes.includes(activeLogTab.value)) {
activeLogTab.value = 'generalAction';
}
}
);
const dropItem = (item: { key: ItemSlotKey; name: string; code: string | null }) =>
confirmMutation(`${item.code ?? item.name}을(를) 버리시겠습니까?`, () =>
trpc.general.dropItem.mutate({ itemType: item.key })
);
watch(screenMode, (mode) => {
localStorage.setItem(SCREEN_MODE_KEY, mode);
document.dispatchEvent(new CustomEvent('tryChangeScreenMode'));
});
watch(customCss, (text) => {
if (cssTimer !== null) window.clearTimeout(cssTimer);
cssSaving.value = true;
cssTimer = window.setTimeout(() => {
localStorage.setItem(CUSTOM_CSS_KEY, text);
applyCustomCss(text);
cssSaving.value = false;
}, 500);
});
onMounted(() => {
refreshScreenMode();
void loadMyPage();
const storedMode = localStorage.getItem(SCREEN_MODE_KEY);
screenMode.value = storedMode === '500px' || storedMode === '1000px' ? storedMode : 'auto';
customCss.value = localStorage.getItem(CUSTOM_CSS_KEY) ?? '';
applyCustomCss(customCss.value);
void loadPage();
});
</script>
<template>
<main class="my-page" :class="`screen-${screenMode}`">
<header class="page-header">
<div>
<h1 class="page-title"> 정보</h1>
<p class="page-subtitle">{{ statusLine }}</p>
</div>
<div class="header-actions">
<RouterLink class="ghost" to="/">메인</RouterLink>
<RouterLink class="ghost" to="/my-settings">게임 설정</RouterLink>
<button class="ghost" @click="loadMyPage">새로고침</button>
</div>
</header>
<main id="container" class="legacy-page bg0" :class="`screen-${screenMode}`">
<div class="title-row">
<span> </span>
<RouterLink class="legacy-button" to="/">돌아가기</RouterLink>
<button class="legacy-button" type="button" @click="loadPage">새로고침</button>
</div>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="error" class="error-row">{{ error }}</div>
<div class="status-row">{{ statusLine }}</div>
<section class="layout-grid">
<div class="stack">
<PanelCard title="장수 상태">
<GeneralBasicCard :general="data?.general ?? null" :loading="loading" />
</PanelCard>
<PanelCard title="도시 상태">
<CityBasicCard :city="data?.city ?? null" :loading="loading" />
</PanelCard>
<PanelCard title="세력 상태">
<NationBasicCard :nation="data?.nation ?? null" :loading="loading" />
</PanelCard>
</div>
<div class="stack">
<PanelCard title="장수 상태 변경" subtitle="중요 액션은 확인 후 실행됩니다.">
<div class="action-grid">
<button
v-if="actionAvailability.canVacation"
class="action-btn"
type="button"
@click="handleVacation"
>
휴가 신청
</button>
<button
v-if="actionAvailability.canDieOnPrestart"
class="action-btn"
type="button"
@click="handleDieOnPrestart"
>
장수 삭제
</button>
<button
v-if="actionAvailability.canBuildNationCandidate"
class="action-btn"
type="button"
@click="handleBuildNationCandidate"
>
사전 거병
</button>
<button
v-if="actionAvailability.canInstantRetreat"
class="action-btn"
type="button"
@click="handleInstantRetreat"
>
접경 귀환
</button>
<RouterLink
v-if="actionAvailability.canSelectOtherGeneral"
class="action-btn link"
to="/join"
>
다른 장수 선택
</RouterLink>
<section class="top-grid">
<div class="general-column">
<div class="section-title sky">장수 정보</div>
<div v-if="loading || !data" class="loading">불러오는 중...</div>
<div v-else class="general-table">
<div class="portrait-cell">
<img
:src="
data.general.picture ? `/image/game/${data.general.picture}` : '/image/game/default.jpg'
"
alt=""
/>
<strong>{{ data.general.name }}</strong>
</div>
</PanelCard>
<PanelCard title="아이템 파기" subtitle="소지 중인 장비를 선택합니다.">
<div class="item-grid">
<button
v-for="slot in itemSlots"
:key="slot.key"
class="item-btn"
type="button"
:disabled="!slot.code"
@click="handleDropItem(slot)"
>
{{ slot.label }}: {{ slot.code ?? '-' }}
</button>
</div>
</PanelCard>
<PanelCard v-if="isMobile" title="장수 기록">
<div class="log-tabs">
<button
v-for="type in logTypes"
:key="type"
:class="{ active: activeLogTab === type }"
@click="activeLogTab = type"
>
{{ logLabels[type] }}
</button>
</div>
<div class="log-block">
<div class="log-title">{{ logLabels[activeLogTab] }}</div>
<SkeletonLines v-if="loading || logLoading[activeLogTab]" :lines="4" />
<!-- eslint-disable vue/no-v-html -->
<template v-else>
<div v-if="logs[activeLogTab].length === 0" class="empty">기록이 없습니다.</div>
<div
v-for="entry in logs[activeLogTab]"
:key="entry.id"
class="log-line"
v-html="entry.html"
/>
<button
v-if="logHasMore[activeLogTab]"
class="ghost log-more"
@click="loadLog(activeLogTab, logs[activeLogTab].at(-1)?.id)"
>
이전 로그 불러오기
</button>
</template>
<!-- eslint-enable vue/no-v-html -->
</div>
</PanelCard>
<PanelCard v-else title="장수 기록" subtitle="개인 기록 및 전투 로그">
<div class="log-grid">
<div v-for="type in logTypes" :key="type" class="log-block">
<div class="log-title">{{ logLabels[type] }}</div>
<SkeletonLines v-if="loading || logLoading[type]" :lines="3" />
<!-- eslint-disable vue/no-v-html -->
<template v-else>
<div v-if="logs[type].length === 0" class="empty">기록이 없습니다.</div>
<div v-for="entry in logs[type]" :key="entry.id" class="log-line" v-html="entry.html" />
<button
v-if="logHasMore[type]"
class="ghost log-more"
@click="loadLog(type, logs[type].at(-1)?.id)"
>
이전 로그 불러오기
</button>
</template>
<!-- eslint-enable vue/no-v-html -->
<dl>
<div>
<dt>통솔</dt>
<dd>{{ data.general.stats.leadership }}</dd>
</div>
</div>
</PanelCard>
<div>
<dt>무력</dt>
<dd>{{ data.general.stats.strength }}</dd>
</div>
<div>
<dt>지력</dt>
<dd>{{ data.general.stats.intelligence }}</dd>
</div>
<div>
<dt>소속</dt>
<dd>{{ data.nation?.name ?? '재야' }}</dd>
</div>
<div>
<dt>도시</dt>
<dd>{{ data.city?.name ?? '-' }}</dd>
</div>
<div>
<dt>/</dt>
<dd>{{ data.general.gold }} / {{ data.general.rice }}</dd>
</div>
<div>
<dt>병력</dt>
<dd>{{ data.general.crew }}</dd>
</div>
<div>
<dt>훈련/사기</dt>
<dd>{{ data.general.train }} / {{ data.general.atmos }}</dd>
</div>
<div>
<dt>경험/공헌</dt>
<dd>{{ data.general.experience }} / {{ data.general.dedication }}</dd>
</div>
</dl>
</div>
</div>
<div class="settings-column">
<div class="setting-line">
토너먼트
<label><input v-model.number="form.tnmt" type="radio" :value="0" />수동참여</label>
<label><input v-model.number="form.tnmt" type="radio" :value="1" />자동참여</label>
</div>
<div class="hint"> 개막직전 남는자리가 있을경우 랜덤하게 참여합니다.</div>
<label class="setting-line">
환약 사용
<select v-model.number="form.use_treatment">
<option :value="10">경상</option>
<option :value="21">중상</option>
<option :value="41">심각</option>
<option :value="61">위독</option>
<option :value="100">사용안함</option>
</select>
</label>
<div class="hint"> 부상을 입었을 환약을 사용하는 기준입니다.</div>
<label v-if="showAutoNationTurn" class="setting-line">
자동 사령턴 허용
<select v-model.number="form.use_auto_nation_turn">
<option :value="1">허용</option>
<option :value="0">허용 안함</option>
</select>
</label>
<label class="setting-line">
수비
<select v-model.number="form.defence_train">
<option :value="90">수비 (훈사90)</option>
<option :value="80">수비 (훈사80)</option>
<option :value="60">수비 (훈사60)</option>
<option :value="40">수비 (훈사40)</option>
<option :value="999">수비 안함 [훈련 -3, 사기 -6]</option>
</select>
</label>
<button
id="set_my_setting"
class="action-button"
type="button"
:hidden="!canSave"
@click="saveSettings"
>
설정저장
</button>
<div class="hint"> 설정저장은 이달중 {{ data?.settings.myset ?? 0 }} 남았습니다.</div>
<div v-if="penalties.length" class="penalties">
징계 목록(저장 갱신)
<div v-for="[key, value] in penalties" :key="key">{{ key }} : {{ value }}</div>
</div>
<div v-if="showVacation" class="action-line">
<br />
<button
class="action-button"
type="button"
@click="confirmMutation('휴가 기능을 신청할까요?', () => trpc.general.vacation.mutate())"
>
휴가 신청
</button>
</div>
<div v-if="actionAvailability.dieOnPrestart" class="action-line">
가오픈 기간 장수 삭제<br />
<button
class="action-button"
@click="confirmMutation('정말로 삭제하시겠습니까?', () => trpc.general.dieOnPrestart.mutate())"
>
장수 삭제
</button>
</div>
<div v-if="actionAvailability.buildNationCandidate" class="action-line">
서버 개시 이전 거병(2턴부터 건국 가능)<br />
<button
class="action-button"
@click="
confirmMutation('거병 이후 장수를 삭제할 수 없게됩니다. 거병하시겠습니까?', () =>
trpc.general.buildNationCandidate.mutate()
)
"
>
사전 거병
</button>
</div>
<div v-if="actionAvailability.instantRetreat" class="action-line">
거리 3칸 이내 아국 도시로 즉시 이동<br />
<button
class="action-button"
@click="
confirmMutation('아군 접경으로 이동할까요?', () => trpc.general.instantRetreat.mutate())
"
>
접경 귀환
</button>
</div>
<div class="screen-mode-row">
<span>500px/1000px 모드<br />(모바일 전용, 즉시 설정)</span>
<div class="button-group">
<label><input v-model="screenMode" type="radio" value="auto" />자동</label>
<label><input v-model="screenMode" type="radio" value="500px" />500px</label>
<label><input v-model="screenMode" type="radio" value="1000px" />1000px</label>
</div>
</div>
<div class="item-title">아이템 파기</div>
<div class="item-group">
<button
v-for="item in items"
:key="item.key"
type="button"
:disabled="!item.code"
@click="dropItem(item)"
>
{{ item.code ?? '-' }}
</button>
</div>
<label class="custom-css">
개인용 CSS <span>{{ cssSaving ? '(저장 중)' : '' }}</span>
<textarea id="custom_css" v-model="customCss" />
</label>
</div>
</section>
<section class="log-grid">
<article v-for="type in logTypes" :key="type" class="log-panel">
<h2 :style="{ color: logColors[type] }">{{ logLabels[type] }}</h2>
<div v-if="logLoading[type]" class="loading">불러오는 중...</div>
<div v-else>
<div v-for="entry in logs[type]" :key="entry.id" class="log-line" v-html="entry.html" />
<div v-if="logs[type].length === 0" class="empty">기록이 없습니다.</div>
<button
v-if="logHasMore[type]"
class="load-old"
type="button"
@click="loadLog(type, logs[type].at(-1)?.id)"
>
이전 로그 불러오기
</button>
</div>
</article>
</section>
</main>
</template>
<style scoped>
.my-page {
.legacy-page {
width: 100%;
max-width: 1000px;
min-width: 500px;
min-height: 100vh;
padding: 24px;
display: flex;
flex-direction: column;
gap: 24px;
transition: width 0.2s ease;
margin: 0 auto;
padding: 0;
color: #fff;
background-color: #111;
background-image: url('/image/game/back_walnut.jpg');
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-size: 14px;
line-height: 1.3;
}
.my-page.screen-500px {
.legacy-page.screen-500px {
max-width: 500px;
}
.my-page.screen-1000px {
.legacy-page.screen-1000px {
max-width: 1000px;
}
.page-header {
.title-row {
height: 54px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
align-content: flex-start;
align-items: flex-start;
justify-content: flex-start;
flex-wrap: wrap;
gap: 0 4px;
border: 1px solid #666;
background: transparent;
font-size: 14px;
}
.page-title {
font-size: 1.6rem;
.title-row > span {
flex-basis: 100%;
height: 18px;
letter-spacing: 0;
}
.legacy-button,
button,
select,
textarea {
border: 1px solid #777;
border-radius: 0;
color: #fff;
background: #6b6b6b;
font: inherit;
}
.legacy-button {
min-height: 34px;
padding: 5px 10px;
border-color: #2d5d7f;
border-radius: 4px;
background: #315f86;
color: #fff;
font-weight: 700;
text-decoration: none;
letter-spacing: 0;
}
.page-subtitle {
color: rgba(232, 221, 196, 0.7);
margin-top: 6px;
}
.header-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.layout-grid {
display: grid;
grid-template-columns: minmax(0, 320px) minmax(0, 1fr);
gap: 18px;
}
.stack {
display: flex;
flex-direction: column;
gap: 18px;
}
.action-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
}
.action-btn {
border: 1px solid rgba(201, 164, 90, 0.5);
background: rgba(12, 12, 12, 0.6);
color: inherit;
padding: 8px 12px;
font-size: 0.85rem;
button {
cursor: pointer;
}
button:disabled {
cursor: default;
opacity: 0.45;
}
.status-row,
.error-row {
padding: 4px 8px;
text-align: center;
}
.action-btn.link {
display: inline-flex;
align-items: center;
justify-content: center;
.error-row {
color: #ff7777;
border: 1px solid #a33;
}
.item-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 8px;
}
.item-btn {
border: 1px solid rgba(201, 164, 90, 0.4);
background: rgba(12, 12, 12, 0.6);
color: inherit;
padding: 8px 10px;
font-size: 0.82rem;
cursor: pointer;
text-align: left;
}
.item-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.top-grid,
.log-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.log-block {
border: 1px solid rgba(201, 164, 90, 0.3);
padding: 8px;
background: rgba(12, 12, 12, 0.6);
min-height: 160px;
.general-column,
.settings-column,
.log-panel {
border: 1px solid #666;
background-image: url('/image/game/back_walnut.jpg');
}
.log-title {
font-weight: 600;
margin-bottom: 6px;
font-size: 0.9rem;
.section-title,
.log-panel h2 {
min-height: 34px;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid #666;
background-color: #14241b;
background-image: url('/image/game/back_green.jpg');
font-size: 1.25em;
font-weight: 500;
}
.log-line {
padding: 4px 0;
border-bottom: 1px dashed rgba(201, 164, 90, 0.2);
.sky {
color: skyblue;
}
.log-line:last-child {
border-bottom: none;
.general-table {
display: grid;
grid-template-columns: 150px 1fr;
padding: 0;
background-color: #172a52;
background-image: url('/image/game/back_blue.jpg');
}
.log-more {
.portrait-cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 10px;
border-right: 1px solid #777;
}
.portrait-cell img {
width: 64px;
height: 64px;
object-fit: cover;
}
dl {
margin: 0;
}
dl > div {
display: grid;
grid-template-columns: 80px 1fr;
border-bottom: 1px solid #777;
}
dt,
dd {
margin: 0;
padding: 2px 5px;
border-right: 1px solid #777;
}
dt {
color: #aaa;
}
.settings-column {
padding: 10px 18px;
}
.setting-line {
display: block;
margin-top: 5px;
}
.hint {
margin: 0 0 13px;
color: orange;
}
.action-button {
width: 160px;
height: 30px;
margin: 4px 0;
background: #225500;
}
.action-line {
margin: 12px 0;
}
.penalties {
margin: 12px 0;
color: #f66;
}
.screen-mode-row {
display: grid;
grid-template-columns: 160px 1fr;
align-items: center;
margin: 14px 0;
}
.button-group {
display: flex;
}
.button-group label {
padding: 5px 8px;
border: 1px solid #666;
background: #26384d;
}
.button-group input {
margin-right: 4px;
}
.item-title {
margin-top: 12px;
}
.item-group {
display: grid;
grid-template-columns: repeat(4, 1fr);
margin: 5px 0 14px;
}
.item-group button {
min-height: 30px;
}
.custom-css {
display: block;
}
.custom-css textarea {
display: block;
width: 420px;
max-width: 100%;
height: 150px;
color: #fff;
background: #000;
}
.log-panel {
min-height: 180px;
}
.log-panel h2 {
color: orange;
}
.log-line,
.empty,
.loading {
padding: 2px 8px;
}
.load-old {
width: 100%;
min-height: 32px;
margin-top: 8px;
}
.log-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.log-tabs button {
border: 1px solid rgba(201, 164, 90, 0.4);
background: transparent;
color: inherit;
padding: 6px 10px;
font-size: 0.8rem;
cursor: pointer;
}
.log-tabs button.active {
background: rgba(201, 164, 90, 0.2);
}
.ghost {
border: 1px solid rgba(201, 164, 90, 0.5);
background: transparent;
color: inherit;
padding: 6px 10px;
font-size: 0.8rem;
cursor: pointer;
}
.empty {
color: rgba(232, 221, 196, 0.6);
font-size: 0.85rem;
}
.error {
color: #f08a5d;
font-size: 0.9rem;
}
@media (max-width: 1024px) {
.layout-grid {
grid-template-columns: 1fr;
@media (max-width: 991px) {
.legacy-page {
width: 500px;
}
.top-grid,
.log-grid {
grid-template-columns: 1fr;
}
+131 -60
View File
@@ -73,9 +73,7 @@ const listYearMonth = computed(() => {
});
const listItems = computed(() =>
list.value
? Object.values(list.value.bettingList).sort((left, right) => right.id - left.id)
: []
list.value ? Object.values(list.value.bettingList).sort((left, right) => right.id - left.id) : []
);
const info = computed(() => detail.value?.bettingInfo ?? null);
@@ -112,9 +110,7 @@ const detailRows = computed(() =>
const myBetMap = computed(() => new Map(detail.value?.myBetting ?? []));
const totalAmount = computed(() =>
(detail.value?.bettingDetail ?? []).reduce((sum, [, value]) => sum + value, 0)
);
const totalAmount = computed(() => (detail.value?.bettingDetail ?? []).reduce((sum, [, value]) => sum + value, 0));
const pureAmount = computed(() =>
(detail.value?.bettingDetail ?? []).reduce(
@@ -137,9 +133,7 @@ const candidateAmounts = computed(() => {
return result;
});
const usedAmount = computed(() =>
Array.from(myBetMap.value.values()).reduce((sum, value) => sum + value, 0)
);
const usedAmount = computed(() => Array.from(myBetMap.value.values()).reduce((sum, value) => sum + value, 0));
const selectedKey = computed(() => JSON.stringify([...selectedCandidates.value].sort((a, b) => a - b)));
@@ -150,10 +144,7 @@ const getErrorMessage = (error: unknown): string => {
return typeof error === 'string' ? error : '요청을 처리하지 못했습니다.';
};
const parseYearMonth = (yearMonth: number): [number, number] => [
Math.floor(yearMonth / 12),
(yearMonth % 12) + 1,
];
const parseYearMonth = (yearMonth: number): [number, number] => [Math.floor(yearMonth / 12), (yearMonth % 12) + 1];
const readSelection = (value: string): number[] => {
try {
@@ -171,8 +162,7 @@ const selectionLabel = (value: string): string =>
.map((index) => candidates.value[index]?.title ?? '-')
.join(', ');
const isListOpen = (item: BettingListItem): boolean =>
!item.finished && listYearMonth.value <= item.closeYearMonth;
const isListOpen = (item: BettingListItem): boolean => !item.finished && listYearMonth.value <= item.closeYearMonth;
const isDetailOpen = computed(() =>
Boolean(info.value && !info.value.finished && currentYearMonth.value <= info.value.closeYearMonth)
@@ -192,19 +182,72 @@ const rowColor = (key: string): string => {
return matched === 0 ? 'red' : matched < info.value.selectCnt ? 'yellow' : 'green';
};
const expectedMultiplier = (key: string, betAmount: number): string => {
if (betAmount <= 0) {
return '0.0';
const rewardByMatch = computed(() => {
const selectCount = info.value?.selectCnt ?? 0;
const rewards = new Array<number>(selectCount + 1).fill(0);
if (selectCount <= 0) {
return rewards;
}
const amountByMatch = new Map<number, number>();
for (const [key, betAmount] of detailRows.value) {
const matched = matchCount(key);
amountByMatch.set(matched, (amountByMatch.get(matched) ?? 0) + betAmount);
}
if (selectCount === 1 || info.value?.isExclusive) {
rewards[selectCount] = totalAmount.value;
return rewards;
}
let remainingReward = totalAmount.value;
let accumulatedReward = 0;
let nextReward = totalAmount.value;
for (let matched = selectCount; matched > 0; matched -= 1) {
nextReward /= 2;
accumulatedReward += nextReward;
if (!amountByMatch.has(matched)) {
continue;
}
rewards[matched] = accumulatedReward;
remainingReward -= accumulatedReward;
accumulatedReward = 0;
}
for (let matched = selectCount; matched >= 0; matched -= 1) {
if (!amountByMatch.has(matched)) {
continue;
}
rewards[matched] += remainingReward;
break;
}
return rewards;
});
const expectedReward = (key: string): number => {
if (!info.value?.finished) {
const reward = info.value?.isExclusive || info.value?.selectCnt === 1 ? totalAmount.value : totalAmount.value / 2;
return (reward / betAmount).toFixed(1);
return info.value?.isExclusive || info.value?.selectCnt === 1 ? totalAmount.value : totalAmount.value / 2;
}
const matched = matchCount(key);
const matchedAmount = detailRows.value
.filter(([candidateKey]) => matchCount(candidateKey) === matched)
.reduce((sum, [, value]) => sum + value, 0);
return matchedAmount > 0 ? (totalAmount.value / matchedAmount).toFixed(1) : '0.0';
return rewardByMatch.value[matchCount(key)] ?? 0;
};
const rewardDivisor = (key: string, betAmount: number): number =>
info.value?.finished
? detailRows.value
.filter(([candidateKey]) => matchCount(candidateKey) === matchCount(key))
.reduce((sum, [, value]) => sum + value, 0)
: betAmount;
const expectedMultiplier = (key: string, betAmount: number): string => {
const divisor = rewardDivisor(key, betAmount);
return divisor > 0 ? (expectedReward(key) / divisor).toFixed(1) : '0.0';
};
const myExpectedReward = (key: string, betAmount: number): string => {
const myAmount = myBetMap.value.get(key);
if (myAmount === undefined) {
return '';
}
const divisor = rewardDivisor(key, betAmount);
const reward = divisor > 0 ? (myAmount * expectedReward(key)) / divisor : 0;
return `(${myAmount.toLocaleString('ko-KR')} -> ${reward.toFixed(1)})`;
};
const loadList = async () => {
@@ -295,7 +338,7 @@ onMounted(() => {
<main id="nation-betting-container" class="nation-betting-page legacy-bg0">
<header class="legacy-top-bar">
<RouterLink class="legacy-nav-button" to="/">돌아가기</RouterLink>
<button class="legacy-nav-button" type="button" :disabled="loadingList" @click="loadList">갱신</button>
<div></div>
<h1>국가 베팅장</h1>
<div></div>
<div></div>
@@ -309,32 +352,37 @@ onMounted(() => {
{{ info.name }}
<span v-if="info.finished">(종료)</span>
<span v-else-if="currentYearMonth <= info.closeYearMonth">
({{ parseYearMonth(info.closeYearMonth)[0] }}
{{ parseYearMonth(info.closeYearMonth)[1] }}월까지)
({{ parseYearMonth(info.closeYearMonth)[0] }} {{ parseYearMonth(info.closeYearMonth)[1] }}월까지)
</span>
<span v-else>(베팅 마감)</span>
(총액: {{ totalAmount.toLocaleString('ko-KR') }})
</div>
<div class="betting-candidates">
<button
<div
v-for="(candidate, index) in candidates"
:key="`${info.id}-${index}`"
type="button"
class="betting-candidate"
:class="{ picked: selectedCandidates.includes(index) || (info.finished && winner.has(index)) }"
:disabled="!isDetailOpen"
@click="toggleCandidate(index)"
class="betting-candidate-cell"
>
<span class="candidate-title legacy-bg1">{{ candidate.title }}</span>
<span class="candidate-info">
<span v-for="line in candidate.info.split('<br>')" :key="line">{{ line }}</span>
</span>
<span class="candidate-rate">
선택율:
{{ (((candidateAmounts.get(index) ?? 0) / Math.max(1, pureAmount)) * 100).toFixed(1) }}%
</span>
</button>
<button
type="button"
class="betting-candidate"
:class="{
picked: selectedCandidates.includes(index) || (info.finished && winner.has(index)),
}"
:disabled="!isDetailOpen"
@click="toggleCandidate(index)"
>
<span class="candidate-title legacy-bg1">{{ candidate.title }}</span>
<span class="candidate-info">
<span v-for="line in candidate.info.split('<br>')" :key="line">{{ line }}</span>
</span>
<span class="candidate-rate">
선택율:
{{ (((candidateAmounts.get(index) ?? 0) / Math.max(1, pureAmount)) * 100).toFixed(1) }}%
</span>
</button>
</div>
</div>
<form v-if="isDetailOpen" class="betting-form" @submit.prevent="submitBet">
@@ -361,7 +409,7 @@ onMounted(() => {
{{ selectionLabel(key) }}
</div>
<div>{{ betAmount.toLocaleString('ko-KR') }}</div>
<div>{{ myBetMap.get(key)?.toLocaleString('ko-KR') ?? '' }}</div>
<div>{{ myExpectedReward(key, betAmount) }}</div>
<div>{{ expectedMultiplier(key, betAmount) }}</div>
</div>
</div>
@@ -382,8 +430,7 @@ onMounted(() => {
{{ item.name }}
<span v-if="item.finished">(종료)</span>
<span v-else-if="isListOpen(item)">
({{ parseYearMonth(item.closeYearMonth)[0] }}
{{ parseYearMonth(item.closeYearMonth)[1] }}월까지)
({{ parseYearMonth(item.closeYearMonth)[0] }} {{ parseYearMonth(item.closeYearMonth)[1] }}월까지)
</span>
<span v-else>(베팅 마감)</span>
</button>
@@ -400,12 +447,11 @@ onMounted(() => {
.nation-betting-page {
position: relative;
width: 500px;
min-height: 100vh;
margin: 0 auto;
color: #fff;
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic';
font-size: 14px;
line-height: 1.3;
line-height: 1.5;
overflow-x: hidden;
}
@@ -458,19 +504,32 @@ onMounted(() => {
}
.section-title {
min-height: 22px;
min-height: 21px;
text-align: center;
line-height: 22px;
line-height: 21px;
}
.betting-candidates {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 4px;
padding: 4px;
display: flex;
flex-wrap: wrap;
margin-top: -3.5px;
margin-right: -1.75px;
margin-left: -1.75px;
}
.betting-candidate-cell {
flex: 0 0 auto;
width: 33.33333333%;
max-width: 100%;
padding-right: 1.75px;
padding-left: 1.75px;
margin-top: 3.5px;
}
.betting-candidate {
width: 100%;
height: 100%;
min-height: 143px;
min-width: 0;
padding: 0;
border: 1px solid gray;
@@ -530,7 +589,7 @@ onMounted(() => {
.betting-form input {
grid-column: span 4;
min-width: 0;
height: 30px;
height: 35.5px;
border: 1px solid #777;
background: #ddd;
color: #303030;
@@ -538,10 +597,11 @@ onMounted(() => {
.betting-form button {
grid-column: span 2;
height: 35.5px;
}
.payout-table {
margin-top: 6px;
margin-top: 0;
}
.payout-row {
@@ -551,7 +611,7 @@ onMounted(() => {
.payout-row > div {
min-width: 0;
padding: 2px 4px;
padding: 0 4px;
}
.payout-row > div:not(:first-child) {
@@ -572,7 +632,7 @@ onMounted(() => {
.betting-item {
display: block;
width: 100%;
width: auto;
margin: 0.25em;
border: 0;
background: transparent;
@@ -595,6 +655,7 @@ onMounted(() => {
.betting-footer .legacy-nav-button {
width: 90px;
height: 35.5px;
}
.betting-notice,
@@ -602,6 +663,15 @@ onMounted(() => {
padding: 6px 10px;
}
.betting-notice {
position: fixed;
top: 8px;
right: 8px;
z-index: 20;
width: min(320px, calc(100vw - 16px));
background: #303030;
}
.betting-notice.error {
border: 1px solid #9b4848;
color: #ffd0d0;
@@ -617,8 +687,9 @@ onMounted(() => {
width: 1000px;
}
.betting-candidates {
grid-template-columns: repeat(6, minmax(0, 1fr));
.betting-candidate-cell {
/* Legacy Bootstrap switches .col-4 to .col-lg-2 at 940px. */
width: 16.66666667%;
}
.betting-form {
@@ -108,6 +108,7 @@ onMounted(() => {
<RouterLink v-else-if="session.needsGeneral" class="ghost" to="/join">장수 생성/빙의</RouterLink>
<RouterLink v-else class="ghost" to="/">메인으로</RouterLink>
<RouterLink class="ghost" to="/npc-list">빙의일람</RouterLink>
<RouterLink class="ghost" to="/traffic">접속량정보</RouterLink>
<button class="ghost" @click="refreshPublicData">새로고침</button>
</div>
</header>
+343
View File
@@ -0,0 +1,343 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { trpc } from '../utils/trpc';
type TrafficData = Awaited<ReturnType<typeof trpc.public.getTraffic.query>>;
const data = ref<TrafficData | null>(null);
const loading = ref(false);
const errorMessage = ref('');
const getErrorMessage = (error: unknown): string => {
if (error instanceof Error) {
return error.message;
}
return typeof error === 'string' ? error : '요청을 처리하지 못했습니다.';
};
const load = async () => {
if (loading.value) {
return;
}
loading.value = true;
errorMessage.value = '';
try {
data.value = await trpc.public.getTraffic.query();
} catch (error) {
// Preserve the last successful graph if a later refresh fails.
errorMessage.value = getErrorMessage(error);
} finally {
loading.value = false;
}
};
const refreshRows = computed(() =>
(data.value?.history ?? []).map((entry) => ({
...entry,
value: entry.refresh,
width: Math.round((entry.refresh / Math.max(1, data.value?.maxRefresh ?? 1)) * 1_000) / 10,
}))
);
const onlineRows = computed(() =>
(data.value?.history ?? []).map((entry) => ({
...entry,
value: entry.online,
width: Math.round((entry.online / Math.max(1, data.value?.maxOnline ?? 1)) * 1_000) / 10,
}))
);
const timeLabel = (value: string): string => {
const timePart = value.includes('T') ? value.split('T')[1] : value.slice(11);
return (timePart ?? '').slice(0, 5);
};
const trafficColor = (percentage: number): string => {
const channel = (value: number): string =>
Math.floor((Math.max(0, Math.min(100, value)) * 255) / 100)
.toString(16)
.padStart(2, '0');
return `#${channel(percentage)}00${channel(100 - percentage)}`;
};
onMounted(() => {
void load();
});
</script>
<template>
<main id="traffic-container" class="traffic-page">
<table class="legacy-table title-table legacy-bg0">
<tbody>
<tr>
<td>
<br />
<RouterLink class="legacy-close" to="/">돌아가기</RouterLink>
</td>
</tr>
</tbody>
</table>
<div v-if="errorMessage" class="traffic-error" role="alert">{{ errorMessage }}</div>
<div v-if="loading && !data" class="traffic-loading">불러오는 중...</div>
<section v-if="data" class="chart-layout">
<table class="legacy-table chart-table legacy-bg0">
<thead>
<tr>
<th colspan="4" class="legacy-bg2 chart-title"> </th>
</tr>
</thead>
<tbody>
<tr v-for="(entry, index) in refreshRows" :key="`${entry.date}-${index}`" class="chart-row">
<td class="period">{{ entry.year }} {{ entry.month }}</td>
<td class="time legacy-bg2">{{ timeLabel(entry.date) }}</td>
<td class="separator legacy-bg1"></td>
<td class="bar-cell">
<div
v-if="entry.width > 0"
class="big-bar"
:style="{ width: `${entry.width}%`, backgroundColor: trafficColor(entry.width) }"
>
<span v-if="entry.width >= 10">{{ entry.value }}</span>
</div>
<span v-if="entry.width < 10" class="out-bar">{{ entry.value }}</span>
</td>
</tr>
<tr><td colspan="4" class="legacy-bg1 spacer"></td></tr>
<tr><td colspan="4" class="record">최고기록: {{ data.maxRefresh }}</td></tr>
</tbody>
</table>
<table class="legacy-table chart-table legacy-bg0">
<thead>
<tr>
<th colspan="4" class="legacy-bg2 chart-title"> </th>
</tr>
</thead>
<tbody>
<tr v-for="(entry, index) in onlineRows" :key="`${entry.date}-${index}`" class="chart-row">
<td class="period">{{ entry.year }} {{ entry.month }}</td>
<td class="time legacy-bg2">{{ timeLabel(entry.date) }}</td>
<td class="separator legacy-bg1"></td>
<td class="bar-cell">
<div
v-if="entry.width > 0"
class="big-bar"
:style="{ width: `${entry.width}%`, backgroundColor: trafficColor(entry.width) }"
>
<span v-if="entry.width >= 10">{{ entry.value }}</span>
</div>
<span v-if="entry.width < 10" class="out-bar">{{ entry.value }}</span>
</td>
</tr>
<tr><td colspan="4" class="legacy-bg1 spacer"></td></tr>
<tr><td colspan="4" class="record">최고기록: {{ data.maxOnline }}</td></tr>
</tbody>
</table>
</section>
<table v-if="data" class="legacy-table suspect-table legacy-bg0">
<thead>
<tr>
<th colspan="3" class="legacy-bg2 chart-title"> (순간과도갱신)</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in data.suspects" :key="entry.generalId ?? 'total'">
<td class="suspect-name">{{ entry.name }}</td>
<td class="suspect-score">{{ entry.refreshScoreTotal }}({{ entry.refresh }})</td>
<td class="little-bar-cell">
<div
v-if="entry.refresh > 0"
class="little-bar"
:style="{
width: `${Math.round((entry.refresh / Math.max(1, data.suspects[0]?.refresh ?? 1)) * 1_000) / 10}%`,
backgroundColor: trafficColor(
Math.round((entry.refresh / Math.max(1, data.suspects[0]?.refresh ?? 1)) * 1_000) / 10
),
}"
></div>
</td>
</tr>
</tbody>
</table>
<table class="legacy-table footer-table legacy-bg0">
<tbody>
<tr><td><RouterLink class="legacy-close" to="/">돌아가기</RouterLink></td></tr>
<tr><td class="banner">SAMMO</td></tr>
</tbody>
</table>
</main>
</template>
<style scoped>
.traffic-page {
width: 1016px;
min-width: 1016px;
min-height: 100vh;
margin: 0 auto;
padding: 0;
color: #fff;
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-size: 14px;
line-height: normal;
}
.legacy-table {
border-collapse: collapse;
padding: 0;
color: #fff;
font-size: 14px;
}
.legacy-table td,
.legacy-table th {
border: 1px solid gray;
padding: 0;
text-align: center;
font-weight: 400;
}
.legacy-bg0 {
background-color: #302016;
background-image: url('/image/game/back_walnut.jpg');
}
.legacy-bg1 {
background-color: #423226;
background-image: url('/image/game/back_sandal.jpg');
}
.legacy-bg2 {
background-color: #14241b;
background-image: url('/image/game/back_green.jpg');
}
.title-table,
.footer-table {
width: 1000px;
margin: 0 auto;
}
.title-table {
margin-bottom: 18px;
}
.title-table td {
height: 54px;
}
.chart-layout {
width: 1016px;
display: flex;
gap: 26px;
align-items: flex-start;
box-sizing: border-box;
padding: 0 12px;
}
.chart-table {
width: 483px;
table-layout: fixed;
}
.chart-title {
height: 34px;
font-size: 24px;
}
.chart-row {
height: 30px;
}
.period {
width: 100px;
}
.time {
width: 60px;
}
.separator {
width: 2px;
}
.bar-cell {
width: 320px;
text-align: left !important;
white-space: nowrap;
}
.big-bar {
float: left;
position: relative;
height: 30px;
}
.big-bar span {
float: right;
padding-right: 1ch;
line-height: 30px;
}
.out-bar {
line-height: 30px;
margin-left: 1ch;
}
.spacer {
height: 5px;
}
.record {
height: 30px;
}
.suspect-table {
margin: 18px auto;
}
.suspect-name,
.suspect-score {
width: 98px;
}
.little-bar-cell {
width: 798px;
text-align: left !important;
}
.little-bar {
height: 17px;
}
.footer-table {
margin-top: 18px;
}
.banner {
height: 24px;
}
.legacy-close {
color: #fff;
text-decoration: underline;
}
.traffic-error,
.traffic-loading {
width: 1000px;
margin: -12px auto 12px;
border: 1px solid gray;
padding: 6px;
text-align: center;
background: #302016;
}
.traffic-error {
color: #ff8080;
}
</style>