파일 이식(0.31.1)
This commit is contained in:
@@ -0,0 +1,393 @@
|
||||
<template>
|
||||
<div class="bg0">
|
||||
<div class="bg2">거래장</div>
|
||||
<div style="background-color: orange">쌀 구매</div>
|
||||
<div class="auctionItem gx-0">
|
||||
<div class="idx">번호</div>
|
||||
<div class="host">판매자</div>
|
||||
<div class="amount">수량</div>
|
||||
<div class="highestBidder">입찰자</div>
|
||||
<div class="highestBid">입찰가</div>
|
||||
<div class="bidRatio">단가</div>
|
||||
<div class="finishBid">마감가</div>
|
||||
<div class="closeDate">거래 종료</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="auction of buyRice"
|
||||
:key="auction.id"
|
||||
class="auctionItem gx-0"
|
||||
@click="selectedBuyRiceAuction = auction"
|
||||
>
|
||||
<div class="idx f_tnum">{{ auction.id }}</div>
|
||||
<div class="host">{{ auction.hostName }}</div>
|
||||
<div class="amount f_tnum">쌀 {{ auction.amount.toLocaleString() }}</div>
|
||||
<div class="highestBidder">{{ auction.highestBid?.generalName ?? "-" }}</div>
|
||||
<div :class="['highestBid f_tnum', auction.highestBid ? '' : 'noBid']">
|
||||
금 {{ (auction.highestBid?.amount ?? auction.startBidAmount).toLocaleString() }}
|
||||
</div>
|
||||
<div class="bidRatio f_tnum">
|
||||
{{ auction.highestBid ? (auction.highestBid.amount / auction.amount).toFixed(2) : "-" }}
|
||||
</div>
|
||||
<div class="finishBid f_tnum">금 {{ auction.finishBidAmount.toLocaleString() }}</div>
|
||||
<div class="closeDate f_tnum">{{ cutDateTime(auction.closeDate) }}</div>
|
||||
</div>
|
||||
<div v-if="selectedBuyRiceAuction !== undefined" class="row gx-1">
|
||||
<div class="offset-1 col-4 offset-md-3 col-md-2 align-self-center f_tnum text-end">
|
||||
{{ selectedBuyRiceAuction.id }}번 쌀 {{ selectedBuyRiceAuction.amount }} 경매에 금
|
||||
</div>
|
||||
<div class="col-3 col-md-2">
|
||||
<NumberInputWithInfo
|
||||
v-model="bidAmountBuyRiceAuction"
|
||||
:int="true"
|
||||
:min="selectedBuyRiceAuction.startBidAmount"
|
||||
:max="selectedBuyRiceAuction.finishBidAmount"
|
||||
:step="10"
|
||||
></NumberInputWithInfo>
|
||||
</div>
|
||||
<div class="col-2 col-md-1 d-grid"><BButton @click="bidBuyRiceAuction">입찰</BButton></div>
|
||||
</div>
|
||||
|
||||
<div style="background-color: skyblue">쌀 판매</div>
|
||||
<div class="auctionItem gx-0">
|
||||
<div class="idx">번호</div>
|
||||
<div class="host">판매자</div>
|
||||
<div class="amount">수량</div>
|
||||
<div class="highestBidder">입찰자</div>
|
||||
<div class="highestBid">입찰가</div>
|
||||
<div class="bidRatio">단가</div>
|
||||
<div class="finishBid">마감가</div>
|
||||
<div class="closeDate">거래 종료</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="auction of sellRice"
|
||||
:key="auction.id"
|
||||
class="auctionItem gx-0"
|
||||
@click="selectedSellRiceAuction = auction"
|
||||
>
|
||||
<div class="idx f_tnum">{{ auction.id }}</div>
|
||||
<div class="host">{{ auction.hostName }}</div>
|
||||
<div class="amount f_tnum">금{{ auction.amount.toLocaleString() }}</div>
|
||||
<div class="highestBidder">{{ auction.highestBid?.generalName ?? "-" }}</div>
|
||||
<div :class="['highestBid f_tnum', auction.highestBid ? '' : 'noBid']">
|
||||
쌀 {{ (auction.highestBid?.amount ?? auction.startBidAmount).toLocaleString() }}
|
||||
</div>
|
||||
<div class="bidRatio f_tnum">
|
||||
{{ auction.highestBid ? (auction.highestBid.amount / auction.amount).toFixed(2) : "-" }}
|
||||
</div>
|
||||
<div class="finishBid f_tnum">쌀 {{ auction.finishBidAmount.toLocaleString() }}</div>
|
||||
<div class="closeDate f_tnum">{{ cutDateTime(auction.closeDate) }}</div>
|
||||
</div>
|
||||
<div v-if="selectedSellRiceAuction !== undefined" class="row gx-1">
|
||||
<div class="offset-1 col-4 offset-md-3 col-md-2 align-self-center f_tnum text-end">
|
||||
{{ selectedSellRiceAuction.id }}번 금 {{ selectedSellRiceAuction.amount }} 경매에 쌀
|
||||
</div>
|
||||
<div class="col-3 col-md-2">
|
||||
<NumberInputWithInfo
|
||||
v-model="bidAmountSellRiceAuction"
|
||||
:int="true"
|
||||
:min="selectedSellRiceAuction.startBidAmount"
|
||||
:max="selectedSellRiceAuction.finishBidAmount"
|
||||
:step="10"
|
||||
></NumberInputWithInfo>
|
||||
</div>
|
||||
<div class="col-2 col-md-1 d-grid"><BButton @click="bidSellRiceAuction">입찰</BButton></div>
|
||||
</div>
|
||||
|
||||
<div>경매 등록</div>
|
||||
<div class="row gx-1">
|
||||
<div class="col-2 offset-md-2 col-md-1">
|
||||
매물<br />
|
||||
<BButtonGroup>
|
||||
<BButton :pressed="openAuctionInfo.type == 'buyRice'" @click="openAuctionInfo.type = 'buyRice'"> 쌀 </BButton>
|
||||
<BButton :pressed="openAuctionInfo.type == 'sellRice'" @click="openAuctionInfo.type = 'sellRice'">
|
||||
금
|
||||
</BButton>
|
||||
</BButtonGroup>
|
||||
</div>
|
||||
|
||||
<div class="col col-md-2">
|
||||
수량 ({{ openAuctionInfo.type == "buyRice" ? "쌀" : "금" }})<br />
|
||||
<NumberInputWithInfo
|
||||
v-model="openAuctionInfo.amount"
|
||||
:int="true"
|
||||
:min="100"
|
||||
:max="10000"
|
||||
:step="10"
|
||||
></NumberInputWithInfo>
|
||||
</div>
|
||||
<div class="col-2 col-md-1">
|
||||
기간(턴)
|
||||
<NumberInputWithInfo
|
||||
v-model="openAuctionInfo.closeTurnCnt"
|
||||
:int="true"
|
||||
:min="3"
|
||||
:max="24"
|
||||
:step="1"
|
||||
></NumberInputWithInfo>
|
||||
</div>
|
||||
<div class="col col-md-2">
|
||||
시작가 ({{ openAuctionInfo.type == "buyRice" ? "금" : "쌀" }})
|
||||
<NumberInputWithInfo
|
||||
v-model="openAuctionInfo.startBidAmount"
|
||||
:int="true"
|
||||
:min="100"
|
||||
:max="10000"
|
||||
:step="10"
|
||||
></NumberInputWithInfo>
|
||||
</div>
|
||||
<div class="col col-md-2">
|
||||
마감가 ({{ openAuctionInfo.type == "buyRice" ? "금" : "쌀" }})
|
||||
<NumberInputWithInfo
|
||||
v-model="openAuctionInfo.finishBidAmount"
|
||||
:int="true"
|
||||
:min="100"
|
||||
:max="10000"
|
||||
:step="10"
|
||||
></NumberInputWithInfo>
|
||||
</div>
|
||||
<div class="col-1 d-grid">
|
||||
<BButton @click="openAuction">등록</BButton>
|
||||
</div>
|
||||
</div>
|
||||
<div>이전 경매(최근 20건)</div>
|
||||
<div v-for="(log, idx) in recentLogs" :key="idx">
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div v-html="formatLog(log)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { BasicResourceAuctionInfo } from "@/defs/API/Auction";
|
||||
import { SammoAPI } from "@/SammoAPI";
|
||||
import { unwrap } from "@/util/unwrap";
|
||||
import { useToast, BButtonGroup, BButton } from "bootstrap-vue-3";
|
||||
import { isString } from "lodash";
|
||||
import { onMounted, reactive, ref, watch } from "vue";
|
||||
import NumberInputWithInfo from "@/components/NumberInputWithInfo.vue";
|
||||
import { formatLog } from "@/utilGame/formatLog";
|
||||
const toasts = unwrap(useToast());
|
||||
|
||||
const buyRice = ref<BasicResourceAuctionInfo[]>([]);
|
||||
const sellRice = ref<BasicResourceAuctionInfo[]>([]);
|
||||
const recentLogs = ref<string[]>([]);
|
||||
|
||||
const selectedBuyRiceAuction = ref<BasicResourceAuctionInfo | undefined>(undefined);
|
||||
const bidAmountBuyRiceAuction = ref<number>(0);
|
||||
|
||||
watch(selectedBuyRiceAuction, (auction) => {
|
||||
if (!auction) {
|
||||
return;
|
||||
}
|
||||
bidAmountBuyRiceAuction.value = auction.highestBid ? auction.highestBid.amount : auction.startBidAmount;
|
||||
});
|
||||
|
||||
function cutDateTime(dateTime: string, showSecond = false) {
|
||||
if (showSecond) {
|
||||
return dateTime.substring(5, 19);
|
||||
}
|
||||
return dateTime.substring(5, 16);
|
||||
}
|
||||
|
||||
async function bidBuyRiceAuction() {
|
||||
if (selectedBuyRiceAuction.value === undefined) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await SammoAPI.Auction.BidBuyRiceAuction({
|
||||
auctionID: selectedBuyRiceAuction.value.id,
|
||||
amount: bidAmountBuyRiceAuction.value,
|
||||
});
|
||||
toasts.success({
|
||||
title: "입찰 완료",
|
||||
body: `입찰했습니다.`,
|
||||
});
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (isString(e)) {
|
||||
toasts.danger({
|
||||
title: "에러",
|
||||
body: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const selectedSellRiceAuction = ref<BasicResourceAuctionInfo | undefined>(undefined);
|
||||
const bidAmountSellRiceAuction = ref<number>(0);
|
||||
|
||||
watch(selectedSellRiceAuction, (auction) => {
|
||||
if (!auction) {
|
||||
return;
|
||||
}
|
||||
bidAmountSellRiceAuction.value = auction.highestBid ? auction.highestBid.amount : auction.startBidAmount;
|
||||
});
|
||||
|
||||
async function bidSellRiceAuction() {
|
||||
if (selectedSellRiceAuction.value === undefined) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await SammoAPI.Auction.BidSellRiceAuction({
|
||||
auctionID: selectedSellRiceAuction.value.id,
|
||||
amount: bidAmountSellRiceAuction.value,
|
||||
});
|
||||
toasts.success({
|
||||
title: "입찰 완료",
|
||||
body: `입찰했습니다.`,
|
||||
});
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (isString(e)) {
|
||||
toasts.danger({
|
||||
title: "에러",
|
||||
body: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type openAuctionT = {
|
||||
type: "buyRice" | "sellRice";
|
||||
amount: number;
|
||||
startBidAmount: number;
|
||||
finishBidAmount: number;
|
||||
closeTurnCnt: number;
|
||||
};
|
||||
|
||||
const openAuctionInfo = reactive<openAuctionT>({
|
||||
type: "buyRice",
|
||||
amount: 1000,
|
||||
startBidAmount: 500,
|
||||
finishBidAmount: 2000,
|
||||
closeTurnCnt: 24,
|
||||
});
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const result = await SammoAPI.Auction.GetActiveResourceAuctionList();
|
||||
buyRice.value = result.buyRice;
|
||||
sellRice.value = result.sellRice;
|
||||
recentLogs.value = result.recentLogs;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (isString(e)) {
|
||||
toasts.danger({
|
||||
title: "에러",
|
||||
body: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function openAuction() {
|
||||
const { type, amount, startBidAmount, finishBidAmount, closeTurnCnt } = openAuctionInfo;
|
||||
|
||||
try {
|
||||
const apiCall = type === "buyRice" ? SammoAPI.Auction.OpenBuyRiceAuction : SammoAPI.Auction.OpenSellRiceAuction;
|
||||
const result = await apiCall({
|
||||
amount,
|
||||
startBidAmount,
|
||||
finishBidAmount,
|
||||
closeTurnCnt,
|
||||
});
|
||||
toasts.success({
|
||||
title: "성공",
|
||||
body: `${result.auctionID}번 경매로 등록되었습니다.`,
|
||||
});
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (isString(e)) {
|
||||
toasts.danger({
|
||||
title: "에러",
|
||||
body: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
refresh,
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
void refresh();
|
||||
console.log("mounted");
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "@scss/common/break_500px.scss";
|
||||
|
||||
.auctionItem {
|
||||
display: grid;
|
||||
text-align: center;
|
||||
|
||||
> div {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.noBid {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
border-bottom: solid gray 1px;
|
||||
}
|
||||
|
||||
@include media-500px {
|
||||
.auctionItem {
|
||||
grid-template-columns: 1fr 3fr 3fr 1fr 2fr 2fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
|
||||
.idx {
|
||||
grid-column: 1 / 2;
|
||||
grid-row: 1 / 3;
|
||||
}
|
||||
|
||||
.host {
|
||||
grid-column: 2 / 3;
|
||||
grid-row: 1 / 2;
|
||||
}
|
||||
|
||||
.amount {
|
||||
grid-column: 2 / 3;
|
||||
grid-row: 2/ 3;
|
||||
}
|
||||
|
||||
.highestBidder {
|
||||
grid-column: 3 / 4;
|
||||
grid-row: 1 / 2;
|
||||
}
|
||||
|
||||
.highestBid {
|
||||
grid-column: 3 / 4;
|
||||
grid-row: 2 / 3;
|
||||
}
|
||||
|
||||
.bidRatio {
|
||||
grid-column: 4 / 5;
|
||||
grid-row: 1 / 3;
|
||||
}
|
||||
|
||||
.finishBid {
|
||||
grid-column: 5 / 6;
|
||||
grid-row: 1 / 3;
|
||||
}
|
||||
|
||||
.closeDate {
|
||||
grid-column: 6 / 7;
|
||||
grid-row: 1 / 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include media-1000px {
|
||||
.auctionItem {
|
||||
grid-template-columns: 1fr 2fr 2fr 2fr 2fr 1fr 3fr 2fr;
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<div class="bg0">
|
||||
<div>
|
||||
내 가명: <span class="isMe">{{ obfuscatedName }}</span>
|
||||
</div>
|
||||
<template v-if="currentAuction !== undefined">
|
||||
<div class="bg2">경매 {{ currentAuction.auction.id }}번 상세</div>
|
||||
<div class="row gx-0 text-center">
|
||||
<div class="col-2 col-md-1 bg1">경매명</div>
|
||||
<div class="col-4 col-md-2">{{ currentAuction.auction.title }}</div>
|
||||
|
||||
<div class="col-2 col-md-1 bg1">주최자(익명)</div>
|
||||
<div :class="['col-4 col-md-2', currentAuction.auction.isCallerHost ? 'isMe' : '']">
|
||||
{{ currentAuction.auction.hostName }}
|
||||
</div>
|
||||
|
||||
<div class="col-2 col-md-1 bg1">종료일시</div>
|
||||
<div class="col-4 col-md-2 f_tnum">{{ cutDateTime(currentAuction.auction.closeDate, true) }}</div>
|
||||
|
||||
<div class="col-2 col-md-1 bg1">최대지연</div>
|
||||
<div class="col-4 col-md-2 f_tnum">
|
||||
{{ cutDateTime(currentAuction.auction.availableLatestBidCloseDate, true) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg1">입찰자 목록</div>
|
||||
<div
|
||||
class="row gx-0 px-md-5 text-center"
|
||||
:style="{
|
||||
borderBottom: 'solid 1px white',
|
||||
}"
|
||||
>
|
||||
<div class="col-4 offset-md-2 col-md-3">입찰자</div>
|
||||
<div class="col-4 col-md-2 text-end px-5">입찰포인트</div>
|
||||
<div class="col-4 col-md-3">시각</div>
|
||||
</div>
|
||||
<div v-for="bidder of currentAuction.bidList" :key="bidder.amount" class="row gx-0 px-md-5 text-center">
|
||||
<div :class="['col-4 offset-md-2 col-md-3', bidder.isCallerHighestBidder ? 'isMe' : '']">{{ bidder.generalName }}</div>
|
||||
<div class="col-4 col-md-2 text-end px-5 f_tnum">{{ bidder.amount.toLocaleString() }}</div>
|
||||
<div class="col-4 col-md-3 f_tnum">{{ cutDateTime(bidder.date) }}</div>
|
||||
</div>
|
||||
<div class="bg1">입찰하기</div>
|
||||
<div class="row">
|
||||
<label class="col-5 offset-md-3 col-md-3 col-form-label text-center">유산포인트 (잔여: {{ currentAuction.remainPoint.toLocaleString() }}포인트)</label>
|
||||
<div class="col-4 col-md-2">
|
||||
<NumberInputWithInfo
|
||||
v-model="bidAmount"
|
||||
:int="true"
|
||||
:min="currentAuction.bidList[0].amount"
|
||||
:max="currentAuction.remainPoint"
|
||||
title=""
|
||||
:step="1"
|
||||
></NumberInputWithInfo>
|
||||
</div>
|
||||
<div class="col-3 col-md-1 d-grid"><BButton @click="bidAuction">입찰</BButton></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="bg1">진행중인 경매 목록</div>
|
||||
<div
|
||||
class="row gx-0 text-center"
|
||||
:style="{
|
||||
borderBottom: 'solid 1px white',
|
||||
}"
|
||||
>
|
||||
<div class="col-1">번호</div>
|
||||
<div class="col-4">경매명</div>
|
||||
<div class="col-1">주최자</div>
|
||||
<div class="col-2">종료일시</div>
|
||||
<div class="col-1">연장</div>
|
||||
<div class="col-1">1순위</div>
|
||||
<div class="col-2 text-end px-2">포인트</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="[auctionID, auction] of ongoingAuctionList"
|
||||
:key="auctionID"
|
||||
class="row gx-0 text-center clickableRow"
|
||||
@click="currentAuctionID = auctionID"
|
||||
>
|
||||
<div class="col-1">{{ auction.id }}</div>
|
||||
<div class="col-4">{{ auction.title }}</div>
|
||||
<div :class="['col-1', auction.isCallerHost ? 'isMe' : '']">{{ auction.hostName }}</div>
|
||||
<div class="col-2 f_tnum">{{ cutDateTime(auction.closeDate) }}</div>
|
||||
<div class="col-1">{{ auction.remainCloseDateExtensionCnt > 0 ? "남음" : "소진" }}</div>
|
||||
<div :class="['col-1', auction.highestBid.isCallerHighestBidder ? 'isMe' : '']">
|
||||
{{ auction.highestBid.generalName }}
|
||||
</div>
|
||||
<div class="col-2 text-end px-2 f_tnum">{{ auction.highestBid.amount.toLocaleString() }}</div>
|
||||
</div>
|
||||
<div class="bg1">종료된 경매 목록</div>
|
||||
<div
|
||||
class="row gx-0 text-center"
|
||||
:style="{
|
||||
borderBottom: 'solid 1px white',
|
||||
}"
|
||||
>
|
||||
<div class="col-1">번호</div>
|
||||
<div class="col-4">경매명</div>
|
||||
<div class="col-1">주최자</div>
|
||||
<div class="col-2">종료일시</div>
|
||||
<div class="col-1">연장</div>
|
||||
<div class="col-1">1순위</div>
|
||||
<div class="col-2 text-end px-2">포인트</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="[auctionID, auction] of finishedAuctionList"
|
||||
:key="auctionID"
|
||||
class="row gx-0 text-center clickableRow"
|
||||
@click="currentAuctionID = auctionID"
|
||||
>
|
||||
<div class="col-1">{{ auction.id }}</div>
|
||||
<div class="col-4">{{ auction.title }}</div>
|
||||
<div :class="['col-1', auction.isCallerHost ? 'isMe' : '']">{{ auction.hostName }}</div>
|
||||
<div class="col-2 f_tnum">{{ cutDateTime(auction.closeDate) }}</div>
|
||||
<div class="col-1">{{ auction.remainCloseDateExtensionCnt > 0 ? "남음" : "소진" }}</div>
|
||||
<div :class="['col-1', auction.highestBid.isCallerHighestBidder ? 'isMe' : '']">
|
||||
{{ auction.highestBid.generalName }}
|
||||
</div>
|
||||
<div class="col-2 text-end px-2 f_tnum">{{ auction.highestBid.amount.toLocaleString() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { UniqueItemAuctionDetail, UniqueItemAuctionList } from "@/defs/API/Auction";
|
||||
import { SammoAPI } from "@/SammoAPI";
|
||||
import { unwrap } from "@/util/unwrap";
|
||||
import { useToast, BButton } from "bootstrap-vue-3";
|
||||
import { isString } from "lodash";
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
import NumberInputWithInfo from "@/components/NumberInputWithInfo.vue";
|
||||
|
||||
type AuctionItemInfo = UniqueItemAuctionList["list"][0];
|
||||
|
||||
const currentAuctionID = ref<number>();
|
||||
const currentAuction = ref<UniqueItemAuctionDetail | undefined>(undefined);
|
||||
const bidAmount = ref<number>(5000);
|
||||
|
||||
async function refreshDetail() {
|
||||
if (currentAuctionID.value === undefined) {
|
||||
return;
|
||||
}
|
||||
const auctionID = currentAuctionID.value;
|
||||
try {
|
||||
currentAuction.value = await SammoAPI.Auction.GetUniqueItemAuctionDetail({ auctionID });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (isString(e)) {
|
||||
unwrap(useToast()).danger({
|
||||
title: "에러",
|
||||
body: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
watch(currentAuctionID, () => {
|
||||
void refreshDetail();
|
||||
});
|
||||
const ongoingAuctionList = ref(new Map<number, AuctionItemInfo>());
|
||||
const finishedAuctionList = ref(new Map<number, AuctionItemInfo>());
|
||||
const obfuscatedName = ref("");
|
||||
|
||||
const toasts = unwrap(useToast());
|
||||
|
||||
function cutDateTime(dateTime: string, showSecond = false) {
|
||||
if (showSecond) {
|
||||
return dateTime.substring(5, 19);
|
||||
}
|
||||
return dateTime.substring(5, 16);
|
||||
}
|
||||
|
||||
async function refreshList() {
|
||||
try {
|
||||
const result = await SammoAPI.Auction.GetUniqueItemAuctionList();
|
||||
obfuscatedName.value = result.obfuscatedName;
|
||||
finishedAuctionList.value = new Map(
|
||||
result.list.filter((auction) => auction.finished).map((auction) => [auction.id, auction])
|
||||
);
|
||||
ongoingAuctionList.value = new Map(
|
||||
result.list.filter((auction) => !auction.finished).map((auction) => [auction.id, auction])
|
||||
);
|
||||
if (currentAuctionID.value === undefined && ongoingAuctionList.value.size > 0) {
|
||||
const auctionIterator = ongoingAuctionList.value.values().next();
|
||||
if (!auctionIterator.done) {
|
||||
currentAuctionID.value = auctionIterator.value.id;
|
||||
bidAmount.value = auctionIterator.value.highestBid.amount;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (isString(e)) {
|
||||
toasts.danger({
|
||||
title: "에러",
|
||||
body: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function bidAuction() {
|
||||
if (currentAuction.value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const amount = bidAmount.value;
|
||||
const auctionInfo = currentAuction.value.auction;
|
||||
|
||||
if (confirm(`${auctionInfo.title}에 ${amount}유산포인트를 입찰하시겠습니까?`)) {
|
||||
try {
|
||||
await SammoAPI.Auction.BidUniqueAuction({ auctionID: auctionInfo.id, amount });
|
||||
toasts.success({
|
||||
title: "성공",
|
||||
body: "입찰이 완료되었습니다.",
|
||||
});
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (isString(e)) {
|
||||
toasts.danger({
|
||||
title: "에러",
|
||||
body: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const waiters = [refreshList(), refreshDetail()];
|
||||
await Promise.all(waiters);
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
refresh,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
void refreshList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.isMe {
|
||||
font-weight: bold;
|
||||
color: aquamarine;
|
||||
}
|
||||
|
||||
.clickableRow{
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.clickableRow:hover{
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,460 @@
|
||||
<template>
|
||||
<div v-if="bettingDetailInfo !== undefined && info !== undefined">
|
||||
<div class="bg2">
|
||||
{{ info.name }}
|
||||
<span v-if="info.finished">(종료)</span>
|
||||
<span v-else-if="(yearMonth ?? 0) <= info.closeYearMonth"
|
||||
>({{ parseYearMonth(info.closeYearMonth)[0] }}년 {{ parseYearMonth(info.closeYearMonth)[1] }}월까지)</span
|
||||
>
|
||||
<span v-else>(베팅 마감)</span>
|
||||
(총액: {{ bettingAmount.toLocaleString() }})
|
||||
</div>
|
||||
|
||||
<div class="row bettingCandidates gx-1 gy-1">
|
||||
<div v-for="(candidate, idx) in info.candidates" :key="idx" class="col-4 col-md-2" @click="toggleCandidate(parseInt(idx))">
|
||||
<div
|
||||
:class="[
|
||||
'bettingCandidate',
|
||||
pickedBetType.has(parseInt(idx)) ? 'picked' : undefined,
|
||||
info.finished && winner.has(parseInt(idx)) ? 'picked' : undefined,
|
||||
]"
|
||||
>
|
||||
<div class="title bg1">
|
||||
{{ candidate.title }}
|
||||
</div>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div v-if="candidate.isHtml" class="info" v-html="candidate.info" />
|
||||
<div v-else class="info">{{ candidate.info }}</div>
|
||||
<div class="pickRate">선택율: {{ (((partialBet.get(parseInt(idx)) ?? 0) / pureBettingAmount) * 100).toFixed(1) }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!info.finished && (yearMonth ?? 0) <= info.closeYearMonth" class="row gx-0">
|
||||
<div class="col-6 col-md-3 align-self-center">
|
||||
잔여 {{ info.reqInheritancePoint ? "포인트" : "금" }} : {{ bettingDetailInfo.remainPoint.toLocaleString() }}
|
||||
</div>
|
||||
<div class="col-6 col-md-3 align-self-center">
|
||||
사용 포인트: {{ sum(Array.from(myBettings.values())).toLocaleString() }}
|
||||
</div>
|
||||
<div class="col-6 col-md-3 align-self-center">대상: {{ getTypeStr(pickedBetTypeKey) }}</div>
|
||||
<div class="col-4 col-md-2 d-grid">
|
||||
<!-- eslint-disable-next-line vue/max-attributes-per-line -->
|
||||
<b-form-input v-model.number="betPoint" class="d-grid" type="number" :min="10" :max="1000" :step="10" />
|
||||
</div>
|
||||
<div class="col-2 col-md-1 d-grid">
|
||||
<b-button class="d-grid" @click="submitBet"> 베팅 </b-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="bg2">배당 순위</div>
|
||||
<div
|
||||
class="row"
|
||||
:style="{
|
||||
borderBottom: 'gray solid 1px',
|
||||
}"
|
||||
>
|
||||
<div class="col-5 text-center">대상</div>
|
||||
<div class="col-2 text-center">베팅액</div>
|
||||
<div class="col-3 text-center">내 베팅</div>
|
||||
<div class="col-2 text-center">
|
||||
{{ info.finished ? "배율" : "기대 배율" }}
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="info.finished">
|
||||
<div v-for="[betType, amount] of detailBet" :key="betType" class="row">
|
||||
<template v-for="[matchPoint, color] of [calcMatchPointWithColor(betType)]" :key="matchPoint">
|
||||
<div
|
||||
class="col-5"
|
||||
:style="{
|
||||
fontWeight: myBettings.has(betType) ? 'bold' : undefined,
|
||||
color: color,
|
||||
}"
|
||||
>
|
||||
{{ getTypeStr(betType) }}
|
||||
</div>
|
||||
<div class="col-2 text-end">
|
||||
{{ amount.toLocaleString() }}
|
||||
</div>
|
||||
<div v-if="myBettings.has(betType)" class="col-3 text-center">
|
||||
<template v-for="subPoint of [myBettings.get(betType) ?? 0]">
|
||||
({{ subPoint.toLocaleString() }} ->
|
||||
{{
|
||||
calculatedReward[matchPoint] == 0
|
||||
? 0
|
||||
: ((subPoint * calculatedReward[matchPoint]) / (calculatedSubAmount.get(matchPoint) ?? 1))
|
||||
.toFixed(1)
|
||||
.toLocaleString()
|
||||
}})
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="col-3 text-center" />
|
||||
<div class="col-2 text-end">
|
||||
{{
|
||||
(calculatedReward[matchPoint] == 0
|
||||
? 0
|
||||
: calculatedReward[matchPoint] / (calculatedSubAmount.get(matchPoint) ?? 1)
|
||||
)
|
||||
.toFixed(1)
|
||||
.toLocaleString()
|
||||
}}배
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-for="[betType, amount] of detailBet" :key="betType" class="row">
|
||||
<div
|
||||
class="col-5"
|
||||
:style="{
|
||||
fontWeight: myBettings.has(betType) ? 'bold' : undefined,
|
||||
}"
|
||||
>
|
||||
{{ getTypeStr(betType) }}
|
||||
</div>
|
||||
<div class="col-2 text-end">
|
||||
{{ amount.toLocaleString() }}
|
||||
</div>
|
||||
<div v-if="myBettings.has(betType)" class="col-3 text-center">
|
||||
<template v-for="subPoint of [myBettings.get(betType) ?? 0]">
|
||||
({{ subPoint.toLocaleString() }} ->
|
||||
{{ ((subPoint * maxBettingReward) / amount).toFixed(1).toLocaleString() }})
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="col-3 text-center" />
|
||||
<div class="col-2 text-end">{{ (maxBettingReward / amount).toFixed(1).toLocaleString() }}배</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ToastType } from "@/defs";
|
||||
import type { BettingDetailResponse, BettingInfo } from "@/defs/API/Betting";
|
||||
import { SammoAPI } from "@/SammoAPI";
|
||||
import { joinYearMonth } from "@/util/joinYearMonth";
|
||||
import { parseYearMonth } from "@/util/parseYearMonth";
|
||||
import { isString, range, sum } from "lodash";
|
||||
import { ref, type PropType, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
bettingID: {
|
||||
type: Number as PropType<number>,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "reqToast", content: ToastType): void;
|
||||
}>();
|
||||
|
||||
const year = ref<number>(0);
|
||||
const month = ref<number>(0);
|
||||
const yearMonth = ref<number>(0);
|
||||
|
||||
const bettingDetailInfo = ref<BettingDetailResponse>();
|
||||
const info = ref<BettingInfo>();
|
||||
|
||||
const bettingAmount = ref<number>(0);
|
||||
const maxBettingReward = ref<number>(1);
|
||||
const pureBettingAmount = ref<number>(0);
|
||||
|
||||
const partialBet = ref(new Map<number, number>());
|
||||
const detailBet = ref<[string, number][]>([]);
|
||||
|
||||
const typeMap = ref(new Map<string, string>());
|
||||
function getTypeStr(type: string): string {
|
||||
const typeResult = typeMap.value.get(type);
|
||||
if (typeResult !== undefined) {
|
||||
return typeResult;
|
||||
}
|
||||
const bettingSubTypes = JSON.parse(type) as number[];
|
||||
if (bettingSubTypes[0] < -1) {
|
||||
return "Invalid";
|
||||
}
|
||||
|
||||
const textBettingType = bettingSubTypes
|
||||
.map((idx) => {
|
||||
return bettingDetailInfo.value?.bettingInfo.candidates[idx].title;
|
||||
})
|
||||
.join(", ");
|
||||
typeMap.value.set(type, textBettingType);
|
||||
return textBettingType;
|
||||
}
|
||||
|
||||
const pickedBetType = ref(new Set<number>());
|
||||
const pickedBetTypeKey = ref("[]");
|
||||
|
||||
const betPoint = ref(0);
|
||||
const myBettings = ref(new Map<string, number>());
|
||||
|
||||
const winner = ref(new Set<number>());
|
||||
const calculatedReward = ref<number[]>([]);
|
||||
const calculatedSubAmount = ref(new Map<number, number>());
|
||||
|
||||
function calcMatchPointWithColor(type: string): [number, "green" | "yellow" | "red" | undefined] {
|
||||
if (!info.value?.finished) {
|
||||
return [0, undefined];
|
||||
}
|
||||
const bettingSubTypes = JSON.parse(type) as number[];
|
||||
if (bettingSubTypes[0] < -1) {
|
||||
return [0, undefined];
|
||||
}
|
||||
|
||||
let matchPoint = 0;
|
||||
for (const subType of bettingSubTypes) {
|
||||
if (winner.value.has(subType)) {
|
||||
matchPoint += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (info.value.isExclusive) {
|
||||
if (matchPoint == info.value.selectCnt) {
|
||||
return [matchPoint, "green"];
|
||||
} else {
|
||||
return [matchPoint, "red"];
|
||||
}
|
||||
}
|
||||
|
||||
let color: "green" | "red" | "yellow" = "green";
|
||||
if (matchPoint == 0) {
|
||||
color = "red";
|
||||
} else if (matchPoint < info.value.selectCnt) {
|
||||
color = "yellow";
|
||||
}
|
||||
return [matchPoint, color];
|
||||
}
|
||||
|
||||
function calcReward() {
|
||||
if (info.value === undefined || bettingDetailInfo.value === undefined) {
|
||||
throw "no info";
|
||||
}
|
||||
const selectCnt = info.value.selectCnt;
|
||||
const rewardAmount = new Array<number>(selectCnt).fill(0);
|
||||
|
||||
const subAmount = new Map<number, number>();
|
||||
for (const [bettingTypeStr, amount] of bettingDetailInfo.value.bettingDetail) {
|
||||
if (amount == 0) {
|
||||
continue;
|
||||
}
|
||||
const [matchPoint] = calcMatchPointWithColor(bettingTypeStr);
|
||||
subAmount.set(matchPoint, (subAmount.get(matchPoint) ?? 0) + amount);
|
||||
}
|
||||
calculatedSubAmount.value = subAmount;
|
||||
|
||||
if (selectCnt == 1) {
|
||||
rewardAmount[selectCnt - 1] = bettingAmount.value;
|
||||
calculatedReward.value = rewardAmount;
|
||||
return;
|
||||
}
|
||||
|
||||
if (info.value.isExclusive) {
|
||||
rewardAmount[selectCnt - 1] = bettingAmount.value;
|
||||
calculatedReward.value = rewardAmount;
|
||||
return;
|
||||
}
|
||||
|
||||
let remainRewardAmount = bettingAmount.value;
|
||||
let accumulatedRewardAmount = 0;
|
||||
let givenRewardAmount = bettingAmount.value;
|
||||
|
||||
for (const matchPoint of range(selectCnt, 0, -1)) {
|
||||
givenRewardAmount /= 2;
|
||||
accumulatedRewardAmount += givenRewardAmount;
|
||||
if (!subAmount.has(matchPoint)) {
|
||||
continue;
|
||||
}
|
||||
rewardAmount[matchPoint] = accumulatedRewardAmount;
|
||||
remainRewardAmount -= accumulatedRewardAmount;
|
||||
accumulatedRewardAmount = 0;
|
||||
}
|
||||
|
||||
//남은 상금은 '당첨자'에게 몰아준다.
|
||||
//당첨자가 아무도 없다면, 0개 맞춘 그룹에게 돌아간다.
|
||||
for (const matchPoint of range(selectCnt, -1, -1)) {
|
||||
if (!subAmount.has(matchPoint)) {
|
||||
continue;
|
||||
}
|
||||
rewardAmount[matchPoint] += remainRewardAmount;
|
||||
break;
|
||||
}
|
||||
|
||||
calculatedReward.value = rewardAmount;
|
||||
}
|
||||
|
||||
async function loadBetting(bettingID: number) {
|
||||
try {
|
||||
const result = await SammoAPI.Betting.GetBettingDetail[bettingID]();
|
||||
year.value = result.year;
|
||||
month.value = result.month;
|
||||
yearMonth.value = joinYearMonth(result.year, result.month);
|
||||
bettingDetailInfo.value = result;
|
||||
info.value = result.bettingInfo;
|
||||
|
||||
typeMap.value.clear();
|
||||
partialBet.value.clear();
|
||||
|
||||
const betSort = new Map<string, number>();
|
||||
|
||||
let _bettingAmount = 0;
|
||||
let adminBettingAmount = 0;
|
||||
for (const [bettingType, amount] of result.bettingDetail) {
|
||||
console.log(amount, typeof amount);
|
||||
let userBet = true;
|
||||
const bettingSubTypes = JSON.parse(bettingType) as number[];
|
||||
for (const bettingSubType of bettingSubTypes) {
|
||||
if (bettingSubType < 0) {
|
||||
userBet = false;
|
||||
continue;
|
||||
}
|
||||
const oldValue = partialBet.value.get(bettingSubType) ?? 0;
|
||||
partialBet.value.set(bettingSubType, oldValue + amount);
|
||||
}
|
||||
|
||||
if (userBet) {
|
||||
const oldValue = betSort.get(bettingType) ?? 0;
|
||||
betSort.set(bettingType, oldValue + amount);
|
||||
}
|
||||
|
||||
_bettingAmount += amount;
|
||||
if (!userBet) {
|
||||
adminBettingAmount += amount;
|
||||
}
|
||||
}
|
||||
console.log(_bettingAmount);
|
||||
bettingAmount.value = _bettingAmount;
|
||||
pureBettingAmount.value = _bettingAmount - adminBettingAmount;
|
||||
if (info.value.isExclusive || info.value.selectCnt == 1) {
|
||||
maxBettingReward.value = _bettingAmount;
|
||||
} else {
|
||||
maxBettingReward.value = _bettingAmount / 2;
|
||||
}
|
||||
|
||||
detailBet.value = Array.from(betSort.entries());
|
||||
detailBet.value.sort(([, lhsVal], [, rhsVal]) => {
|
||||
return rhsVal - lhsVal;
|
||||
});
|
||||
|
||||
pickedBetType.value.clear();
|
||||
pickedBetTypeKey.value = "[]";
|
||||
myBettings.value.clear();
|
||||
|
||||
if (result.bettingInfo.winner) {
|
||||
winner.value = new Set(result.bettingInfo.winner);
|
||||
} else {
|
||||
winner.value.clear();
|
||||
}
|
||||
|
||||
for (const [betType, amount] of result.myBetting) {
|
||||
myBettings.value.set(betType, amount);
|
||||
}
|
||||
calcReward();
|
||||
} catch (e) {
|
||||
if (isString(e)) {
|
||||
emit("reqToast", {
|
||||
content: {
|
||||
title: "에러",
|
||||
body: e,
|
||||
},
|
||||
options: {
|
||||
variant: "danger",
|
||||
},
|
||||
});
|
||||
}
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
void loadBetting(props.bettingID);
|
||||
watch(
|
||||
() => props.bettingID,
|
||||
(newBettingID) => {
|
||||
void loadBetting(newBettingID);
|
||||
}
|
||||
);
|
||||
|
||||
function toggleCandidate(idx: number) {
|
||||
if (info.value === undefined) {
|
||||
return;
|
||||
}
|
||||
if (bettingDetailInfo.value === undefined) {
|
||||
return;
|
||||
}
|
||||
if (info.value.closeYearMonth < yearMonth.value) {
|
||||
return;
|
||||
}
|
||||
if (info.value.finished) {
|
||||
return;
|
||||
}
|
||||
const selectCnt = bettingDetailInfo.value.bettingInfo.selectCnt;
|
||||
|
||||
if (selectCnt == 1) {
|
||||
pickedBetType.value.clear();
|
||||
pickedBetType.value.add(idx);
|
||||
pickedBetTypeKey.value = JSON.stringify([idx]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pickedBetType.value.has(idx)) {
|
||||
pickedBetType.value.delete(idx);
|
||||
} else if (pickedBetType.value.size < selectCnt) {
|
||||
pickedBetType.value.add(idx);
|
||||
} else {
|
||||
emit("reqToast", {
|
||||
content: {
|
||||
title: "오류",
|
||||
body: `이미 ${selectCnt}개를 선택했습니다.`,
|
||||
},
|
||||
options: {
|
||||
variant: "warning",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const typeArr = Array.from(pickedBetType.value.values());
|
||||
pickedBetTypeKey.value = JSON.stringify(typeArr.sort((lhs, rhs) => lhs - rhs));
|
||||
}
|
||||
|
||||
async function submitBet(): Promise<void> {
|
||||
const bettingInfo = info.value;
|
||||
if (bettingInfo === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bettingID = bettingInfo.id;
|
||||
const bettingType = JSON.parse(pickedBetTypeKey.value) as number[];
|
||||
const amount = betPoint.value;
|
||||
try {
|
||||
await SammoAPI.Betting.Bet({
|
||||
bettingID,
|
||||
bettingType,
|
||||
amount,
|
||||
});
|
||||
emit("reqToast", {
|
||||
content: {
|
||||
title: "완료",
|
||||
body: "베팅했습니다",
|
||||
},
|
||||
options: {
|
||||
variant: "success",
|
||||
},
|
||||
});
|
||||
await loadBetting(bettingInfo.id);
|
||||
} catch (e) {
|
||||
if (isString(e)) {
|
||||
emit("reqToast", {
|
||||
content: {
|
||||
title: "에러",
|
||||
body: e,
|
||||
},
|
||||
options: {
|
||||
variant: "danger",
|
||||
},
|
||||
});
|
||||
}
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div class="articleFrame bg0">
|
||||
<div class="bg1 row gx-0">
|
||||
<div class="authorName center">
|
||||
{{ article.author }}
|
||||
</div>
|
||||
<div class="col articleTitle center">
|
||||
{{ article.title }}
|
||||
</div>
|
||||
<div class="col-2 col-md-1 date center">
|
||||
{{ article.date.slice(5, 16) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row gx-0 s-border-b">
|
||||
<div class="col-2 col-md-1 authorIcon center">
|
||||
<img class="generalIcon" width="64" height="64" :src="article.author_icon" />
|
||||
</div>
|
||||
<div class="col text">
|
||||
{{ article.text }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="commentList">
|
||||
<board-comment v-for="comment in article.comment" :key="comment.no" :comment="comment" />
|
||||
</div>
|
||||
<div class="row gx-0">
|
||||
<div class="bg2 inputCommentHeader center d-grid">
|
||||
<div class="align-self-center">댓글 달기</div>
|
||||
</div>
|
||||
<div class="col d-grid">
|
||||
<input
|
||||
v-model.trim="newCommentText"
|
||||
class="commentText"
|
||||
type="text"
|
||||
maxlength="250"
|
||||
placeholder="새 댓글 내용"
|
||||
@keyup.enter="submitComment"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-2 col-md-1 d-grid">
|
||||
<b-button class="submitComment" size="sm" @click="submitComment"> 등록 </b-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import type { BoardArticleItem } from "@/PageBoard.vue";
|
||||
import BoardComment from "@/components/BoardComment.vue";
|
||||
import { ref, type PropType } from "vue";
|
||||
import axios from "axios";
|
||||
import { convertFormData } from "@util/convertFormData";
|
||||
import type { InvalidResponse } from "@/defs";
|
||||
|
||||
const newCommentText = ref("");
|
||||
|
||||
const props = defineProps({
|
||||
article: {
|
||||
type: Object as PropType<BoardArticleItem>,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "submit-comment"): void;
|
||||
}>();
|
||||
|
||||
async function submitComment() {
|
||||
const comment = newCommentText.value;
|
||||
if (!comment) {
|
||||
return;
|
||||
}
|
||||
const articleNo = props.article.no;
|
||||
|
||||
let result: InvalidResponse;
|
||||
|
||||
try {
|
||||
const response = await axios({
|
||||
url: "j_board_comment_add.php",
|
||||
method: "post",
|
||||
responseType: "json",
|
||||
data: convertFormData({
|
||||
articleNo: articleNo,
|
||||
text: comment,
|
||||
}),
|
||||
});
|
||||
result = response.data;
|
||||
if (!result.result) {
|
||||
throw result.reason;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert(`실패했습니다: ${e}`);
|
||||
return;
|
||||
}
|
||||
|
||||
newCommentText.value = "";
|
||||
|
||||
emit("submit-comment");
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
td.text {
|
||||
white-space: pre;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div class="row gx-0 comment s-border-b">
|
||||
<div class="authorName center d-grid">
|
||||
<div class="align-self-center">
|
||||
{{ comment.author }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col text">
|
||||
{{ comment.text }}
|
||||
</div>
|
||||
<div class="col-2 col-md-1 date center d-grid">
|
||||
<div class="align-self-center">
|
||||
{{ comment.date.slice(5, 16) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import type { BoardCommentItem } from "@/PageBoard.vue";
|
||||
import { defineComponent, type PropType } from "vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "BoardComment",
|
||||
props: {
|
||||
comment: {
|
||||
type: Object as PropType<BoardCommentItem>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div class="bg0" style="padding-top: 20px">
|
||||
<button type="button" class="btn btn-sammo-base2 back_btn" @click="back">
|
||||
{{ props.type == "close" ? "창 닫기" : "돌아가기" }}
|
||||
</button>
|
||||
<div />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { PropType } from "vue";
|
||||
import "@scss/game_bg.scss";
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String as PropType<"normal" | "chief" | "close">,
|
||||
default: "normal",
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
|
||||
function back() {
|
||||
if (props.type === "normal") {
|
||||
location.href = "./";
|
||||
} else if (props.type == "chief") {
|
||||
location.href = "v_chiefCenter.php";
|
||||
} else {
|
||||
//TODO: window.close하려면 부모창이 있어야함!
|
||||
window.close();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,969 @@
|
||||
<template>
|
||||
<div class="commandBox">
|
||||
<div class="only1000px bg1 center row gx-0" style="height: 24px; font-size: 1.2em">
|
||||
<div class="col-5 align-self-center text-end">{{ officer.officerLevelText }} :</div>
|
||||
<div
|
||||
class="col-7 align-self-center"
|
||||
:style="{
|
||||
color: getNpcColor(officer.npcType ?? 0),
|
||||
}"
|
||||
>
|
||||
{{ officer.name }}
|
||||
</div>
|
||||
</div>
|
||||
<div :class="['row', 'controlPad', props.targetIsMe ? 'targetIsMe' : 'targetIsNotMe']">
|
||||
<div class="col-3 col-md-12 order-md-last">
|
||||
<div class="d-grid mb-1 py-1 only500px bg1 center">
|
||||
<div
|
||||
:style="{
|
||||
color: getNpcColor(officer.npcType ?? 0),
|
||||
fontSize: '1.2em',
|
||||
}"
|
||||
>
|
||||
{{ officer.name }}
|
||||
</div>
|
||||
<div>{{ officer.officerLevelText }}</div>
|
||||
</div>
|
||||
<div class="row gx-1 gy-1 py-1">
|
||||
<div class="col-md-4 mx-0 mb-0 mt-1 d-grid">
|
||||
<div class="alert alert-primary mb-0 center" style="padding: 0.5rem 0">
|
||||
<SimpleClock :serverTime="parseTime(props.date)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 d-grid">
|
||||
<BButton variant="secondary" @click="isEditMode = !isEditMode">
|
||||
{{ isEditMode ? "일반 모드" : "고급 모드" }}
|
||||
</BButton>
|
||||
</div>
|
||||
|
||||
<BDropdown class="col-md-4" text="반복">
|
||||
<BDropdownItem v-for="turnIdx in maxPushTurn" :key="turnIdx" @click="repeatNationCommand(turnIdx)">
|
||||
{{ turnIdx }}턴
|
||||
</BDropdownItem>
|
||||
</BDropdown>
|
||||
|
||||
<template v-if="isEditMode">
|
||||
<BDropdown class="col-md-4" left text="범위">
|
||||
<BDropdownItem @click="queryActionHelper.selectTurn()"> 해제 </BDropdownItem>
|
||||
<BDropdownItem @click="queryActionHelper.selectAll()"> 모든턴 </BDropdownItem>
|
||||
<BDropdownItem @click="queryActionHelper.selectStep(0, 2)"> 홀수턴 </BDropdownItem>
|
||||
<BDropdownItem @click="queryActionHelper.selectStep(1, 2)"> 짝수턴 </BDropdownItem>
|
||||
<BDropdownDivider />
|
||||
|
||||
<BDropdownText v-for="spanIdx in [3, 4, 5, 6, 7]" :key="spanIdx">
|
||||
{{ spanIdx }}턴 간격
|
||||
<br />
|
||||
<BButtonGroup>
|
||||
<BButton
|
||||
v-for="beginIdx in spanIdx"
|
||||
:key="beginIdx"
|
||||
class="ignoreMe"
|
||||
@click="queryActionHelper.selectStep(beginIdx - 1, spanIdx)"
|
||||
>
|
||||
{{ beginIdx }}
|
||||
</BButton>
|
||||
</BButtonGroup>
|
||||
</BDropdownText>
|
||||
</BDropdown>
|
||||
|
||||
<BDropdown class="col-md-4" left text="보관함">
|
||||
<BDropdownItem
|
||||
v-for="[actionKey, actions] of storedActions"
|
||||
:key="actionKey"
|
||||
@click.self="useStoredAction(actions)"
|
||||
>
|
||||
{{ actionKey }}
|
||||
<BButton size="sm" @click.prevent="deleteStoredActions(actionKey)"> 삭제 </BButton>
|
||||
</BDropdownItem>
|
||||
</BDropdown>
|
||||
|
||||
<div class="col-md-4 d-grid">
|
||||
<BDropdown right text="최근">
|
||||
<BDropdownItem
|
||||
v-for="(action, idx) in Array.from(recentActions.values()).reverse()"
|
||||
:key="idx"
|
||||
@click="void reserveCommandDirect([[queryActionHelper.getSelectedTurnList(), action]])"
|
||||
>
|
||||
{{ action.brief }}
|
||||
</BDropdownItem>
|
||||
</BDropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<BDropdown class="col-md-6" split text="당기기" @click="pullNationCommandSingle">
|
||||
<BDropdownItem v-for="turnIdx in maxPushTurn" :key="turnIdx" @click="pushNationCommand(-turnIdx)">
|
||||
{{ turnIdx }}턴
|
||||
</BDropdownItem>
|
||||
</BDropdown>
|
||||
<BDropdown class="col-md-6" split text="미루기" @click="pushNationCommandSingle">
|
||||
<BDropdownItem v-for="turnIdx in maxPushTurn" :key="turnIdx" @click="pushNationCommand(turnIdx)">
|
||||
{{ turnIdx }}턴
|
||||
</BDropdownItem>
|
||||
</BDropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div :style="{ position: 'relative' }">
|
||||
<div
|
||||
class="commandQuickReserveFormAnchor bg-dark"
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
top: `${basicModeRowHeight * currentQuickReserveTarget + 26}px`,
|
||||
width: '100%',
|
||||
zIndex: 9,
|
||||
}"
|
||||
>
|
||||
<CommandSelectForm
|
||||
ref="commandQuickReserveForm"
|
||||
v-model:activatedCategory="activatedCategory"
|
||||
:commandList="commandList"
|
||||
:hideClose="false"
|
||||
class="bg-dark"
|
||||
style="position: absolute"
|
||||
@onClose="chooseQuickReserveCommand($event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="commandPad chiefReservedCommand">
|
||||
<div :class="['commandTable', isEditMode ? 'editMode' : 'singleMode']">
|
||||
<DragSelect
|
||||
v-slot="{ selected }"
|
||||
:style="rowGridStyle"
|
||||
attribute="turnIdx"
|
||||
:disabled="!isEditMode"
|
||||
@dragStart="isDragSingle = true"
|
||||
@dragDone="
|
||||
isDragSingle = false;
|
||||
queryActionHelper.selectTurn(...$event);
|
||||
"
|
||||
>
|
||||
<div
|
||||
v-for="(turnObj, turnIdx) in reservedCommandList"
|
||||
:key="turnIdx"
|
||||
:turnIdx="turnIdx"
|
||||
class="time_pad center f_tnum"
|
||||
:style="{
|
||||
backgroundColor: 'black',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
color: isDragSingle && selected.has(`${turnIdx}`) ? 'cyan' : undefined,
|
||||
}"
|
||||
>
|
||||
{{ turnObj.time }}
|
||||
</div>
|
||||
</DragSelect>
|
||||
<DragSelect
|
||||
v-slot="{ selected }"
|
||||
:style="{ ...rowGridStyle, display: isEditMode ? 'grid' : 'none' }"
|
||||
attribute="turnIdx"
|
||||
:disabled="!isEditMode"
|
||||
@dragStart="isDragToggle = true"
|
||||
@dragDone="
|
||||
isDragToggle = false;
|
||||
toggleTurn(...$event);
|
||||
"
|
||||
>
|
||||
<div
|
||||
v-for="(turnObj, turnIdx) in reservedCommandList"
|
||||
:key="turnIdx"
|
||||
:turnIdx="turnIdx"
|
||||
class="idx_pad center d-grid"
|
||||
>
|
||||
<BButton
|
||||
size="sm"
|
||||
:variant="
|
||||
isDragToggle && selected.has(`${turnIdx}`)
|
||||
? 'light'
|
||||
: selectedTurnList.has(turnIdx)
|
||||
? 'info'
|
||||
: selectedTurnList.size == 0 && prevSelectedTurnList.has(turnIdx)
|
||||
? 'success'
|
||||
: 'primary'
|
||||
"
|
||||
>
|
||||
{{ turnIdx + 1 }}
|
||||
</BButton>
|
||||
</div>
|
||||
</DragSelect>
|
||||
<div :style="rowGridStyle">
|
||||
<div v-for="(turnObj, turnIdx) in reservedCommandList" :key="turnIdx" class="turn_pad center">
|
||||
<span v-b-tooltip.hover class="turn_text" :style="turnObj.style" :title="turnObj.tooltip">
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<span v-html="turnObj.brief" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div :style="{ ...rowGridStyle, display: isEditMode ? 'none' : 'grid' }">
|
||||
<div v-for="turnIdx in range(props.maxTurn)" :key="turnIdx" class="action_pad d-grid">
|
||||
<BButton
|
||||
:variant="turnIdx % 2 == 0 ? 'secondary' : 'dark'"
|
||||
size="sm"
|
||||
class="simple_action_btn bi bi-pencil"
|
||||
@click="toggleQuickReserveForm(turnIdx)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="position: relative">
|
||||
<CommandSelectForm
|
||||
ref="commandSelectForm"
|
||||
v-model:activatedCategory="activatedCategory"
|
||||
:commandList="commandList"
|
||||
class="bg-dark"
|
||||
:style="{ position: 'absolute', bottom: '0' }"
|
||||
@onClose="chooseCommand($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="isEditMode" class="row gx-0">
|
||||
<div class="col-5 col-md-6 d-grid">
|
||||
<BDropdown left variant="light" :style="{ color: 'black' }" text="선택한 턴을">
|
||||
<BDropdownItem @click="clipboardCut"> <i class="bi bi-scissors" /> 잘라내기 </BDropdownItem>
|
||||
<BDropdownItem @click="clipboardCopy"> <i class="bi bi-files" /> 복사하기 </BDropdownItem>
|
||||
<BDropdownItem @click="clipboardPaste">
|
||||
<i class="bi bi-clipboard-fill" /> 붙여넣기
|
||||
</BDropdownItem>
|
||||
<BDropdownDivider />
|
||||
<BDropdownItem @click="setStoredActions">
|
||||
<i class="bi bi-bookmark-plus-fill" /> 보관하기
|
||||
</BDropdownItem>
|
||||
<BDropdownItem @click="subRepeatCommand">
|
||||
<i class="bi bi-arrow-repeat" /> 반복하기
|
||||
</BDropdownItem>
|
||||
<BDropdownDivider />
|
||||
<BDropdownItem @click="eraseSelectedTurnList"> <i class="bi bi-eraser" /> 비우기 </BDropdownItem>
|
||||
<BDropdownItem @click="eraseAndPullCommand">
|
||||
<i class="bi bi-arrow-bar-up" /> 지우고 당기기
|
||||
</BDropdownItem>
|
||||
<BDropdownItem @click="pushEmptyCommand">
|
||||
<i class="bi bi-arrow-bar-down" /> 뒤로 밀기
|
||||
</BDropdownItem>
|
||||
<!-- 최근에 실행한 10턴 -->
|
||||
</BDropdown>
|
||||
</div>
|
||||
|
||||
<div class="col-7 col-md-6 d-grid">
|
||||
<BButton variant="info" @click="toggleForm($event)"> 명령 선택 ▾ </BButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import addMinutes from "date-fns/esm/addMinutes";
|
||||
import { stringifyUrl } from "query-string";
|
||||
import { onMounted, ref, watch, type PropType, inject } from "vue";
|
||||
import { formatTime } from "@util/formatTime";
|
||||
import { joinYearMonth } from "@util/joinYearMonth";
|
||||
import { mb_strwidth } from "@util/mb_strwidth";
|
||||
import { parseTime } from "@util/parseTime";
|
||||
import { parseYearMonth } from "@util/parseYearMonth";
|
||||
import { convertSearch초성 } from "@util/convertSearch초성";
|
||||
import VueTypes from "vue-types";
|
||||
import DragSelect from "@/components/DragSelect.vue";
|
||||
import { isString, range, trim } from "lodash";
|
||||
import { SammoAPI } from "@/SammoAPI";
|
||||
import type { CommandItem, TurnObj } from "@/defs";
|
||||
import { QueryActionHelper } from "@/util/QueryActionHelper";
|
||||
import type { Args } from "@/processing/args";
|
||||
import type { StoredActionsHelper } from "@/util/StoredActionsHelper";
|
||||
import { getNpcColor } from "@/common_legacy";
|
||||
import { BButton, BDropdownItem, BDropdownText, BButtonGroup, BDropdownDivider, BDropdown } from "bootstrap-vue-3";
|
||||
import CommandSelectForm from "@/components/CommandSelectForm.vue";
|
||||
import SimpleClock from "@/components/SimpleClock.vue";
|
||||
import type { ChiefResponse } from "@/defs/API/NationCommand";
|
||||
|
||||
type TurnObjWithTime = TurnObj & {
|
||||
time: string;
|
||||
year?: number;
|
||||
month?: number;
|
||||
tooltip?: string;
|
||||
style?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const props = defineProps({
|
||||
maxTurn: VueTypes.integer.isRequired,
|
||||
maxPushTurn: VueTypes.integer.isRequired,
|
||||
date: VueTypes.string.isRequired,
|
||||
year: VueTypes.integer.isRequired,
|
||||
month: VueTypes.integer.isRequired,
|
||||
turnTerm: VueTypes.integer.isRequired,
|
||||
turnTime: VueTypes.string.isRequired,
|
||||
targetIsMe: VueTypes.bool.isRequired,
|
||||
|
||||
selectedTurn: {
|
||||
type: Object as PropType<Set<number>>,
|
||||
required: false,
|
||||
default: () => new Set(),
|
||||
},
|
||||
turn: {
|
||||
type: Array as PropType<TurnObj[]>,
|
||||
required: true,
|
||||
},
|
||||
commandList: {
|
||||
type: Object as PropType<ChiefResponse["commandList"]>,
|
||||
required: true,
|
||||
},
|
||||
|
||||
officer: {
|
||||
type: Object as PropType<ChiefResponse["chiefList"][0]>,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const basicModeRowHeight = 30;
|
||||
|
||||
const listReqArgCommand = new Set<string>();
|
||||
for (const commandCategories of props.commandList) {
|
||||
if (!commandCategories.values) {
|
||||
continue;
|
||||
}
|
||||
for (const commandObj of commandCategories.values) {
|
||||
if (!commandObj.reqArg) {
|
||||
continue;
|
||||
}
|
||||
listReqArgCommand.add(commandObj.value);
|
||||
}
|
||||
}
|
||||
|
||||
const selectedCommand = ref(props.commandList[0].values[0]);
|
||||
for (const subCategory of props.commandList) {
|
||||
for (const command of subCategory.values) {
|
||||
if (command.searchText) {
|
||||
continue;
|
||||
}
|
||||
command.searchText = convertSearch초성(command.simpleName).join("|");
|
||||
}
|
||||
}
|
||||
|
||||
const invCommandMap: Record<string, CommandItem> = {};
|
||||
for (const category of props.commandList) {
|
||||
for (const command of category.values) {
|
||||
invCommandMap[command.value] = command;
|
||||
}
|
||||
}
|
||||
|
||||
const rowGridStyle = ref({
|
||||
display: "grid",
|
||||
gridTemplateRows: `repeat(${props.maxTurn}, 30px)`,
|
||||
});
|
||||
|
||||
const updated = ref(false);
|
||||
const isDragSingle = ref(false);
|
||||
const isDragToggle = ref(false);
|
||||
const autorun_limit = ref<number | null>(null);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "raise-reload"): void;
|
||||
(event: "update:selectedTurn", value: Set<number>): void;
|
||||
}>();
|
||||
|
||||
function triggerUpdateCommandList(type?: string) {
|
||||
console.log("try update", type);
|
||||
updated.value = false;
|
||||
setTimeout(() => {
|
||||
updateCommandList();
|
||||
}, 1);
|
||||
}
|
||||
|
||||
function toggleTurn(...reqTurnList: number[] | string[]) {
|
||||
for (let turnIdx of reqTurnList) {
|
||||
if (isString(turnIdx)) {
|
||||
turnIdx = parseInt(turnIdx);
|
||||
}
|
||||
if (selectedTurnList.value.has(turnIdx)) {
|
||||
selectedTurnList.value.delete(turnIdx);
|
||||
} else {
|
||||
selectedTurnList.value.add(turnIdx);
|
||||
}
|
||||
}
|
||||
emit("update:selectedTurn", selectedTurnList.value);
|
||||
}
|
||||
|
||||
function isDropdownChildren(e?: Event): boolean {
|
||||
if (!e) {
|
||||
return false;
|
||||
}
|
||||
if (!e.target) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
(e.target as HTMLElement).classList.contains("dropdown-item") ||
|
||||
(e.target as HTMLElement).classList.contains("dropdown-toggle-split") ||
|
||||
(e.target as HTMLElement).classList.contains("ignoreMe")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function repeatNationCommand(amount: number) {
|
||||
try {
|
||||
await SammoAPI.NationCommand.RepeatCommand({ amount });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert(`실패했습니다: ${e}`);
|
||||
return;
|
||||
}
|
||||
emit("raise-reload");
|
||||
}
|
||||
|
||||
function pushNationCommandSingle(e: Event) {
|
||||
//NOTE: split 구현에 버그가 있어서, 수동으로 구분해야함
|
||||
if (isDropdownChildren(e)) {
|
||||
return;
|
||||
}
|
||||
void pushNationCommand(1);
|
||||
}
|
||||
|
||||
function pullNationCommandSingle(e: Event) {
|
||||
//NOTE: split 구현에 버그가 있어서, 수동으로 구분해야함
|
||||
if (isDropdownChildren(e)) {
|
||||
return;
|
||||
}
|
||||
void pushNationCommand(-1);
|
||||
}
|
||||
|
||||
async function pushNationCommand(amount: number) {
|
||||
try {
|
||||
await SammoAPI.NationCommand.PushCommand({ amount });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert(`실패했습니다: ${e}`);
|
||||
return;
|
||||
}
|
||||
emit("raise-reload");
|
||||
}
|
||||
|
||||
const queryActionHelper = new QueryActionHelper(props.maxTurn);
|
||||
const reservedCommandList = queryActionHelper.reservedCommandList;
|
||||
const prevSelectedTurnList = queryActionHelper.prevSelectedTurnList;
|
||||
const selectedTurnList = queryActionHelper.selectedTurnList;
|
||||
|
||||
async function reserveCommandDirect(args: [number[], TurnObj][], reload = true): Promise<boolean> {
|
||||
const query: {
|
||||
turnList: number[];
|
||||
action: string;
|
||||
arg: Args;
|
||||
}[] = [];
|
||||
for (const [turnList, { action, arg }] of args) {
|
||||
query.push({
|
||||
turnList,
|
||||
action,
|
||||
arg,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await SammoAPI.NationCommand.ReserveBulkCommand(query);
|
||||
queryActionHelper.releaseSelectedTurnList();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert(`실패했습니다: ${e}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (reload) {
|
||||
emit("raise-reload");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function updateCommandList() {
|
||||
if (updated.value) {
|
||||
return;
|
||||
}
|
||||
console.log("do update!");
|
||||
const _reservedCommandList: TurnObjWithTime[] = [];
|
||||
let yearMonth = joinYearMonth(props.year, props.month);
|
||||
|
||||
const turnTime = parseTime(props.turnTime);
|
||||
let nextTurnTime = new Date(turnTime);
|
||||
|
||||
const autorunLimitYearMonth = autorun_limit.value ?? yearMonth - 1;
|
||||
const [autorunLimitYear, autorunLimitMonth] = parseYearMonth(autorunLimitYearMonth);
|
||||
|
||||
for (const obj of props.turn) {
|
||||
const [year, month] = parseYearMonth(yearMonth);
|
||||
let tooltip: string[] = [];
|
||||
let style: Record<string, unknown> = {};
|
||||
|
||||
const brief = obj.brief;
|
||||
|
||||
if (yearMonth <= autorunLimitYearMonth) {
|
||||
if (obj.brief == "휴식") {
|
||||
obj.brief = "휴식<small>(자율 행동)</small>";
|
||||
}
|
||||
style.color = "#aaffff";
|
||||
|
||||
tooltip.push(`자율 행동 기간: ${autorunLimitYear}년 ${autorunLimitMonth}월까지`);
|
||||
}
|
||||
|
||||
if (mb_strwidth(brief) > 22) {
|
||||
tooltip.push(brief);
|
||||
}
|
||||
|
||||
_reservedCommandList.push({
|
||||
...obj,
|
||||
year,
|
||||
month,
|
||||
time: formatTime(nextTurnTime, props.turnTerm >= 5 ? "HH:mm" : "mm:ss"),
|
||||
tooltip: tooltip.length == 0 ? undefined : tooltip.join("\n"),
|
||||
style,
|
||||
});
|
||||
|
||||
yearMonth += 1;
|
||||
nextTurnTime = addMinutes(nextTurnTime, props.turnTerm);
|
||||
}
|
||||
reservedCommandList.value = _reservedCommandList;
|
||||
updated.value = true;
|
||||
}
|
||||
|
||||
async function reserveCommand() {
|
||||
const reqTurnList = queryActionHelper.getSelectedTurnList();
|
||||
const commandName = selectedCommand.value.value;
|
||||
|
||||
if (listReqArgCommand.has(commandName)) {
|
||||
document.location.href = stringifyUrl({
|
||||
url: "v_processing.php",
|
||||
query: {
|
||||
command: commandName,
|
||||
turnList: reqTurnList.join("_"),
|
||||
is_chief: true,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await SammoAPI.NationCommand.ReserveCommand({
|
||||
turnList: reqTurnList,
|
||||
action: commandName,
|
||||
});
|
||||
|
||||
storedActionsHelper.pushRecentActions({
|
||||
action: commandName,
|
||||
brief: result.brief,
|
||||
arg: {},
|
||||
});
|
||||
|
||||
queryActionHelper.releaseSelectedTurnList();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert(`실패했습니다: ${e}`);
|
||||
return;
|
||||
}
|
||||
emit("raise-reload");
|
||||
}
|
||||
|
||||
function chooseCommand(val?: string) {
|
||||
if (val === undefined) {
|
||||
return;
|
||||
}
|
||||
selectedCommand.value = invCommandMap[val];
|
||||
void reserveCommand();
|
||||
}
|
||||
|
||||
const emptyTurnObj: TurnObj = { action: "휴식", brief: "휴식", arg: {} };
|
||||
|
||||
const storedActionsHelper = inject("storedNationActionsHelper") as StoredActionsHelper;
|
||||
|
||||
const recentActions = storedActionsHelper.recentActions;
|
||||
const storedActions = storedActionsHelper.storedActions;
|
||||
const activatedCategory = storedActionsHelper.activatedCategory;
|
||||
|
||||
async function eraseSelectedTurnList(releaseSelect = true): Promise<boolean> {
|
||||
const result = await reserveCommandDirect([[queryActionHelper.getSelectedTurnList(), emptyTurnObj]]);
|
||||
if (releaseSelect) {
|
||||
queryActionHelper.releaseSelectedTurnList();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const clipboard = storedActionsHelper.clipboard;
|
||||
|
||||
async function clipboardCut(releaseSelect = true) {
|
||||
clipboardCopy(false);
|
||||
return eraseSelectedTurnList(releaseSelect);
|
||||
}
|
||||
|
||||
function clipboardCopy(releaseSelect = true) {
|
||||
clipboard.value = queryActionHelper.extractQueryActions();
|
||||
if (releaseSelect) {
|
||||
queryActionHelper.releaseSelectedTurnList();
|
||||
}
|
||||
}
|
||||
|
||||
async function clipboardPaste(releaseSelect = true) {
|
||||
const rawActions = clipboard.value;
|
||||
if (rawActions === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actions = queryActionHelper.amplifyQueryActions(rawActions, queryActionHelper.getSelectedTurnList());
|
||||
if (actions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await reserveCommandDirect(actions);
|
||||
if (releaseSelect) {
|
||||
queryActionHelper.releaseSelectedTurnList();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function subRepeatCommand(releaseSelect = true): Promise<boolean> {
|
||||
const reqTurnList = queryActionHelper.getSelectedTurnList();
|
||||
const selectedMinTurnIdx = reqTurnList[0];
|
||||
const selectedMaxTurnIdx = reqTurnList[reqTurnList.length - 1];
|
||||
const queryLength = selectedMaxTurnIdx - selectedMinTurnIdx + 1;
|
||||
|
||||
const rawActions = queryActionHelper.extractQueryActions();
|
||||
const actions = queryActionHelper.amplifyQueryActions(
|
||||
rawActions,
|
||||
range(selectedMinTurnIdx, props.maxTurn, queryLength)
|
||||
);
|
||||
|
||||
const result = await reserveCommandDirect(actions);
|
||||
if (releaseSelect) {
|
||||
queryActionHelper.releaseSelectedTurnList();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function eraseAndPullCommand(releaseSelect = true): Promise<boolean> {
|
||||
const reqTurnList = queryActionHelper.getSelectedTurnList();
|
||||
const selectedMinTurnIdx = reqTurnList[0];
|
||||
const selectedMaxTurnIdx = reqTurnList[reqTurnList.length - 1];
|
||||
const queryLength = selectedMaxTurnIdx - selectedMinTurnIdx + 1;
|
||||
|
||||
if (selectedMinTurnIdx === 0) {
|
||||
await pushNationCommand(-queryLength);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (selectedMinTurnIdx + queryLength == props.maxTurn) {
|
||||
return eraseSelectedTurnList(releaseSelect);
|
||||
}
|
||||
|
||||
const actions: [number[], TurnObj][] = [];
|
||||
|
||||
const emptyTurnList: number[] = [];
|
||||
|
||||
for (const srcTurnIdx of range(selectedMinTurnIdx + queryLength, props.maxTurn)) {
|
||||
const rawAction = reservedCommandList.value[srcTurnIdx];
|
||||
if (rawAction.action == emptyTurnObj.action) {
|
||||
emptyTurnList.push(srcTurnIdx - queryLength);
|
||||
continue;
|
||||
}
|
||||
actions.push([
|
||||
[srcTurnIdx - queryLength],
|
||||
{
|
||||
action: rawAction.action,
|
||||
arg: rawAction.arg,
|
||||
brief: rawAction.brief,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
emptyTurnList.push(...range(props.maxTurn - queryLength, props.maxTurn));
|
||||
actions.push([emptyTurnList, emptyTurnObj]);
|
||||
|
||||
const result = await reserveCommandDirect(actions);
|
||||
if (releaseSelect) {
|
||||
queryActionHelper.releaseSelectedTurnList();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function pushEmptyCommand(releaseSelect = true): Promise<boolean> {
|
||||
const reqTurnList = queryActionHelper.getSelectedTurnList();
|
||||
const selectedMinTurnIdx = reqTurnList[0];
|
||||
const selectedMaxTurnIdx = reqTurnList[reqTurnList.length - 1];
|
||||
const queryLength = selectedMaxTurnIdx - selectedMinTurnIdx + 1;
|
||||
|
||||
if (selectedMinTurnIdx === 0) {
|
||||
await pushNationCommand(queryLength);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (selectedMaxTurnIdx == props.maxTurn) {
|
||||
return eraseSelectedTurnList(releaseSelect);
|
||||
}
|
||||
|
||||
const actions: [number[], TurnObj][] = [];
|
||||
|
||||
const emptyTurnList: number[] = [];
|
||||
|
||||
for (const srcTurnIdx of range(selectedMinTurnIdx, props.maxTurn - queryLength)) {
|
||||
const rawAction = reservedCommandList.value[srcTurnIdx];
|
||||
if (rawAction.action == emptyTurnObj.action) {
|
||||
emptyTurnList.push(srcTurnIdx + queryLength);
|
||||
continue;
|
||||
}
|
||||
actions.push([
|
||||
[srcTurnIdx + queryLength],
|
||||
{
|
||||
action: rawAction.action,
|
||||
arg: rawAction.arg,
|
||||
brief: rawAction.brief,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
emptyTurnList.push(...range(selectedMinTurnIdx, selectedMinTurnIdx + queryLength));
|
||||
actions.push([emptyTurnList, emptyTurnObj]);
|
||||
|
||||
const result = await reserveCommandDirect(actions);
|
||||
if (releaseSelect) {
|
||||
queryActionHelper.releaseSelectedTurnList();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function setStoredActions() {
|
||||
const actions = queryActionHelper.extractQueryActions();
|
||||
const turnBrief = new Map<number, string>();
|
||||
for (const [subTurnList, action] of actions) {
|
||||
const actionName = action.action.split("_");
|
||||
const actionShortName = actionName.length == 1 ? actionName[0] : actionName[1];
|
||||
for (const turnIdx of subTurnList) {
|
||||
turnBrief.set(turnIdx, actionShortName[0]);
|
||||
}
|
||||
}
|
||||
|
||||
const turnBriefStr = Array.from(turnBrief.entries())
|
||||
.sort(([turnA], [turnB]) => turnA - turnB)
|
||||
.map(([, action]) => action)
|
||||
.join("");
|
||||
|
||||
const nickName = trim(prompt("선택한 턴들의 별명을 지어주세요", turnBriefStr) ?? "");
|
||||
if (nickName == "") {
|
||||
return;
|
||||
}
|
||||
|
||||
storedActionsHelper.setStoredActions(nickName, actions);
|
||||
queryActionHelper.releaseSelectedTurnList();
|
||||
}
|
||||
|
||||
function deleteStoredActions(actionKey: string) {
|
||||
storedActionsHelper.deleteStoredActions(actionKey);
|
||||
}
|
||||
|
||||
async function useStoredAction(rawActions: [number[], TurnObj][]) {
|
||||
const reqTurnList = queryActionHelper.getSelectedTurnList();
|
||||
const actions = queryActionHelper.amplifyQueryActions(rawActions, reqTurnList);
|
||||
const result = await reserveCommandDirect(actions);
|
||||
queryActionHelper.releaseSelectedTurnList();
|
||||
return result;
|
||||
}
|
||||
|
||||
function getQueryActionHelper(): QueryActionHelper {
|
||||
return queryActionHelper;
|
||||
}
|
||||
|
||||
function getStoredActionHeler(): StoredActionsHelper {
|
||||
return storedActionsHelper;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
useStoredAction,
|
||||
deleteStoredActions,
|
||||
clipboardCut,
|
||||
clipboardCopy,
|
||||
clipboardPaste,
|
||||
getQueryActionHelper,
|
||||
getStoredActionHeler,
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.date,
|
||||
() => {
|
||||
triggerUpdateCommandList("date");
|
||||
}
|
||||
);
|
||||
watch(
|
||||
() => props.year,
|
||||
() => {
|
||||
triggerUpdateCommandList("year");
|
||||
}
|
||||
);
|
||||
watch(
|
||||
() => props.month,
|
||||
() => {
|
||||
triggerUpdateCommandList("month");
|
||||
}
|
||||
);
|
||||
watch(
|
||||
() => props.turnTime,
|
||||
() => {
|
||||
triggerUpdateCommandList("turnTime");
|
||||
}
|
||||
);
|
||||
watch(
|
||||
() => props.commandList,
|
||||
() => {
|
||||
triggerUpdateCommandList("commandList");
|
||||
}
|
||||
);
|
||||
watch(
|
||||
() => props.selectedTurn,
|
||||
(val: Set<number>) => {
|
||||
console.log(val);
|
||||
if (val === selectedTurnList.value) {
|
||||
console.log("pass!");
|
||||
return;
|
||||
}
|
||||
selectedTurnList.value.clear();
|
||||
for (const t of val.values()) {
|
||||
selectedTurnList.value.add(t);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(selectedTurnList, () => {
|
||||
console.log(selectedTurnList.value);
|
||||
emit("update:selectedTurn", selectedTurnList.value);
|
||||
});
|
||||
|
||||
const commandQuickReserveForm = ref<InstanceType<typeof CommandSelectForm> | null>(null);
|
||||
const commandSelectForm = ref<InstanceType<typeof CommandSelectForm> | null>(null);
|
||||
|
||||
const currentQuickReserveTarget = ref(-1);
|
||||
function chooseQuickReserveCommand(val?: string) {
|
||||
if (!val) {
|
||||
return;
|
||||
}
|
||||
selectedCommand.value = invCommandMap[val];
|
||||
selectedTurnList.value.clear();
|
||||
selectedTurnList.value.add(currentQuickReserveTarget.value);
|
||||
void reserveCommand();
|
||||
}
|
||||
|
||||
function toggleQuickReserveForm(turnIdx: number) {
|
||||
if (turnIdx == currentQuickReserveTarget.value) {
|
||||
commandQuickReserveForm.value?.toggle();
|
||||
return;
|
||||
}
|
||||
currentQuickReserveTarget.value = turnIdx;
|
||||
commandQuickReserveForm.value?.show();
|
||||
}
|
||||
|
||||
const isEditMode = storedActionsHelper.isEditMode;
|
||||
watch(isEditMode, (newEditMode) => {
|
||||
if (newEditMode) {
|
||||
commandQuickReserveForm.value?.close();
|
||||
currentQuickReserveTarget.value = -1;
|
||||
} else {
|
||||
commandSelectForm.value?.close();
|
||||
}
|
||||
});
|
||||
function toggleForm($event: Event): void {
|
||||
$event.preventDefault();
|
||||
|
||||
const form = commandSelectForm.value;
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
form.toggle();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateCommandList();
|
||||
});
|
||||
</script>
|
||||
<style lang="scss">
|
||||
@import "@scss/common/break_500px.scss";
|
||||
@import "@scss/common/variables.scss";
|
||||
@import "@scss/common/bootswatch_custom_variables.scss";
|
||||
|
||||
.chiefReservedCommand {
|
||||
background-color: $gray-900;
|
||||
|
||||
.commandTable.editMode {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(39.67px, 1fr) minmax(28px, 1fr) 5fr;
|
||||
//30, 70, 37.65, 160
|
||||
}
|
||||
|
||||
.commandTable.singleMode {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(39.67px, 1fr) 5fr minmax(28px, 1fr);
|
||||
//30, 70, 160, 37.65
|
||||
}
|
||||
|
||||
@include media-1000px {
|
||||
.turn_pad {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.multiselect__content-wrapper {
|
||||
margin-left: calc(-100% / 7 * 2);
|
||||
width: calc(100% / 7 * 12);
|
||||
}
|
||||
|
||||
.multiselect__single {
|
||||
display: inline-block;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@include media-500px {
|
||||
.dropdown-item {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.multiselect__content-wrapper {
|
||||
margin-left: calc(-100% / 7 * 2);
|
||||
width: calc(100% / 7 * 12);
|
||||
}
|
||||
|
||||
.commandPad {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.btn {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.month_pad,
|
||||
.time_pad,
|
||||
.turn_pad {
|
||||
padding: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.month_pad:hover {
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.month_pad,
|
||||
.time_pad,
|
||||
.turn_pad {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.turn_pad {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.turn_pad .turn_text {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,218 @@
|
||||
<template>
|
||||
<div v-if="showForm" class="my-1">
|
||||
<div class="commandCategory row gx-0 gy-1">
|
||||
<div
|
||||
v-for="[categoryKey, { deco: categoryDeco }] of commandList"
|
||||
:key="categoryKey"
|
||||
class="categoryItem col-4 d-grid"
|
||||
>
|
||||
<BButton variant="success" :active="chosenCategory == categoryKey" @click="chosenCategory = categoryKey">
|
||||
{{ categoryDeco.altName ?? categoryDeco.name }}
|
||||
</BButton>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="commandList my-1"
|
||||
:style="{
|
||||
display: 'grid',
|
||||
alignItems: 'self-start',
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-for="[category, { values }] of commandList"
|
||||
:key="category"
|
||||
class="row gx-1 gy-1"
|
||||
:style="{ visibility: category == chosenCategory ? 'visible' : 'hidden', gridRow: '1', gridColumn: '1' }"
|
||||
>
|
||||
<div
|
||||
v-for="commandItem of values"
|
||||
:key="commandItem.value"
|
||||
class="col-6 d-grid"
|
||||
@click="close(commandItem.value)"
|
||||
>
|
||||
<div class="commandItem">
|
||||
<div class="commandBody">
|
||||
<p :class="['center', 'my-0', commandItem.possible ? '' : 'commandImpossible']">
|
||||
{{ commandItem.simpleName }}
|
||||
<span v-if="commandItem.compensation > 0" class="compensatePositive">▲</span>
|
||||
<span v-else-if="commandItem.compensation < 0" class="compensateNegative">▼</span>
|
||||
</p>
|
||||
<small class="center" :style="{ display: 'block' }">
|
||||
{{
|
||||
commandItem.title.startsWith(commandItem.simpleName)
|
||||
? commandItem.title.substring(commandItem.simpleName.length)
|
||||
: commandItem.title
|
||||
}}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!hideClose" class="commandBottom row mt-1 mb-1">
|
||||
<div class="offset-8 col-4 d-grid">
|
||||
<BButton @click="close()"> 닫기 </BButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { CommandItem } from "@/defs";
|
||||
import { BButton } from "bootstrap-vue-3";
|
||||
import { ref, type PropType, watch, onMounted } from "vue";
|
||||
|
||||
interface CategoryDecoration {
|
||||
name: string;
|
||||
altName?: string;
|
||||
//icon?: string,
|
||||
//color?: string,
|
||||
//backgroundColor?: string,
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
categoryInfo: {
|
||||
type: Object as PropType<Record<string, Omit<CategoryDecoration, "name">>>,
|
||||
default: () => {
|
||||
return {};
|
||||
},
|
||||
required: false,
|
||||
},
|
||||
commandList: {
|
||||
type: Object as PropType<
|
||||
{
|
||||
category: string;
|
||||
values: CommandItem[];
|
||||
}[]
|
||||
>,
|
||||
required: true,
|
||||
},
|
||||
anchor: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ".commandSelectFormAnchor",
|
||||
},
|
||||
hideClose: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
activatedCategory: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
const chosenCategory = ref<string>("-");
|
||||
const chosenSubList = ref<CommandItem[]>([]);
|
||||
|
||||
const categories = new Set(props.commandList.map(({ category }) => category));
|
||||
|
||||
watch(
|
||||
() => props.activatedCategory,
|
||||
(newValue) => {
|
||||
chosenCategory.value = newValue;
|
||||
}
|
||||
);
|
||||
|
||||
const showForm = ref(false);
|
||||
|
||||
function convCategoryDeco(category: string): CategoryDecoration {
|
||||
const itemInfo = props.categoryInfo?.[category];
|
||||
if (!itemInfo) {
|
||||
return {
|
||||
name: category,
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: category,
|
||||
...itemInfo,
|
||||
};
|
||||
}
|
||||
|
||||
const commandList = ref(
|
||||
new Map<
|
||||
string,
|
||||
{
|
||||
deco: CategoryDecoration;
|
||||
values: CommandItem[];
|
||||
}
|
||||
>()
|
||||
);
|
||||
|
||||
function updateCommandList(rawCommandList: typeof props.commandList) {
|
||||
commandList.value.clear();
|
||||
for (const { category, values } of rawCommandList) {
|
||||
commandList.value.set(category, {
|
||||
deco: convCategoryDeco(category),
|
||||
values,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.commandList, updateCommandList);
|
||||
updateCommandList(props.commandList);
|
||||
|
||||
watch(chosenCategory, (category) => {
|
||||
const itemInfo = commandList.value?.get(category);
|
||||
if (itemInfo === undefined) {
|
||||
console.error(`category 없음: ${category}`);
|
||||
return;
|
||||
}
|
||||
chosenSubList.value = itemInfo.values;
|
||||
if (props.activatedCategory !== category) {
|
||||
emits("update:activatedCategory", category);
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (!categories.has(props.activatedCategory)) {
|
||||
chosenCategory.value = props.commandList[0].category;
|
||||
} else {
|
||||
chosenCategory.value = props.activatedCategory;
|
||||
}
|
||||
});
|
||||
|
||||
function show(): void {
|
||||
showForm.value = true;
|
||||
}
|
||||
|
||||
function toggle(): void {
|
||||
showForm.value = !showForm.value;
|
||||
if (showForm.value === false) {
|
||||
emits("onClose");
|
||||
}
|
||||
}
|
||||
|
||||
function close(category?: string): void {
|
||||
showForm.value = false;
|
||||
emits("onClose", category);
|
||||
}
|
||||
|
||||
const emits = defineEmits<{
|
||||
(event: "onClose", command?: string): void;
|
||||
(event: "update:activatedCategory", category: string): void;
|
||||
}>();
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
close,
|
||||
toggle,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.commandItem {
|
||||
border: gray 1px solid;
|
||||
border-radius: 0.5em;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
padding: 0.1em;
|
||||
margin: 0;
|
||||
min-height: 2.8em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,221 @@
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
:style="{
|
||||
position: 'relative',
|
||||
userSelect: disabled ? undefined : 'none',
|
||||
overflow: 'hidden',
|
||||
touchAction: disabled ? undefined : 'none',
|
||||
}"
|
||||
:class="{ disabledDrag: disabled }"
|
||||
>
|
||||
<slot :selected="intersected" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
/// https://github.com/andi23rosca/drag-select-vue/blob/master/src/DragSelect.vue
|
||||
import { defineComponent, ref, watch, onMounted, onBeforeUnmount, type PropType } from "vue";
|
||||
import VueTypes from "vue-types";
|
||||
|
||||
function getDimensions(p1: coord, p2: coord): rect {
|
||||
return {
|
||||
width: Math.abs(p1.x - p2.x),
|
||||
height: Math.abs(p1.y - p2.y),
|
||||
};
|
||||
}
|
||||
function collisionCheck(node1: DOMRect, node2: DOMRect): boolean {
|
||||
return (
|
||||
node1.left < node2.left + node2.width &&
|
||||
node1.left + node1.width > node2.left &&
|
||||
node1.top < node2.top + node2.height &&
|
||||
node1.top + node1.height > node2.top
|
||||
);
|
||||
}
|
||||
|
||||
type coord = { x: number; y: number };
|
||||
type rect = { width: number; height: number };
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
attribute: VueTypes.string.isRequired,
|
||||
color: VueTypes.string.def("#4299E1"),
|
||||
opacity: VueTypes.number.def(0.7),
|
||||
modelValue: {
|
||||
type: Object as PropType<Set<string>>,
|
||||
required: false,
|
||||
default: () => ref(new Set()),
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue", "dragDone", "dragStart"],
|
||||
setup(props, { emit }) {
|
||||
const intersected = ref<Set<string>>(props.modelValue);
|
||||
const container = ref<HTMLElement>();
|
||||
|
||||
watch(intersected, (val) => {
|
||||
emit("update:modelValue", val);
|
||||
});
|
||||
watch(props.modelValue, (val) => {
|
||||
if (intersected.value === val) {
|
||||
return;
|
||||
}
|
||||
intersected.value = val;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (!container.value) {
|
||||
console.error(`Container is not referenced.`);
|
||||
return;
|
||||
}
|
||||
const uContainer = container.value;
|
||||
let containerRect = uContainer.getBoundingClientRect();
|
||||
|
||||
function getCoords(e: MouseEvent | Touch): coord {
|
||||
return {
|
||||
x: e.clientX - containerRect.left,
|
||||
y: e.clientY - containerRect.top,
|
||||
};
|
||||
}
|
||||
let children: HTMLCollection;
|
||||
let box = document.createElement("div");
|
||||
box.setAttribute("data-drag-box-component", "");
|
||||
box.style.position = "absolute";
|
||||
box.style.backgroundColor = props.color;
|
||||
box.style.opacity = `${props.opacity}`;
|
||||
let start = { x: 0, y: 0 };
|
||||
let end = { x: 0, y: 0 };
|
||||
function intersection() {
|
||||
const rect = box.getBoundingClientRect();
|
||||
const localIntersected = new Set<string>();
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
if (collisionCheck(rect, children[i].getBoundingClientRect())) {
|
||||
const attr = children[i].getAttribute(props.attribute);
|
||||
if (children[i].hasAttribute(props.attribute)) {
|
||||
localIntersected.add(attr as string);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let dismatch = false;
|
||||
for (const oldVal of intersected.value) {
|
||||
if (!localIntersected.has(oldVal)) {
|
||||
dismatch = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!dismatch) {
|
||||
for (const newVal of localIntersected) {
|
||||
if (!intersected.value.has(newVal)) {
|
||||
dismatch = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (dismatch) {
|
||||
intersected.value = localIntersected;
|
||||
}
|
||||
}
|
||||
function touchStart(e: TouchEvent) {
|
||||
e.preventDefault();
|
||||
startDrag(e.touches[0]);
|
||||
}
|
||||
function touchMove(e: TouchEvent) {
|
||||
e.preventDefault();
|
||||
drag(e.touches[0]);
|
||||
}
|
||||
|
||||
let isMine = false;
|
||||
function startDrag(e: MouseEvent | Touch) {
|
||||
if (props.disabled) {
|
||||
return;
|
||||
}
|
||||
containerRect = uContainer.getBoundingClientRect();
|
||||
children = uContainer.children;
|
||||
start = getCoords(e);
|
||||
end = start;
|
||||
document.addEventListener("mousemove", drag);
|
||||
document.addEventListener("touchmove", touchMove);
|
||||
box.style.top = start.y + "px";
|
||||
box.style.left = start.x + "px";
|
||||
uContainer.append(box);
|
||||
intersection();
|
||||
isMine = true;
|
||||
emit("dragStart");
|
||||
}
|
||||
function drag(e: MouseEvent | Touch) {
|
||||
if (props.disabled) {
|
||||
return;
|
||||
}
|
||||
end = getCoords(e);
|
||||
const dimensions = getDimensions(start, end);
|
||||
if (end.x < start.x) {
|
||||
box.style.left = end.x + "px";
|
||||
}
|
||||
if (end.y < start.y) {
|
||||
box.style.top = end.y + "px";
|
||||
}
|
||||
box.style.width = dimensions.width + "px";
|
||||
box.style.height = dimensions.height + "px";
|
||||
intersection();
|
||||
}
|
||||
function endDrag() {
|
||||
if (props.disabled) {
|
||||
return;
|
||||
}
|
||||
start = { x: 0, y: 0 };
|
||||
end = { x: 0, y: 0 };
|
||||
box.style.width = "0";
|
||||
box.style.height = "0";
|
||||
document.removeEventListener("mousemove", drag);
|
||||
document.removeEventListener("touchmove", touchMove);
|
||||
box.remove();
|
||||
if (isMine) {
|
||||
emit("dragDone", intersected.value);
|
||||
}
|
||||
isMine = false;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.disabled,
|
||||
(disabledNext) => {
|
||||
if (disabledNext) {
|
||||
uContainer.removeEventListener("mousedown", startDrag);
|
||||
uContainer.removeEventListener("touchstart", touchStart);
|
||||
document.removeEventListener("mouseup", endDrag);
|
||||
document.removeEventListener("touchend", endDrag);
|
||||
} else {
|
||||
uContainer.addEventListener("mousedown", startDrag);
|
||||
uContainer.addEventListener("touchstart", touchStart);
|
||||
document.addEventListener("mouseup", endDrag);
|
||||
document.addEventListener("touchend", endDrag);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!props.disabled) {
|
||||
uContainer.addEventListener("mousedown", startDrag);
|
||||
uContainer.addEventListener("touchstart", touchStart);
|
||||
document.addEventListener("mouseup", endDrag);
|
||||
document.addEventListener("touchend", endDrag);
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
uContainer.removeEventListener("mousedown", startDrag);
|
||||
uContainer.removeEventListener("touchstart", touchStart);
|
||||
document.removeEventListener("mouseup", endDrag);
|
||||
document.removeEventListener("touchend", endDrag);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
intersected,
|
||||
container,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,266 @@
|
||||
<template>
|
||||
<div class="general-card-basic">
|
||||
<div
|
||||
class="general-icon"
|
||||
:style="{
|
||||
backgroundImage: `url(${iconPath})`,
|
||||
}"
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="general-name"
|
||||
:style="{
|
||||
color: isBrightColor(nation.color) ? '#000' : '#fff',
|
||||
backgroundColor: nation.color,
|
||||
}"
|
||||
>
|
||||
{{ general.name }} 【{{ general.officerLevelText }} | {{ generalTypeCall }} |
|
||||
<span :style="{ color: injuryInfo.color }">{{ injuryInfo.text }}</span>
|
||||
】 {{ general.turntime.substring(11, 19) }}
|
||||
</div>
|
||||
|
||||
<div>통솔</div>
|
||||
<div>
|
||||
<div class="row gx-0">
|
||||
<div class="col">
|
||||
<span :style="{ color: injuryInfo.color }">{{ general.leadership }}</span>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<span v-if="general.lbonus > 0" style="color: cyan">+{{ general.lbonus }}</span>
|
||||
</div>
|
||||
<div class="col">
|
||||
<SammoBar :height="10" :percent="general.leadership_exp / 20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>무력</div>
|
||||
<div>
|
||||
<div class="row gx-0">
|
||||
<div class="col" :style="{ color: injuryInfo.color }">
|
||||
{{ general.strength }}
|
||||
</div>
|
||||
<div class="col">
|
||||
<SammoBar :height="10" :percent="general.strength_exp / 20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>지력</div>
|
||||
<div>
|
||||
<div class="row gx-0">
|
||||
<div class="col" :style="{ color: injuryInfo.color }">
|
||||
{{ general.intel }}
|
||||
</div>
|
||||
<div class="col">
|
||||
<SammoBar :height="10" :percent="general.intel_exp / 20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>명마</div>
|
||||
<div v-b-tooltip.hover :title="horse.info ?? undefined">{{ horse.name }}</div>
|
||||
<div>무기</div>
|
||||
<div v-b-tooltip.hover :title="weapon.info ?? undefined">{{ weapon.name }}</div>
|
||||
<div>서적</div>
|
||||
<div v-b-tooltip.hover :title="book.info ?? undefined">{{ book.name }}</div>
|
||||
|
||||
<div>자금</div>
|
||||
<div>{{ general.gold.toLocaleString() }}</div>
|
||||
<div>군량</div>
|
||||
<div>{{ general.rice.toLocaleString() }}</div>
|
||||
<div>도구</div>
|
||||
<div v-b-tooltip.hover :title="item.info ?? undefined">{{ item.name }}</div>
|
||||
|
||||
<!-- TODO: show_img_level을 고려 -->
|
||||
<div
|
||||
class="general-crew-type-icon"
|
||||
:style="{
|
||||
backgroundImage: `url(${imagePath}/crewtype${general.crewtype}.png)`,
|
||||
}"
|
||||
></div>
|
||||
|
||||
<div>병종</div>
|
||||
<div v-b-tooltip.hover :title="crewtype.info ?? undefined">{{ crewtype.name }}</div>
|
||||
<div>병사</div>
|
||||
<div>{{ general.crew.toLocaleString() }}</div>
|
||||
<div>성격</div>
|
||||
<div v-b-tooltip.hover :title="personal.info ?? undefined">{{ personal.name }}</div>
|
||||
|
||||
<!-- TODO: bonusTrain 같은 개념이 필요 -->
|
||||
<div>훈련</div>
|
||||
<div>{{ general.train }}</div>
|
||||
<div>사기</div>
|
||||
<div>{{ general.atmos }}</div>
|
||||
<div>특기</div>
|
||||
<div>
|
||||
<span v-b-tooltip.hover :title="specialDomestic.info ?? undefined"> {{ specialDomestic.name }}</span> /
|
||||
<span v-b-tooltip.hover :title="specialWar.info ?? undefined"> {{ specialWar.name }}</span>
|
||||
</div>
|
||||
|
||||
<div>Lv</div>
|
||||
<!-- TODO: 경험치 막대가 필요 -->
|
||||
<div class="general-exp-level">
|
||||
{{ general.explevel }}
|
||||
</div>
|
||||
<div class="general-exp-level-bar">{{ nextExpLevelRemain(general.experience, general.explevel) }}</div>
|
||||
<div>연령</div>
|
||||
<div :style="{ color: ageColor }">{{ general.age }}세</div>
|
||||
|
||||
<div>수비</div>
|
||||
<div class="general-defence-train">
|
||||
<span v-if="general.defence_train === 999" style="color: red">수비 안함</span>
|
||||
<span v-else style="color: limegreen">수비 함(훈사{{ general.defence_train }})</span>
|
||||
</div>
|
||||
<div>삭턴</div>
|
||||
<div>{{ general.killturn }} 턴</div>
|
||||
<div>실행</div>
|
||||
<div>{{ nextExecuteMinute }}분 남음</div>
|
||||
|
||||
<div>부대</div>
|
||||
<div v-if="!troopInfo" class="general-troop">-</div>
|
||||
<div v-else class="general-troop">
|
||||
<s v-if="troopInfo.leader.reservedCommand[0]?.action != 'che_집합'" style="color: gray">
|
||||
{{ troopInfo.name }}
|
||||
</s>
|
||||
<span v-else style="color: orange">
|
||||
{{ troopInfo.name }}({{ gameConstStore.cityConst[troopInfo.leader.city].name }})
|
||||
</span>
|
||||
</div>
|
||||
<div>벌점</div>
|
||||
<div class="general-v">
|
||||
{{ formatConnectScore(general.connect) }} {{ general.connect.toLocaleString() }}점({{ general.con }})
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { GeneralListItemP1 } from "@/defs/API/Nation";
|
||||
import { computed, inject, onMounted, ref, toRefs, type Ref } from "vue";
|
||||
import { getIconPath } from "@/util/getIconPath";
|
||||
import { isBrightColor } from "@/util/isBrightColor";
|
||||
import { formatInjury } from "@/utilGame/formatInjury";
|
||||
import type { NationStaticItem } from "@/defs";
|
||||
import { unwrap } from "@/util/unwrap";
|
||||
import type { GameConstStore } from "@/GameConstStore";
|
||||
import { formatGeneralTypeCall } from "@/utilGame/formatGeneralTypeCall";
|
||||
import { nextExpLevelRemain } from "@/utilGame/nextExpLevelRemain";
|
||||
import { formatConnectScore } from "@/utilGame/formatConnectScore";
|
||||
import SammoBar from "@/components/SammoBar.vue";
|
||||
import { parseTime } from "@/util/parseTime";
|
||||
import { clamp } from "lodash";
|
||||
const imagePath = window.pathConfig.gameImage;
|
||||
const gameConstStore = unwrap(inject<Ref<GameConstStore>>("gameConstStore"));
|
||||
const props = defineProps<{
|
||||
general: GeneralListItemP1;
|
||||
troopInfo?: {
|
||||
leader: GeneralListItemP1;
|
||||
name: string;
|
||||
};
|
||||
nation: NationStaticItem;
|
||||
}>();
|
||||
|
||||
const { general, troopInfo, nation } = toRefs(props);
|
||||
const iconPath = computed(() => getIconPath(general.value.imgsvr, general.value.picture));
|
||||
const injuryInfo = computed(() => {
|
||||
const [text, color] = formatInjury(general.value.injury);
|
||||
return {
|
||||
text,
|
||||
color,
|
||||
};
|
||||
});
|
||||
const generalTypeCall = computed(() =>
|
||||
formatGeneralTypeCall(
|
||||
general.value.leadership,
|
||||
general.value.strength,
|
||||
general.value.intel,
|
||||
gameConstStore.value.gameConst
|
||||
)
|
||||
);
|
||||
|
||||
const horse = computed(
|
||||
() => gameConstStore.value.iActionInfo.item[general.value.horse] ?? { value: "None", name: "-" }
|
||||
);
|
||||
const weapon = computed(
|
||||
() => gameConstStore.value.iActionInfo.item[general.value.weapon] ?? { value: "None", name: "-" }
|
||||
);
|
||||
const book = computed(() => gameConstStore.value.iActionInfo.item[general.value.book] ?? { value: "None", name: "-" });
|
||||
const item = computed(() => gameConstStore.value.iActionInfo.item[general.value.item] ?? { value: "None", name: "-" });
|
||||
|
||||
const crewtype = computed(
|
||||
() => gameConstStore.value.iActionInfo.crewtype[general.value.crewtype] ?? { value: "None", name: "-" }
|
||||
);
|
||||
|
||||
const personal = computed(
|
||||
() => gameConstStore.value.iActionInfo.personality[general.value.personal] ?? { value: "None", name: "-" }
|
||||
);
|
||||
const specialDomestic = computed(
|
||||
() =>
|
||||
gameConstStore.value.iActionInfo.specialDomestic[general.value.specialDomestic] ?? {
|
||||
value: "None",
|
||||
name: `${general.value.specage}세`,
|
||||
}
|
||||
);
|
||||
const specialWar = computed(
|
||||
() =>
|
||||
gameConstStore.value.iActionInfo.specialWar[general.value.specialWar] ?? {
|
||||
value: "None",
|
||||
name: `${general.value.specage2}세`,
|
||||
}
|
||||
);
|
||||
|
||||
const ageColor = computed(() => {
|
||||
const age = general.value.age;
|
||||
const retirementYear = gameConstStore.value.gameConst.retirementYear;
|
||||
if (age < retirementYear * 0.75) {
|
||||
return "limegreen";
|
||||
}
|
||||
if (age < retirementYear) {
|
||||
return "yellow";
|
||||
}
|
||||
return "red";
|
||||
});
|
||||
|
||||
const nextExecuteMinute = ref(999);
|
||||
onMounted(() => {
|
||||
const now = new Date();
|
||||
const turnTime = parseTime(general.value.turntime);
|
||||
nextExecuteMinute.value = Math.floor(clamp(turnTime.getSeconds() - now.getSeconds() / 60, 0));
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.general-card-basic {
|
||||
display: grid;
|
||||
grid-template-columns: 64px repeat(3, 2fr 5fr);
|
||||
grid-template-rows: repeat(9, calc(64px / 3));
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
.general-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
grid-row: 1 / 4;
|
||||
}
|
||||
|
||||
.general-name {
|
||||
grid-row: 1 / 2;
|
||||
grid-column: 2 / 8;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.general-crew-type-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
grid-row: 4 / 7;
|
||||
}
|
||||
|
||||
.general-exp-level-bar {
|
||||
grid-column: 3 / 6;
|
||||
}
|
||||
|
||||
.general-defence-train {
|
||||
grid-column: 2 / 4;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,1243 @@
|
||||
<template>
|
||||
<Teleport v-if="toolbarID" :to="`#${toolbarID}`">
|
||||
<BButtonGroup class="d-flex general-list-toolbar">
|
||||
<BDropdown class="w-50" menuClass="view-mode-list" variant="primary" text="보기 모드">
|
||||
<BDropdownItem @click="setDisplaySetting([true, 'normal'], defaultDisplaySetting.normal)">기본</BDropdownItem>
|
||||
<BDropdownItem @click="setDisplaySetting([true, 'war'], defaultDisplaySetting.war)">전투</BDropdownItem>
|
||||
<BDropdownDivider />
|
||||
<BDropdownItem @click="storeDisplaySetting"><i class="bi bi-bookmark-plus-fill" /> 보관하기</BDropdownItem>
|
||||
<BDropdownDivider />
|
||||
<BDropdownItem
|
||||
v-for="[key, setting] of displaySettings.entries()"
|
||||
:key="key"
|
||||
@click="setDisplaySetting([false, key], setting)"
|
||||
><div class="row gx-0">
|
||||
<div class="col-9 text-wrap">
|
||||
<span class="align-middle">{{ key }}</span>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<div class="d-grid"><BButton size="sm" @click="deleteDisplaySetting(key)">삭제</BButton></div>
|
||||
</div>
|
||||
</div></BDropdownItem
|
||||
>
|
||||
</BDropdown>
|
||||
<!-- eslint-disable-next-line vue/max-attributes-per-line -->
|
||||
<BDropdown class="w-50" variant="info" text="열 선택" menuClass="column-menu" right>
|
||||
<template v-for="[colID, col, depth] of getColumnList()" :key="[colID, depth]">
|
||||
<BDropdownItem v-if="col instanceof ProvidedColumnGroup" disabled>
|
||||
<span :style="{ marginLeft: depth ? `${12 * depth}px` : undefined }">
|
||||
{{ col.getColGroupDef()?.headerName }}</span
|
||||
></BDropdownItem
|
||||
>
|
||||
<BDropdownItem v-else>
|
||||
<div :style="{ marginLeft: depth ? `${12 * depth}px` : undefined }" class="form-check" @click.stop="1">
|
||||
<input
|
||||
:id="`column-type-${colID}`"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
:checked="col.isVisible()"
|
||||
@change.stop="toggleColumn(colID, col)"
|
||||
/>
|
||||
<label
|
||||
class="form-check-label"
|
||||
:for="`column-type-${colID}`"
|
||||
:style="{
|
||||
textDecoration: validColumns.has(colID) ? undefined : 'line-through',
|
||||
}"
|
||||
>
|
||||
{{ col.getColDef().headerName }}
|
||||
</label>
|
||||
</div></BDropdownItem
|
||||
>
|
||||
</template>
|
||||
<BDropdownDivider />
|
||||
</BDropdown>
|
||||
</BButtonGroup>
|
||||
</Teleport>
|
||||
<div
|
||||
class="component-general-list"
|
||||
:style="{
|
||||
height: props.height === 'fill' ? '100%' : props.height === 'static' ? undefined : `${props.height}px`,
|
||||
}"
|
||||
>
|
||||
<AgGridVue
|
||||
style="width: 100%; height: 100%"
|
||||
class="ag-theme-balham-dark"
|
||||
:getRowId="getRowId"
|
||||
:getRowHeight="getRowHeight"
|
||||
:columnDefs="columnDefs"
|
||||
:rowData="list"
|
||||
:defaultColDef="defaultColDef"
|
||||
:suppressColumnMoveAnimation="suppressColumnMoveAnimation"
|
||||
@grid-ready="onGridReady"
|
||||
@cell-clicked="onCellClicked"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts"></script>
|
||||
<script lang="ts" setup>
|
||||
import type { GeneralListItem, GeneralListItemP1, GeneralListItemP2, GeneralListResponse } from "@/defs/API/Nation";
|
||||
import { getIconPath } from "@/util/getIconPath";
|
||||
import { inject, ref, watch, type PropType, type Ref, type StyleValue } from "vue";
|
||||
import { AgGridVue } from "ag-grid-vue3";
|
||||
import type {
|
||||
Column,
|
||||
CellClassParams,
|
||||
CellStyle,
|
||||
ColDef,
|
||||
ColGroupDef,
|
||||
ColumnApi,
|
||||
GetRowIdParams,
|
||||
GridApi,
|
||||
GridReadyEvent,
|
||||
RowNode,
|
||||
CellClickedEvent,
|
||||
} from "ag-grid-community";
|
||||
import { ProvidedColumnGroup } from "ag-grid-community";
|
||||
import { getNpcColor } from "@/common_legacy";
|
||||
import type { BaseWithValueColDefParams, ValueGetterParams } from "ag-grid-community/dist/lib/entities/colDef";
|
||||
import type { GameConstStore } from "@/GameConstStore";
|
||||
import { unwrap } from "@/util/unwrap";
|
||||
import SimpleTooltipCell from "@/gridCellRenderer/SimpleTooltipCell.vue";
|
||||
import GridTooltipCell, { type GridCellInfo } from "@/gridCellRenderer/GridTooltipCell.vue";
|
||||
import { formatConnectScore } from "@/utilGame/formatConnectScore";
|
||||
import { convertSearch초성 } from "@/util/convertSearch초성";
|
||||
import { isString } from "lodash";
|
||||
import { formatDefenceTrain } from "@/utilGame/formatDefenceTrain";
|
||||
import { BDropdownItem, BDropdownDivider, BButtonGroup, BDropdown, BButton } from "bootstrap-vue-3";
|
||||
import { unwrap_err } from "@/util/unwrap_err";
|
||||
import { RuntimeError } from "@/util/RuntimeError";
|
||||
import { defaultDisplaySetting, type GridDisplaySetting } from "@/defs/gridDefs";
|
||||
const props = defineProps({
|
||||
list: {
|
||||
type: Array as PropType<GeneralListItem[]>,
|
||||
required: true,
|
||||
},
|
||||
troops: {
|
||||
type: Object as PropType<Record<number, string>>,
|
||||
required: true,
|
||||
},
|
||||
height: {
|
||||
type: String as PropType<"static" | "fill" | number | `${number}px` | `${number}%`>,
|
||||
required: false,
|
||||
default: "static",
|
||||
},
|
||||
env: {
|
||||
type: Object as PropType<GeneralListResponse["env"]>,
|
||||
required: true,
|
||||
},
|
||||
toolbarID: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: undefined,
|
||||
},
|
||||
role: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "generic",
|
||||
},
|
||||
availableGeneralClick: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "generalClick", generalID: number): void;
|
||||
}>();
|
||||
|
||||
const suppressColumnMoveAnimation = ref(true);
|
||||
const gameConstStore = unwrap(inject<Ref<GameConstStore>>("gameConstStore"));
|
||||
|
||||
const validColumns = ref(new Set<string>());
|
||||
|
||||
watch(
|
||||
() => props.list,
|
||||
(newValue) => {
|
||||
const newValidColumns = new Set<string>(["icon"]);
|
||||
if (newValue.length > 0) {
|
||||
for (const key of Object.keys(newValue[0])) {
|
||||
newValidColumns.add(key);
|
||||
}
|
||||
validColumns.value = newValidColumns;
|
||||
}
|
||||
setTimeout(() => {
|
||||
gridApi.value?.redrawRows();
|
||||
}, 0);
|
||||
}
|
||||
);
|
||||
watch(
|
||||
() => props.height,
|
||||
(val) => {
|
||||
if (val === "static") {
|
||||
gridApi.value?.setDomLayout("autoHeight");
|
||||
} else {
|
||||
gridApi.value?.setDomLayout("normal");
|
||||
}
|
||||
}
|
||||
);
|
||||
const generalByID = ref(new Map<number, GeneralListItem>());
|
||||
function refineGeneralList(list: GeneralListItem[]) {
|
||||
const map = new Map<number, GeneralListItem>();
|
||||
for (const general of list) {
|
||||
map.set(general.no, general);
|
||||
}
|
||||
generalByID.value = map;
|
||||
}
|
||||
refineGeneralList(props.list);
|
||||
watch(() => props.list, refineGeneralList);
|
||||
const gridApi = ref<GridApi>();
|
||||
const columnApi = ref<ColumnApi>();
|
||||
const rowHeight = ref(68);
|
||||
function getRowId(params: GetRowIdParams): string {
|
||||
const genID = (params.data as GeneralListItem).no;
|
||||
return `${genID}`;
|
||||
}
|
||||
|
||||
function setDisplaySetting(settingKey: SettingKeyType, setting: GridDisplaySetting) {
|
||||
if (!columnApi.value) {
|
||||
console.error("nyc?");
|
||||
return;
|
||||
}
|
||||
columnApi.value.applyColumnState({ state: setting.column, applyOrder: true });
|
||||
columnApi.value.setColumnGroupState(setting.columnGroup);
|
||||
currentSetting.value = settingKey;
|
||||
}
|
||||
|
||||
const displaySettings = ref(new Map<string, GridDisplaySetting>());
|
||||
const displaySettingVersion = 1; //추가되는 걸로 버전 올리지 말고, 사용할 수 없게 될때만 올리기
|
||||
const displaySettingsKey = "GeneralListDisplaySetting";
|
||||
|
||||
function getLastUsedSettingsKey() {
|
||||
const lastUsedSettingsKey = "LastUsedSettingsKey";
|
||||
return `${lastUsedSettingsKey}_${props.role}`;
|
||||
}
|
||||
|
||||
function loadDisplaySetting() {
|
||||
const rawSettings = localStorage.getItem(displaySettingsKey);
|
||||
if (!rawSettings) {
|
||||
return;
|
||||
}
|
||||
const settings: { version: number; settings: [string, GridDisplaySetting][] } = JSON.parse(rawSettings);
|
||||
if (settings.version != displaySettingVersion) {
|
||||
localStorage.removeItem(displaySettingsKey);
|
||||
return;
|
||||
}
|
||||
|
||||
displaySettings.value = new Map(settings.settings);
|
||||
}
|
||||
loadDisplaySetting();
|
||||
|
||||
type SettingKeyType = [true, keyof typeof defaultDisplaySetting] | [false, string];
|
||||
const currentSetting = ref<SettingKeyType>([true, "normal"]);
|
||||
function loadLastUsedSettings() {
|
||||
const rawLastSettingKey = localStorage.getItem(getLastUsedSettingsKey());
|
||||
if (!rawLastSettingKey) {
|
||||
return;
|
||||
}
|
||||
const settingKey: SettingKeyType = JSON.parse(rawLastSettingKey);
|
||||
const [isDefault, settingKeyName] = settingKey;
|
||||
if (isDefault) {
|
||||
if (!(settingKeyName in defaultDisplaySetting)) {
|
||||
console.error(`${settingKeyName}은 이제 기본 지원 타입이 아닙니다.`);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (!displaySettings.value.has(settingKeyName)) {
|
||||
console.error(`${settingKeyName}는 저장되어있지 않습니다.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
currentSetting.value = settingKey;
|
||||
return settingKey;
|
||||
}
|
||||
watch(currentSetting, (newTypeKey) => {
|
||||
localStorage.setItem(getLastUsedSettingsKey(), JSON.stringify(newTypeKey));
|
||||
});
|
||||
|
||||
watch(
|
||||
displaySettings,
|
||||
(newSettings) => {
|
||||
const settings = Array.from(newSettings.entries());
|
||||
localStorage.setItem(
|
||||
displaySettingsKey,
|
||||
JSON.stringify({
|
||||
version: displaySettingVersion,
|
||||
settings,
|
||||
})
|
||||
);
|
||||
console.log("저장!", Array.from(newSettings.keys()));
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
function deleteDisplaySetting(key: string) {
|
||||
if (!confirm(`${key} 설정을 지울까요?`)) {
|
||||
return;
|
||||
}
|
||||
displaySettings.value.delete(key);
|
||||
}
|
||||
|
||||
function storeDisplaySetting() {
|
||||
if (!columnApi.value) {
|
||||
console.error("nyc?");
|
||||
return;
|
||||
}
|
||||
|
||||
const nickName = prompt("선택한 설정의 별명을 지어주세요", currentSetting.value[0] ? "" : currentSetting.value[1]);
|
||||
if (!nickName) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (displaySettings.value.has(nickName)) {
|
||||
if (!confirm("이미 있는 이름입니다. 덮어쓸까요?")) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const setting: GridDisplaySetting = {
|
||||
column: columnApi.value.getColumnState(),
|
||||
columnGroup: columnApi.value.getColumnGroupState(),
|
||||
};
|
||||
|
||||
displaySettings.value.set(nickName, setting);
|
||||
currentSetting.value = [false, nickName];
|
||||
}
|
||||
|
||||
function onGridReady(params: GridReadyEvent) {
|
||||
gridApi.value = params.api;
|
||||
columnApi.value = params.columnApi;
|
||||
if (props.height === "static") {
|
||||
params.api.setDomLayout("autoHeight");
|
||||
} else {
|
||||
params.api.setDomLayout("normal");
|
||||
}
|
||||
loadLastUsedSettings();
|
||||
if (currentSetting.value[0]) {
|
||||
setDisplaySetting(currentSetting.value, defaultDisplaySetting[currentSetting.value[1]]);
|
||||
} else {
|
||||
setDisplaySetting(currentSetting.value, unwrap(displaySettings.value.get(currentSetting.value[1])));
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
suppressColumnMoveAnimation.value = false;
|
||||
}, 1);
|
||||
}
|
||||
|
||||
function onCellClicked(event: CellClickedEvent) {
|
||||
const colID = event.column.getColId();
|
||||
if (colID === "icon" || colID === "name") {
|
||||
const generalItem = event.data as GeneralListItem;
|
||||
emit("generalClick", generalItem.no);
|
||||
}
|
||||
}
|
||||
|
||||
function getRowHeight(): number {
|
||||
return rowHeight.value;
|
||||
}
|
||||
type headerType =
|
||||
| keyof Omit<GeneralListItemP2, "no" | "imgsvr" | "picture" | "lbonus" | "permission" | "st0" | "st1" | "st2">
|
||||
| "stat"
|
||||
| "icon"
|
||||
| "goldRice"
|
||||
| "expDedLv"
|
||||
| "crewtypeAndCrew"
|
||||
| "trainAtmos"
|
||||
| "specials"
|
||||
| "reservedCommandShort"
|
||||
| "killturnAndConnect"
|
||||
| "years"
|
||||
| "warResults";
|
||||
interface GenValueParams extends BaseWithValueColDefParams {
|
||||
data: GeneralListItem;
|
||||
}
|
||||
interface GenValueGetterParams extends ValueGetterParams {
|
||||
data: GeneralListItem;
|
||||
}
|
||||
interface GenRowNode extends RowNode {
|
||||
data: GeneralListItem;
|
||||
}
|
||||
interface GenColDef extends ColDef {
|
||||
colId: headerType;
|
||||
field?: headerType;
|
||||
headerName: string;
|
||||
valueFormatter?: string | ((params: GenValueParams) => string);
|
||||
filterValueGetter?: string | ((params: GenValueGetterParams) => unknown);
|
||||
valueGetter?: string | ((params: GenValueGetterParams) => unknown);
|
||||
}
|
||||
interface GenColGroupDef extends ColGroupDef {
|
||||
headerName: string;
|
||||
children: GenColDef[]; //1단만 할꺼다!
|
||||
groupId: headerType;
|
||||
}
|
||||
interface GenCellClassParams extends CellClassParams {
|
||||
data: GeneralListItem;
|
||||
}
|
||||
function getColumnList(): [headerType, ProvidedColumnGroup | Column, number?][] {
|
||||
const result: [headerType, ProvidedColumnGroup | Column, number?][] = [];
|
||||
if (!columnApi.value) {
|
||||
return result;
|
||||
}
|
||||
for (const [rawColKey, rawColDef] of Object.entries(columnRawDefs.value)) {
|
||||
if (rawColKey === "name") {
|
||||
continue;
|
||||
}
|
||||
if (!("children" in rawColDef)) {
|
||||
const col = unwrap_err(columnApi.value.getColumn(rawColDef.colId), RuntimeError, `no col: ${rawColDef.colId}`);
|
||||
result.push([rawColDef.colId, col]);
|
||||
continue;
|
||||
}
|
||||
const colGroup = unwrap_err(
|
||||
columnApi.value.getProvidedColumnGroup(rawColDef.groupId),
|
||||
RuntimeError,
|
||||
`no colGroup: ${rawColDef.groupId}`
|
||||
);
|
||||
result.push([rawColDef.groupId, colGroup]);
|
||||
for (const subColDef of rawColDef.children) {
|
||||
const subColId = subColDef.colId;
|
||||
if (rawColDef.groupId == subColId) {
|
||||
continue;
|
||||
}
|
||||
const col = unwrap_err(columnApi.value.getColumn(subColId), RuntimeError, `no subCol: ${subColDef.colId}`);
|
||||
result.push([subColId, col, 1]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function naiveCheClassNameFilter(value: string): string {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
const text = value.split("_").pop() ?? "None";
|
||||
if (text === "None") {
|
||||
return "-";
|
||||
}
|
||||
return text;
|
||||
}
|
||||
function numberFormatter(unit?: string) {
|
||||
if (unit) {
|
||||
return (value: GenValueParams): string => {
|
||||
if (value.value == null) {
|
||||
return "?";
|
||||
}
|
||||
const valueText = (value.value as number).toLocaleString();
|
||||
return `${valueText} ${unit}`;
|
||||
};
|
||||
}
|
||||
return (value: GenValueParams): string => {
|
||||
return (value.value as number).toLocaleString();
|
||||
};
|
||||
}
|
||||
function extractTroopInfo(value: GeneralListItem): [string, GeneralListItemP1] | undefined {
|
||||
if (!value.st1) {
|
||||
return undefined;
|
||||
}
|
||||
const troopID = value.troop;
|
||||
if (!(troopID in props.troops)) {
|
||||
return undefined;
|
||||
}
|
||||
const troopName = props.troops[troopID];
|
||||
const troopLeader = generalByID.value.get(troopID) as GeneralListItemP1 | undefined;
|
||||
if (troopLeader === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return [troopName, troopLeader];
|
||||
}
|
||||
function toggleColumn(colID: headerType, col: Column) {
|
||||
const newState = !col.isVisible();
|
||||
const target: string[] = [colID];
|
||||
const parent = col.getParent();
|
||||
if (newState) {
|
||||
for (const child of (parent.getChildren() ?? []) as Column[]) {
|
||||
if (parent.getGroupId() == child.getColDef().colId) {
|
||||
target.push(child.getColId());
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let stillVisible = false;
|
||||
let header: string | null = null;
|
||||
for (const child of (parent.getChildren() ?? []) as Column[]) {
|
||||
if (child.getColId() == colID) {
|
||||
continue;
|
||||
}
|
||||
if (parent.getGroupId() == child.getColDef().colId) {
|
||||
header = child.getColId();
|
||||
continue;
|
||||
}
|
||||
stillVisible = true;
|
||||
break;
|
||||
}
|
||||
if (!stillVisible && header) {
|
||||
target.push(header);
|
||||
}
|
||||
}
|
||||
columnApi.value?.setColumnsVisible(target, newState);
|
||||
}
|
||||
const defaultCellClass = ["cell-middle"];
|
||||
const centerCellClass = [...defaultCellClass, "cell-center"];
|
||||
const rightAlignClass = [...defaultCellClass, "cell-right"];
|
||||
const sortableNumber: Omit<GenColDef, "colId" | "headerName"> = {
|
||||
sortable: true,
|
||||
comparator: (a, b) => a - b,
|
||||
sortingOrder: ["desc", "asc", null],
|
||||
filter: "number",
|
||||
cellClass: rightAlignClass,
|
||||
};
|
||||
const defaultColDef = ref<ColDef>({
|
||||
resizable: true,
|
||||
headerClass: "default-cell-header",
|
||||
cellClass: centerCellClass,
|
||||
floatingFilter: true,
|
||||
width: 80,
|
||||
});
|
||||
const columnRawDefs = ref<Partial<Record<headerType, GenColDef | GenColGroupDef>>>({
|
||||
icon: {
|
||||
colId: "icon",
|
||||
headerName: "아이콘",
|
||||
width: 64 + 16,
|
||||
suppressSizeToFit: true,
|
||||
resizable: false,
|
||||
cellRenderer: (obj: GenValueParams) => {
|
||||
const { data: gen } = obj;
|
||||
return `<img src="${getIconPath(gen.imgsvr, gen.picture)}" width="64">`;
|
||||
},
|
||||
pinned: "left",
|
||||
cellClass: [props.availableGeneralClick ? "clickable-cell" : "", ...defaultCellClass],
|
||||
lockPosition: true,
|
||||
},
|
||||
name: {
|
||||
headerName: "장수명",
|
||||
colId: "name",
|
||||
field: "name",
|
||||
pinned: "left",
|
||||
sortable: true,
|
||||
width: 120,
|
||||
sortingOrder: ["asc", "desc", null],
|
||||
lockPosition: true,
|
||||
cellStyle: (val: GenCellClassParams) => {
|
||||
const gen = val.data;
|
||||
const style: StyleValue = {
|
||||
color: getNpcColor(gen.npc),
|
||||
};
|
||||
return style as CellStyle;
|
||||
},
|
||||
comparator: (_lhs, _rhs, { data: lhs }: GenRowNode, { data: rhs }: GenRowNode) => {
|
||||
const npcDiff = lhs.npc - rhs.npc;
|
||||
if (npcDiff != 0) {
|
||||
return npcDiff;
|
||||
}
|
||||
return lhs.name.localeCompare(rhs.name);
|
||||
},
|
||||
|
||||
filterValueGetter: ({ data }) => convertSearch초성(data.name),
|
||||
cellClass: [props.availableGeneralClick ? "clickable-cell" : "", ...defaultCellClass],
|
||||
filter: true,
|
||||
hide: false,
|
||||
lockVisible: true,
|
||||
},
|
||||
//npc: { headerName: "NPC", colId: "npc", field: "npc" },
|
||||
stat: {
|
||||
groupId: "stat",
|
||||
openByDefault: false,
|
||||
headerName: "능력치",
|
||||
children: [
|
||||
{
|
||||
colId: "stat",
|
||||
headerName: "통|무|지",
|
||||
width: 88,
|
||||
cellRenderer: (obj: GenValueParams) => {
|
||||
const gen = obj.data;
|
||||
return `${gen.leadership}|${gen.strength}|${gen.intel}`;
|
||||
},
|
||||
columnGroupShow: "closed",
|
||||
},
|
||||
{
|
||||
colId: "leadership",
|
||||
headerName: "통솔",
|
||||
field: "leadership",
|
||||
...sortableNumber,
|
||||
columnGroupShow: "open",
|
||||
width: 60,
|
||||
type: "numericColumn",
|
||||
},
|
||||
{
|
||||
colId: "strength",
|
||||
headerName: "무력",
|
||||
field: "strength",
|
||||
...sortableNumber,
|
||||
columnGroupShow: "open",
|
||||
width: 60,
|
||||
},
|
||||
{
|
||||
colId: "intel",
|
||||
headerName: "지력",
|
||||
field: "intel",
|
||||
...sortableNumber,
|
||||
columnGroupShow: "open",
|
||||
width: 60,
|
||||
},
|
||||
],
|
||||
},
|
||||
officerLevel: {
|
||||
headerName: "관직",
|
||||
colId: "officerLevel",
|
||||
field: "officerLevelText",
|
||||
sortable: true,
|
||||
comparator: (a, b, c, d) => c.data.officerLevel - d.data.officerLevel,
|
||||
cellRenderer: ({ data }: GenValueParams) => {
|
||||
if (data.officerLevel >= 5) {
|
||||
return `<span style="color:cyan;">${data.officerLevelText}</span>`;
|
||||
}
|
||||
if (data.st1 && 2 <= data.officerLevel && data.officerLevel <= 4) {
|
||||
const cityName = gameConstStore.value.cityConst[data.officer_city].name;
|
||||
return `${cityName}<br>${data.officerLevelText}`;
|
||||
}
|
||||
return data.officerLevelText;
|
||||
},
|
||||
filterValueGetter: ({ data }) => {
|
||||
if (data.st1 && 2 <= data.officerLevel && data.officerLevel <= 4) {
|
||||
const cityName = gameConstStore.value.cityConst[data.officer_city].name;
|
||||
return convertSearch초성(`${cityName} ${data.officerLevelText}`);
|
||||
}
|
||||
return convertSearch초성(data.officerLevelText);
|
||||
},
|
||||
filter: true,
|
||||
cellClass: centerCellClass,
|
||||
width: 70,
|
||||
},
|
||||
expDedLv: {
|
||||
headerName: "명성/계급",
|
||||
groupId: "expDedLv",
|
||||
width: 70,
|
||||
children: [
|
||||
{
|
||||
colId: "expDedLv",
|
||||
headerName: "",
|
||||
columnGroupShow: "closed",
|
||||
width: 60,
|
||||
cellRenderer: ({ data }: GenValueParams) => {
|
||||
return `Lv ${data.explevel}<br>${data.dedLevelText}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
colId: "explevel",
|
||||
headerName: "명성",
|
||||
field: "explevel",
|
||||
width: 60,
|
||||
cellRenderer: ({ data }: GenValueParams) => {
|
||||
return `Lv ${data.explevel}<br>(${data.honorText})`;
|
||||
},
|
||||
...sortableNumber,
|
||||
cellClass: centerCellClass,
|
||||
columnGroupShow: "open",
|
||||
},
|
||||
{
|
||||
colId: "dedlevel",
|
||||
headerName: "계급",
|
||||
field: "dedLevelText",
|
||||
width: 70,
|
||||
cellRenderer: ({ data }: GenValueParams) => {
|
||||
return `${data.dedLevelText}<br>(${data.bill.toLocaleString()})`;
|
||||
},
|
||||
...sortableNumber,
|
||||
cellClass: centerCellClass,
|
||||
columnGroupShow: "open",
|
||||
},
|
||||
],
|
||||
},
|
||||
goldRice: {
|
||||
headerName: "자금",
|
||||
groupId: "goldRice",
|
||||
children: [
|
||||
{
|
||||
colId: "goldRice",
|
||||
headerName: "금/쌀",
|
||||
cellRenderer: ({ data }: GenValueParams) => {
|
||||
return `${data.gold.toLocaleString()} 금<br>${data.rice.toLocaleString()} 쌀`;
|
||||
},
|
||||
width: 80,
|
||||
cellClass: rightAlignClass,
|
||||
columnGroupShow: "closed",
|
||||
sortable: true,
|
||||
sortingOrder: ["desc", "asc", null],
|
||||
comparator(_a, _b, { data: lhs }: GenRowNode, { data: rhs }: GenRowNode) {
|
||||
const lhsAmount = lhs.gold + lhs.rice;
|
||||
const rhsAmount = rhs.gold + rhs.rice;
|
||||
return lhsAmount - rhsAmount;
|
||||
},
|
||||
},
|
||||
{
|
||||
colId: "gold",
|
||||
headerName: "금",
|
||||
field: "gold",
|
||||
...sortableNumber,
|
||||
valueFormatter: numberFormatter("금"),
|
||||
width: 70,
|
||||
columnGroupShow: "open",
|
||||
},
|
||||
{
|
||||
colId: "rice",
|
||||
headerName: "쌀",
|
||||
field: "rice",
|
||||
...sortableNumber,
|
||||
valueFormatter: numberFormatter("쌀"),
|
||||
width: 70,
|
||||
columnGroupShow: "open",
|
||||
},
|
||||
],
|
||||
},
|
||||
city: {
|
||||
colId: "city",
|
||||
headerName: "도시",
|
||||
field: "city",
|
||||
valueGetter: ({ data }) => {
|
||||
if (!data.st1) {
|
||||
return "?";
|
||||
}
|
||||
return gameConstStore.value.cityConst[data.city].name;
|
||||
},
|
||||
filter: true,
|
||||
sortable: true,
|
||||
width: 60,
|
||||
filterValueGetter: ({ data }) => {
|
||||
if (!data.st1) {
|
||||
return "";
|
||||
}
|
||||
return convertSearch초성(gameConstStore.value.cityConst[data.city].name);
|
||||
},
|
||||
},
|
||||
troop: {
|
||||
colId: "troop",
|
||||
headerName: "부대",
|
||||
field: "troop",
|
||||
valueGetter: ({ data }: GenValueGetterParams) => {
|
||||
if (!data.st1) {
|
||||
return "?";
|
||||
}
|
||||
const troopInfo = extractTroopInfo(data);
|
||||
if (troopInfo === undefined) {
|
||||
return "-";
|
||||
}
|
||||
const [troopName, troopLeader] = troopInfo;
|
||||
const cityName = gameConstStore.value.cityConst[troopLeader.city].name;
|
||||
return [troopName, cityName];
|
||||
},
|
||||
cellRenderer: ({ value }: { value: [string, string] | string }) => {
|
||||
if (isString(value)) {
|
||||
return value;
|
||||
}
|
||||
const [troopName, cityName] = value;
|
||||
return `${troopName}<br>[${cityName}]`;
|
||||
},
|
||||
width: 90,
|
||||
sortable: true,
|
||||
comparator: (valX, valB, { data: lhs }: GenRowNode, { data: rhs }: GenRowNode) => {
|
||||
const troopInfoLhs = extractTroopInfo(lhs);
|
||||
const troopInfoRhs = extractTroopInfo(rhs);
|
||||
console.log(troopInfoLhs, troopInfoRhs);
|
||||
if (troopInfoLhs === troopInfoRhs) {
|
||||
return 0;
|
||||
}
|
||||
if (troopInfoLhs === undefined) {
|
||||
return 1;
|
||||
}
|
||||
if (troopInfoRhs === undefined) {
|
||||
return -1;
|
||||
}
|
||||
return troopInfoLhs[0].localeCompare(troopInfoRhs[0]);
|
||||
},
|
||||
filter: true,
|
||||
filterValueGetter: ({ data }) => {
|
||||
const troopInfo = extractTroopInfo(data);
|
||||
if (troopInfo === undefined) {
|
||||
return "-";
|
||||
}
|
||||
const [troopName, troopLeader] = troopInfo;
|
||||
const cityName = gameConstStore.value.cityConst[troopLeader.city].name;
|
||||
return convertSearch초성(`${troopName}$${cityName}`);
|
||||
},
|
||||
},
|
||||
crewtypeAndCrew: {
|
||||
groupId: "crewtypeAndCrew",
|
||||
headerName: "보유 병력",
|
||||
children: [
|
||||
{
|
||||
colId: "crewtypeAndCrew",
|
||||
headerName: "병종",
|
||||
cellRenderer: GridTooltipCell,
|
||||
cellRendererParams: {
|
||||
cells: ((): GridCellInfo[][] => {
|
||||
return [
|
||||
[{ target: "crewtype", iActionMap: gameConstStore.value.iActionInfo.crewtype }],
|
||||
[{ target: "crew", converter: (value) => [`${value.crew.toLocaleString()}명`, undefined] }],
|
||||
];
|
||||
})(),
|
||||
},
|
||||
columnGroupShow: "closed",
|
||||
},
|
||||
{
|
||||
colId: "crewtype",
|
||||
headerName: "병종",
|
||||
field: "crewtype",
|
||||
cellRenderer: SimpleTooltipCell,
|
||||
cellRendererParams: {
|
||||
iActionMap: gameConstStore.value.iActionInfo.crewtype,
|
||||
},
|
||||
sortable: true,
|
||||
columnGroupShow: "open",
|
||||
filter: true,
|
||||
filterValueGetter: ({ data }) => {
|
||||
if (!data.st1) {
|
||||
return "?";
|
||||
}
|
||||
const name = gameConstStore.value.iActionInfo.crewtype[data.crewtype].name;
|
||||
return convertSearch초성(name);
|
||||
},
|
||||
},
|
||||
{
|
||||
colId: "crew",
|
||||
headerName: "병력",
|
||||
field: "crew",
|
||||
...sortableNumber,
|
||||
valueFormatter: numberFormatter("명"),
|
||||
width: 70,
|
||||
columnGroupShow: "open",
|
||||
},
|
||||
],
|
||||
},
|
||||
trainAtmos: {
|
||||
groupId: "trainAtmos",
|
||||
headerName: "훈/사",
|
||||
children: [
|
||||
{
|
||||
colId: "trainAtmos",
|
||||
headerName: "훈/사",
|
||||
width: 60,
|
||||
cellRenderer: ({ data }: GenValueParams) => {
|
||||
if (!data.st1) {
|
||||
return "?";
|
||||
}
|
||||
return `${data.train}<br>${data.atmos}`;
|
||||
},
|
||||
columnGroupShow: "closed",
|
||||
},
|
||||
{
|
||||
colId: "train",
|
||||
headerName: "훈련",
|
||||
field: "train",
|
||||
...sortableNumber,
|
||||
valueFormatter: numberFormatter(),
|
||||
width: 70,
|
||||
columnGroupShow: "open",
|
||||
},
|
||||
{
|
||||
colId: "atmos",
|
||||
headerName: "사기",
|
||||
field: "atmos",
|
||||
...sortableNumber,
|
||||
valueFormatter: numberFormatter(),
|
||||
width: 70,
|
||||
columnGroupShow: "open",
|
||||
},
|
||||
{
|
||||
colId: "defence_train",
|
||||
headerName: "수비",
|
||||
field: "defence_train",
|
||||
sortable: true,
|
||||
sortingOrder: ["desc", "asc", null],
|
||||
valueFormatter: ({ value }) => formatDefenceTrain(value as number),
|
||||
width: 50,
|
||||
},
|
||||
],
|
||||
},
|
||||
specials: {
|
||||
groupId: "specials",
|
||||
headerName: "특성",
|
||||
children: [
|
||||
{
|
||||
colId: "specials",
|
||||
headerName: "요약",
|
||||
cellRenderer: GridTooltipCell,
|
||||
cellRendererParams: {
|
||||
cells: ((): GridCellInfo[][] => {
|
||||
return [
|
||||
[{ target: "personal", iActionMap: gameConstStore.value.iActionInfo.personality }],
|
||||
[
|
||||
{ target: "specialDomestic", iActionMap: gameConstStore.value.iActionInfo.specialDomestic },
|
||||
{ target: "specialWar", iActionMap: gameConstStore.value.iActionInfo.specialWar },
|
||||
],
|
||||
];
|
||||
})(),
|
||||
},
|
||||
width: 80,
|
||||
columnGroupShow: "closed",
|
||||
},
|
||||
{
|
||||
colId: "personal",
|
||||
headerName: "성격",
|
||||
field: "personal",
|
||||
cellRenderer: SimpleTooltipCell,
|
||||
cellRendererParams: {
|
||||
iActionMap: gameConstStore.value.iActionInfo.personality,
|
||||
},
|
||||
width: 60,
|
||||
sortable: true,
|
||||
filter: true,
|
||||
columnGroupShow: "open",
|
||||
filterValueGetter: ({ data }) => {
|
||||
const name = gameConstStore.value.iActionInfo.personality[data.personal].name;
|
||||
return convertSearch초성(name);
|
||||
},
|
||||
},
|
||||
{
|
||||
colId: "specialDomestic",
|
||||
headerName: "내특",
|
||||
field: "specialDomestic",
|
||||
cellRenderer: SimpleTooltipCell,
|
||||
cellRendererParams: {
|
||||
iActionMap: gameConstStore.value.iActionInfo.specialDomestic,
|
||||
},
|
||||
width: 60,
|
||||
sortable: true,
|
||||
filter: true,
|
||||
columnGroupShow: "open",
|
||||
filterValueGetter: ({ data }) => {
|
||||
const name = gameConstStore.value.iActionInfo.specialDomestic[data.specialDomestic].name;
|
||||
return convertSearch초성(name);
|
||||
},
|
||||
},
|
||||
{
|
||||
colId: "specialWar",
|
||||
headerName: "전특",
|
||||
field: "specialWar",
|
||||
cellRenderer: SimpleTooltipCell,
|
||||
cellRendererParams: {
|
||||
iActionMap: gameConstStore.value.iActionInfo.specialWar,
|
||||
},
|
||||
width: 60,
|
||||
sortable: true,
|
||||
filter: true,
|
||||
columnGroupShow: "open",
|
||||
filterValueGetter: ({ data }) => {
|
||||
const name = gameConstStore.value.iActionInfo.specialWar[data.specialWar].name;
|
||||
return convertSearch초성(name);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
reservedCommandShort: {
|
||||
groupId: "reservedCommandShort",
|
||||
headerName: "명령",
|
||||
children: [
|
||||
{
|
||||
colId: "reservedCommandShort",
|
||||
headerName: "단축",
|
||||
width: 70,
|
||||
cellRenderer: ({ data }: GenValueParams) => {
|
||||
if (data.npc >= 2) {
|
||||
return "NPC 장수";
|
||||
}
|
||||
if (!data.reservedCommand) {
|
||||
return "-";
|
||||
}
|
||||
const commandList = data.reservedCommand;
|
||||
if (!commandList) {
|
||||
return "???";
|
||||
}
|
||||
return commandList
|
||||
.map(({ action }) => {
|
||||
if (action !== "휴식" || data.npc >= 2) {
|
||||
return naiveCheClassNameFilter(action);
|
||||
}
|
||||
const limitMinutes = props.env.autorun_user?.limit_minutes ?? 0;
|
||||
if (!limitMinutes) {
|
||||
return naiveCheClassNameFilter(action);
|
||||
}
|
||||
if (data.killturn + limitMinutes > props.env.killturn) {
|
||||
return "자율행동";
|
||||
}
|
||||
return naiveCheClassNameFilter(action);
|
||||
})
|
||||
.join("<br>");
|
||||
},
|
||||
cellStyle: {
|
||||
lineHeight: "1em",
|
||||
fontSize: "0.85em",
|
||||
},
|
||||
columnGroupShow: "closed",
|
||||
},
|
||||
{
|
||||
colId: "reservedCommand",
|
||||
headerName: "전체",
|
||||
width: 120,
|
||||
cellRenderer: ({ data }: GenValueParams) => {
|
||||
if (data.npc >= 2) {
|
||||
return "NPC 장수";
|
||||
}
|
||||
const commandList = data.reservedCommand;
|
||||
if (!commandList) {
|
||||
return "???";
|
||||
}
|
||||
return commandList
|
||||
.map(({ action, brief }) => {
|
||||
if (action !== "휴식" || data.npc >= 2) {
|
||||
return brief;
|
||||
}
|
||||
const limitMinutes = props.env.autorun_user?.limit_minutes ?? 0;
|
||||
if (!limitMinutes) {
|
||||
return brief;
|
||||
}
|
||||
if (data.killturn + limitMinutes > props.env.killturn) {
|
||||
return "자율 행동";
|
||||
}
|
||||
return brief;
|
||||
})
|
||||
.join("<br>");
|
||||
},
|
||||
cellStyle: {
|
||||
lineHeight: "1em",
|
||||
fontSize: "0.85em",
|
||||
},
|
||||
columnGroupShow: "open",
|
||||
},
|
||||
],
|
||||
},
|
||||
turntime: {
|
||||
colId: "turntime",
|
||||
headerName: "턴",
|
||||
field: "turntime",
|
||||
width: 60,
|
||||
valueFormatter: ({ value, data }) => {
|
||||
if (!data.st1) {
|
||||
return "?";
|
||||
}
|
||||
const turntime = value as string;
|
||||
return turntime.substring(14, 19);
|
||||
},
|
||||
sortable: true,
|
||||
cellClass: centerCellClass,
|
||||
},
|
||||
recent_war: {
|
||||
colId: "recent_war",
|
||||
headerName: "최근전투",
|
||||
field: "recent_war",
|
||||
width: 60,
|
||||
valueFormatter: ({ value, data }) => {
|
||||
if (!data.st1) {
|
||||
return "?";
|
||||
}
|
||||
const turntime = value as string;
|
||||
return turntime.substring(14, 19);
|
||||
},
|
||||
sortable: true,
|
||||
cellClass: centerCellClass,
|
||||
},
|
||||
years: {
|
||||
groupId: "years",
|
||||
headerName: "연도",
|
||||
children: [
|
||||
{
|
||||
colId: "years",
|
||||
headerName: "요약",
|
||||
width: 60,
|
||||
cellRenderer: ({ data }: GenValueParams) => {
|
||||
return `${data.age}세<br>${data.belong}년`;
|
||||
},
|
||||
cellClass: centerCellClass,
|
||||
columnGroupShow: "closed",
|
||||
},
|
||||
{
|
||||
colId: "age",
|
||||
headerName: "연령",
|
||||
field: "age",
|
||||
...sortableNumber,
|
||||
valueFormatter: (v: GenValueParams) => `${v.value}세`,
|
||||
width: 60,
|
||||
cellClass: centerCellClass,
|
||||
columnGroupShow: "open",
|
||||
},
|
||||
{
|
||||
colId: "belong",
|
||||
headerName: "사관",
|
||||
field: "belong",
|
||||
...sortableNumber,
|
||||
valueFormatter: (v: GenValueParams) => `${v.value}년`,
|
||||
width: 60,
|
||||
cellClass: centerCellClass,
|
||||
columnGroupShow: "open",
|
||||
},
|
||||
],
|
||||
},
|
||||
killturnAndConnect: {
|
||||
groupId: "killturnAndConnect",
|
||||
headerName: "기타",
|
||||
children: [
|
||||
{
|
||||
colId: "killturnAndConnect",
|
||||
headerName: "삭/벌",
|
||||
cellRenderer: ({ data }: GenValueParams) => {
|
||||
return `${data.killturn.toLocaleString()}턴<br>${data.connect.toLocaleString()}점`;
|
||||
},
|
||||
cellClass: rightAlignClass,
|
||||
columnGroupShow: "closed",
|
||||
width: 70,
|
||||
},
|
||||
{
|
||||
colId: "killturn",
|
||||
headerName: "삭턴",
|
||||
field: "killturn",
|
||||
cellRenderer: ({ data }: GenValueParams) => {
|
||||
return `${data.killturn.toLocaleString()}턴`;
|
||||
},
|
||||
...sortableNumber,
|
||||
width: 70,
|
||||
columnGroupShow: "open",
|
||||
},
|
||||
{
|
||||
colId: "connect",
|
||||
headerName: "벌점",
|
||||
field: "connect",
|
||||
cellRenderer: ({ data }: GenValueParams) => {
|
||||
return `${data.connect.toLocaleString()}점<br>(${formatConnectScore(data.connect)})`;
|
||||
},
|
||||
...sortableNumber,
|
||||
width: 70,
|
||||
columnGroupShow: "open",
|
||||
},
|
||||
],
|
||||
},
|
||||
warResults: {
|
||||
groupId: "warResults",
|
||||
headerName: "전과",
|
||||
children: [
|
||||
{
|
||||
colId: "warResults",
|
||||
headerName: "요약",
|
||||
cellRenderer: ({ data }: GenValueParams) => {
|
||||
if (!data.st1) {
|
||||
return "?";
|
||||
}
|
||||
const killRatePercent = Math.round((data.killcrew / Math.max(1, data.deathcrew)) * 100);
|
||||
return `${data.warnum.toLocaleString()}전 ${data.killnum.toLocaleString()}승<br>살상: ${killRatePercent}%`;
|
||||
},
|
||||
cellClass: centerCellClass,
|
||||
columnGroupShow: "closed",
|
||||
width: 90,
|
||||
},
|
||||
{
|
||||
colId: "warnum",
|
||||
headerName: "전투",
|
||||
field: "warnum",
|
||||
...sortableNumber,
|
||||
valueFormatter: numberFormatter("전"),
|
||||
columnGroupShow: "open",
|
||||
width: 60,
|
||||
},
|
||||
{
|
||||
colId: "killnum",
|
||||
headerName: "승리",
|
||||
field: "killnum",
|
||||
...sortableNumber,
|
||||
valueFormatter: numberFormatter("승"),
|
||||
columnGroupShow: "open",
|
||||
width: 60,
|
||||
},
|
||||
{
|
||||
colId: "killcrew",
|
||||
headerName: "살상률",
|
||||
field: "killcrew",
|
||||
...sortableNumber,
|
||||
valueGetter: ({ data }) => {
|
||||
if (!data.st1) {
|
||||
return "?";
|
||||
}
|
||||
const killRatePercent = Math.round((data.killcrew / Math.max(1, data.deathcrew)) * 100);
|
||||
return killRatePercent;
|
||||
},
|
||||
valueFormatter: numberFormatter("%"),
|
||||
columnGroupShow: "open",
|
||||
width: 60,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const columnDefs = ref([...Object.values(columnRawDefs.value)]);
|
||||
watch(columnRawDefs, (val) => {
|
||||
columnDefs.value = [...Object.values(val)];
|
||||
gridApi.value?.refreshCells();
|
||||
});
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.g-tr {
|
||||
border-bottom: solid gray 1px;
|
||||
}
|
||||
.g-thead-tr {
|
||||
position: sticky;
|
||||
top: 0px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
:deep(.view-mode-list) {
|
||||
width: 180px;
|
||||
}
|
||||
</style>
|
||||
<style lang="scss">
|
||||
.component-general-list {
|
||||
.clickable-cell:hover {
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ag-root-wrapper .cell-middle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.ag-root-wrapper {
|
||||
font-family: "Pretendard", "Apple SD Gothic Neo", "Noto Sans KR", "Malgun Gothic";
|
||||
font-size: 14px;
|
||||
overflow: auto;
|
||||
}
|
||||
.ag-root {
|
||||
overflow: auto;
|
||||
}
|
||||
.ag-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
.cell-center {
|
||||
justify-content: space-around;
|
||||
text-align: center;
|
||||
}
|
||||
.cell-right {
|
||||
justify-content: flex-end;
|
||||
text-align: right;
|
||||
}
|
||||
.cell-sp .col {
|
||||
min-width: 30px;
|
||||
}
|
||||
.ag-header-cell,
|
||||
.ag-header-group-cell,
|
||||
.ag-cell {
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.ag-header-cell-label,
|
||||
.ag-header-group-cell-label {
|
||||
justify-content: center;
|
||||
}
|
||||
.ag-ltr .ag-floating-filter-button {
|
||||
margin-left: 2px;
|
||||
}
|
||||
.ag-rtl .ag-floating-filter-button {
|
||||
margin-right: 2px;
|
||||
}
|
||||
}
|
||||
.general-list-toolbar {
|
||||
.column-menu {
|
||||
column-count: 3;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<div class="general-card-supplement row gx-0">
|
||||
<div class="col-12 general-card-info">
|
||||
<div class="part-title">추가 정보</div>
|
||||
<div>명성</div>
|
||||
<div>{{ formatHonor(general.experience) }} ({{ general.experience.toLocaleString() }})</div>
|
||||
<div>계급</div>
|
||||
<div>{{ general.dedLevelText }} ({{ general.dedication.toLocaleString() }}</div>
|
||||
<div>봉급</div>
|
||||
<div>{{ general.bill.toLocaleString() }}</div>
|
||||
|
||||
<div>전투</div>
|
||||
<div>{{ general.warnum.toLocaleString() }}</div>
|
||||
<div>계략</div>
|
||||
<div>{{ general.firenum.toLocaleString() }}</div>
|
||||
<div>사관</div>
|
||||
<div>{{ general.belong }}년차</div>
|
||||
|
||||
<div>승률</div>
|
||||
<div>{{ ((general.killnum / Math.max(general.warnum, 1)) * 100).toFixed(2) }} %</div>
|
||||
<div>승리</div>
|
||||
<div>{{ general.killnum.toLocaleString() }}</div>
|
||||
<div>패배</div>
|
||||
<div>{{ general.deathnum.toPrecision() }}</div>
|
||||
|
||||
<div>살상률</div>
|
||||
<div>
|
||||
{{
|
||||
((general.killcrew / Math.max(general.deathcrew, 1)) * 100).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
}}
|
||||
%
|
||||
</div>
|
||||
<div>사살</div>
|
||||
<div>{{ general.killcrew.toLocaleString() }}</div>
|
||||
<div>피살</div>
|
||||
<div>{{ general.deathcrew.toLocaleString() }}</div>
|
||||
</div>
|
||||
<div class="col-7 general-card-dex">
|
||||
<div class="part-title">숙련도</div>
|
||||
<template v-for="[dexType, dex, dexInfo] of dexList" :key="dexType">
|
||||
<div>{{ dexType }}</div>
|
||||
<div :style="{ color: dexInfo.color }">{{ dexInfo.name }}</div>
|
||||
<div>{{ (dex / 1000).toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 1 }) }}K</div>
|
||||
<div>
|
||||
<SammoBar :height="10" :percent="dex / 1_000_000" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="col-5 general-card-turn">
|
||||
<div class="part-title">예약턴</div>
|
||||
<template v-if="general.reservedCommand">
|
||||
<div v-for="(turn, idx) in general.reservedCommand.slice(0, 5)" :key="idx">
|
||||
{{ turn.brief }}
|
||||
</div>
|
||||
</template>
|
||||
<div v-else style="grid-row: 2 / 7">NPC</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { GeneralListItemP1 } from "@/defs/API/Nation";
|
||||
import { computed } from "vue";
|
||||
import SammoBar from "@/components/SammoBar.vue";
|
||||
import { formatDexLevel, type DexInfo } from "@/utilGame/formatDexLevel";
|
||||
import { formatHonor } from "@/utilGame/formatHonor";
|
||||
const props = defineProps<{
|
||||
general: GeneralListItemP1;
|
||||
}>();
|
||||
|
||||
const dexList = computed((): [string, number, DexInfo][] => {
|
||||
return [
|
||||
["보병", props.general.dex1, formatDexLevel(props.general.dex1)],
|
||||
["궁병", props.general.dex2, formatDexLevel(props.general.dex2)],
|
||||
["기병", props.general.dex3, formatDexLevel(props.general.dex3)],
|
||||
["귀병", props.general.dex4, formatDexLevel(props.general.dex4)],
|
||||
["차병", props.general.dex5, formatDexLevel(props.general.dex5)],
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.general-card-basic {
|
||||
display: grid;
|
||||
grid-template-columns: 64px repeat(3, 2fr 5fr);
|
||||
grid-template-rows: repeat(9, calc(64px / 3));
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
.general-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
grid-row: 1 / 4;
|
||||
}
|
||||
|
||||
.general-name {
|
||||
grid-row: 1 / 2;
|
||||
grid-column: 2 / 8;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.general-crew-type-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
grid-row: 4 / 7;
|
||||
}
|
||||
|
||||
.general-exp-level-bar {
|
||||
grid-column: 3 / 6;
|
||||
}
|
||||
|
||||
.general-defence-train {
|
||||
grid-column: 2 / 4;
|
||||
}
|
||||
|
||||
.general-card-info {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 64px 1fr);
|
||||
grid-template-rows: repeat(5, calc(64px / 3));
|
||||
|
||||
.part-title {
|
||||
grid-column: 1 / 4;
|
||||
}
|
||||
}
|
||||
|
||||
.general-card-dex {
|
||||
display: grid;
|
||||
grid-template-columns: 64px 40px 60px 1fr;
|
||||
grid-template-rows: repeat(6, calc(64px / 3));
|
||||
|
||||
.part-title {
|
||||
grid-column: 1 / 5;
|
||||
}
|
||||
}
|
||||
|
||||
.general-card-turn {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: repeat(6, calc(64px / 3));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<div
|
||||
:class="['city_base', `city_base_${city.id}`, `city_level_${city.level}`]"
|
||||
:style="cityPos"
|
||||
@mouseenter="silent"
|
||||
@mouseleave="silent"
|
||||
>
|
||||
<a
|
||||
class="city_link"
|
||||
:data-text="city.text"
|
||||
:data-nation="city.nation"
|
||||
:data-id="city.id"
|
||||
:href="props.href"
|
||||
:style="{
|
||||
cursor: city.clickable ? 'pointer' : 'default',
|
||||
}"
|
||||
@click="clicked"
|
||||
@touchend="touchend"
|
||||
@mouseenter="mouseenter"
|
||||
@mouseleave="mouseleave"
|
||||
>
|
||||
<div
|
||||
class="city_img"
|
||||
:style="{
|
||||
backgroundColor: city.color,
|
||||
}"
|
||||
>
|
||||
<div :class="['city_filler', props.isMyCity ? 'my_city' : '']"></div>
|
||||
<div v-if="city.state > 0" :class="['city_state', `city_state_${getCityState()}`]"></div>
|
||||
<div v-if="city.nationID && city.nationID > 0" class="city_flag">
|
||||
<div v-if="city.isCapital" class="city_capital"></div>
|
||||
</div>
|
||||
<span class="city_detail_name">{{ city.name }}</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { MapCityParsed } from "@/map";
|
||||
import { ref, toRef, watch, type PropType } from "vue";
|
||||
const emit = defineEmits<{
|
||||
(event: "click", evnet: MouseEvent | TouchEvent): void;
|
||||
(event: "mouseenter", e: MouseEvent): void;
|
||||
(event: "mouseleave", e: MouseEvent): void;
|
||||
}>();
|
||||
const props = defineProps({
|
||||
city: {
|
||||
type: Object as PropType<MapCityParsed>,
|
||||
required: true,
|
||||
},
|
||||
href: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
required: false,
|
||||
},
|
||||
isMyCity: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
defeault: false,
|
||||
},
|
||||
isFullWidth: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const city = toRef(props, "city");
|
||||
const cityPos = ref({
|
||||
left: "0px",
|
||||
top: "0px",
|
||||
});
|
||||
watch(
|
||||
() => props.isFullWidth,
|
||||
(isFullWidth) => {
|
||||
const { x, y } = city.value;
|
||||
if (isFullWidth) {
|
||||
cityPos.value = {
|
||||
left: `${x - 20}px`,
|
||||
top: `${y - 15}px`,
|
||||
};
|
||||
} else {
|
||||
cityPos.value = {
|
||||
left: `${(x * 5) / 7 - 20}px`,
|
||||
top: `${(y * 5) / 7 - 18}px`,
|
||||
};
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
function getCityState(): string {
|
||||
const state = city.value.state;
|
||||
if (state < 10) {
|
||||
return "good";
|
||||
}
|
||||
if (state < 40) {
|
||||
return "bad";
|
||||
}
|
||||
if (state < 50) {
|
||||
return "war";
|
||||
}
|
||||
return "wrong";
|
||||
}
|
||||
|
||||
function clicked(event: MouseEvent) {
|
||||
emit("click", event);
|
||||
}
|
||||
|
||||
function mouseenter(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
emit("mouseenter", event);
|
||||
}
|
||||
|
||||
function mouseleave(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
emit("mouseleave", event);
|
||||
}
|
||||
|
||||
function touchend(event: TouchEvent) {
|
||||
event.stopPropagation();
|
||||
emit("click", event);
|
||||
}
|
||||
|
||||
function silent(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
a,
|
||||
div,
|
||||
span {
|
||||
line-height: 1.3;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div
|
||||
:class="['city_base', `city_base_${city.id}`, `city_level_${city.level}`]"
|
||||
:style="cityPos"
|
||||
@mouseenter="silent"
|
||||
@mouseleave="silent"
|
||||
>
|
||||
<div
|
||||
v-if="city.color"
|
||||
class="city_bg"
|
||||
:style="{
|
||||
backgroundImage: `url(${imagePath}/b${city.color.substring(1).toUpperCase()}.png)`,
|
||||
}"
|
||||
></div>
|
||||
|
||||
<a
|
||||
class="city_link"
|
||||
:data-text="city.text"
|
||||
:data-nation="city.nation"
|
||||
:data-id="city.id"
|
||||
:href="props.href"
|
||||
:style="{
|
||||
cursor: city.clickable ? 'pointer' : 'default',
|
||||
}"
|
||||
@click="clicked"
|
||||
@touchend="touchend"
|
||||
@mouseenter="mouseenter"
|
||||
@mouseleave="mouseleave"
|
||||
>
|
||||
<div class="city_img">
|
||||
<img :src="`${imagePath}/cast_${city.level}.gif`" />
|
||||
<div :class="['city_filler', props.isMyCity ? 'my_city' : '']"></div>
|
||||
|
||||
<div v-if="city.nationID && city.nationID > 0" class="city_flag">
|
||||
<img :src="`${imagePath}/${city.supply ? 'f' : 'd'}${city.color.substring(1).toUpperCase()}.gif`" />
|
||||
<div v-if="city.isCapital" class="city_capital">
|
||||
<img :src="`${imagePath}/event51.gif`" />
|
||||
</div>
|
||||
</div>
|
||||
<span class="city_detail_name">{{ city.name }}</span>
|
||||
</div>
|
||||
<div v-if="city.state > 0" class="city_state">
|
||||
<img :src="`${imagePath}/event${city.state}.gif`" />
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { MapCityParsed } from "@/map";
|
||||
import { ref, toRef, watch, type PropType } from "vue";
|
||||
const emit = defineEmits<{
|
||||
(event: "click", e: MouseEvent | TouchEvent): void;
|
||||
(event: "mouseenter", e: MouseEvent): void;
|
||||
(event: "mouseleave", e: MouseEvent): void;
|
||||
}>();
|
||||
const props = defineProps({
|
||||
city: {
|
||||
type: Object as PropType<MapCityParsed>,
|
||||
required: true,
|
||||
},
|
||||
href: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
required: false,
|
||||
},
|
||||
isMyCity: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
defeault: false,
|
||||
},
|
||||
imagePath: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isFullWidth: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
const city = toRef(props, "city");
|
||||
const cityPos = ref({
|
||||
left: "0px",
|
||||
top: "0px",
|
||||
});
|
||||
watch(
|
||||
() => props.isFullWidth,
|
||||
(isFullWidth) => {
|
||||
const { x, y } = city.value;
|
||||
if (isFullWidth) {
|
||||
cityPos.value = {
|
||||
left: `${x - 20}px`,
|
||||
top: `${y - 15}px`,
|
||||
};
|
||||
} else {
|
||||
cityPos.value = {
|
||||
left: `${(x * 5) / 7 - 20}px`,
|
||||
top: `${(y * 5) / 7 - 18}px`,
|
||||
};
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const imagePath = toRef(props, "imagePath");
|
||||
|
||||
function clicked(event: MouseEvent) {
|
||||
emit("click", event);
|
||||
}
|
||||
|
||||
function mouseenter(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
emit("mouseenter", event);
|
||||
}
|
||||
|
||||
function mouseleave(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
emit("mouseleave", event);
|
||||
}
|
||||
|
||||
function touchend(event: TouchEvent) {
|
||||
event.stopPropagation();
|
||||
emit("click", event);
|
||||
}
|
||||
|
||||
function silent(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.full_width_map{
|
||||
a,
|
||||
div,
|
||||
img,
|
||||
span {
|
||||
line-height: 1.3;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
.small_width_map {
|
||||
a,
|
||||
div,
|
||||
img {
|
||||
line-height: 1.3;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
span {
|
||||
line-height: 1.0;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,493 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="(mapData.version ?? 0) == CURRENT_MAP_VERSION"
|
||||
:id="uuid"
|
||||
:class="[
|
||||
'world_map',
|
||||
`map_theme_${mapTheme}`,
|
||||
drawableMap ? '' : 'draw_required',
|
||||
props.isDetailMap ? 'map_detail' : 'map_basic',
|
||||
hideMapCityName ? 'hide_cityname' : '',
|
||||
isFullWidth ? 'full_width_map' : 'small_width_map',
|
||||
getMapSeasonClassName(),
|
||||
]"
|
||||
>
|
||||
<div
|
||||
v-my-tooltip.hover.top="{
|
||||
class: 'map_title_tooltiptext',
|
||||
}"
|
||||
class="map_title"
|
||||
:title="getTitleTooltip()"
|
||||
>
|
||||
<!-- eslint-disable-next-line vue/max-attributes-per-line -->
|
||||
<span class="map_title_text" :style="{ color: getTitleColor() }"
|
||||
>{{ mapData?.year }}年 {{ mapData?.month }}月</span
|
||||
>
|
||||
<span class="tooltiptext" />
|
||||
</div>
|
||||
<div ref="map_area" class="map_body" @click="clickOutside">
|
||||
<div class="map_bglayer1" />
|
||||
<div class="map_bglayer2" />
|
||||
<div class="map_bgroad" />
|
||||
<div class="map_button_stack">
|
||||
<button
|
||||
type="button"
|
||||
:class="['btn btn-primary map_toggle_cityname btn-sm btn-minimum', hideMapCityName ? 'active' : '']"
|
||||
data-bs-toggle="button"
|
||||
:aria-pressed="hideMapCityName"
|
||||
autocomplete="off"
|
||||
@click="hideMapCityName = !hideMapCityName"
|
||||
>
|
||||
도시명 표기</button
|
||||
><br />
|
||||
<button
|
||||
:style="{
|
||||
display: deviceType != 'mouseOnly' ? 'block' : 'none',
|
||||
}"
|
||||
type="button"
|
||||
:class="['btn btn-secondary map_toggle_single_tap btn-sm btn-minimum', toggleSingleTap ? 'active' : '']"
|
||||
data-bs-toggle="button"
|
||||
:aria-pressed="toggleSingleTap"
|
||||
autocomplete="off"
|
||||
@click="toggleSingleTap = !toggleSingleTap"
|
||||
>
|
||||
두번 탭 해 도시 이동
|
||||
</button>
|
||||
</div>
|
||||
<template v-if="drawableMap === undefined"><!--로딩중?--></template>
|
||||
<template v-else-if="props.isDetailMap">
|
||||
<MapCityDetail
|
||||
v-for="city of drawableMap.cityList"
|
||||
:key="city.id"
|
||||
:city="city"
|
||||
:image-path="imagePath"
|
||||
:is-my-city="city.id === drawableMap.myCity"
|
||||
:isFullWidth="isFullWidth"
|
||||
:href="props.genHref?.call(city, city.id)"
|
||||
@click="cityClick(city, $event)"
|
||||
@mouseenter="mouseenter(city, $event)"
|
||||
@mouseleave="mouseleave(city, $event)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else
|
||||
><MapCityBasic
|
||||
v-for="city of drawableMap.cityList"
|
||||
:key="city.id"
|
||||
:city="city"
|
||||
:is-my-city="city.id === drawableMap.myCity"
|
||||
:isFullWidth="isFullWidth"
|
||||
:href="props.genHref?.call(city, city.id)"
|
||||
@click="cityClick(city, $event)"
|
||||
@mouseenter="mouseenter(city, $event)"
|
||||
@mouseleave="mouseleave(city, $event)"
|
||||
/></template>
|
||||
</div>
|
||||
<div
|
||||
ref="tooltipDom"
|
||||
class="city_tooltip"
|
||||
:style="{
|
||||
display: isOutside || !activatedCity ? 'none' : 'block',
|
||||
position: 'absolute',
|
||||
left: `${(() => {
|
||||
if (cursorX + tooltipWidth + 10 > (isFullWidth ? 700 : 500)) {
|
||||
return cursorX - tooltipWidth - 5;
|
||||
}
|
||||
return cursorX + 10;
|
||||
})()}px`,
|
||||
top: `${cursorY + 30}px`,
|
||||
}"
|
||||
>
|
||||
<div class="city_name">{{ activatedCity?.text }}</div>
|
||||
<div class="nation_name">{{ activatedCity?.nation }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="world_map">
|
||||
<span class="map_title_text">
|
||||
맵 버전이 맞지 않습니다.<br />
|
||||
렌더러 버전: {{ CURRENT_MAP_VERSION }}<br />
|
||||
API 버전: {{ mapData.version ?? 0 }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export type MapCityParsedRaw = {
|
||||
id: number;
|
||||
level: number;
|
||||
state: number;
|
||||
nationID?: number;
|
||||
region: number;
|
||||
supply: boolean;
|
||||
};
|
||||
|
||||
type MapCityParsedName = MapCityParsedRaw & {
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
type MapCityParsedNation = MapCityParsedName & {
|
||||
nationID?: number;
|
||||
nation?: string;
|
||||
color?: string;
|
||||
isCapital: boolean;
|
||||
};
|
||||
|
||||
type MapCityParsedClickable = MapCityParsedNation & {
|
||||
clickable: number;
|
||||
};
|
||||
|
||||
type MapCityParsedRegionLevelText = MapCityParsedClickable & {
|
||||
region_str: string;
|
||||
level_str: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type MapCityParsed = MapCityParsedRegionLevelText;
|
||||
|
||||
type MapCityDrawable = {
|
||||
cityList: MapCityParsed[];
|
||||
myCity?: number;
|
||||
};
|
||||
|
||||
type MapNationParsed = {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
capital: number;
|
||||
};
|
||||
|
||||
export type CityPositionMap = {
|
||||
[cityID: number]: [string, number, number];
|
||||
};
|
||||
</script>
|
||||
<script lang="ts" setup>
|
||||
import "@/../scss/map.scss";
|
||||
import { type PropType, toRef, inject, type Ref, ref, watch, type ComponentPublicInstance } from "vue";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { CURRENT_MAP_VERSION, type MapResult } from "@/defs";
|
||||
import { joinYearMonth } from "@/util/joinYearMonth";
|
||||
import { parseYearMonth } from "@/util/parseYearMonth";
|
||||
import vMyTooltip from "@/directives/vMyTooltip";
|
||||
import type { GameConstStore } from "@/GameConstStore";
|
||||
import { unwrap_err } from "@/util/unwrap_err";
|
||||
import { getMaxRelativeTechLevel, TECH_LEVEL_YEAR_GAP } from "@/utilGame/techLevel";
|
||||
import { deviceType } from "detect-it";
|
||||
import MapCityBasic from "./MapCityBasic.vue";
|
||||
import MapCityDetail from "./MapCityDetail.vue";
|
||||
import { convertDictById } from "@/common_legacy";
|
||||
import { useElementSize, useMouse, useMouseInElement } from "@vueuse/core";
|
||||
import { hideMapCityName, toggleSingleTap } from "@/state/mapViewer";
|
||||
import { is1000pxMode } from "@/state/is1000pxMode";
|
||||
const uuid = uuidv4();
|
||||
const gameConstStore = unwrap_err(
|
||||
inject<Ref<GameConstStore>>("gameConstStore"),
|
||||
Error,
|
||||
"gameConstStore가 주입되지 않았습니다."
|
||||
);
|
||||
|
||||
const tooltipDom = ref<ComponentPublicInstance<HTMLDivElement>>();
|
||||
const map_area = ref<ComponentPublicInstance<HTMLDivElement>>();
|
||||
const { elementX: cursorX, elementY: cursorY, isOutside } = useMouseInElement(map_area);
|
||||
|
||||
const tooltipWidth = ref(0);
|
||||
const { width: tooltipCurrWidth } = useElementSize(tooltipDom);
|
||||
watch(
|
||||
tooltipCurrWidth,
|
||||
(newWidth) => {
|
||||
if (newWidth == 0) return;
|
||||
tooltipWidth.value = newWidth;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const { sourceType: cursorType } = useMouse();
|
||||
const emit = defineEmits<{
|
||||
(event: "city-click", city: MapCityParsed, e: MouseEvent | TouchEvent): void;
|
||||
(event: "parsed", drawable: MapCityDrawable): void;
|
||||
(event: "update:modelValue", value: MapCityParsed): void;
|
||||
}>();
|
||||
|
||||
const isFullWidth = ref(true);
|
||||
|
||||
function setWidthMode([widthMode, is1000pxMode]: ["auto" | "full" | "small" | undefined, boolean]): void {
|
||||
if (widthMode == "full") {
|
||||
isFullWidth.value = true;
|
||||
}
|
||||
if (widthMode == "small") {
|
||||
isFullWidth.value = false;
|
||||
}
|
||||
isFullWidth.value = is1000pxMode;
|
||||
}
|
||||
watch([() => props.width, is1000pxMode], setWidthMode, { immediate: true });
|
||||
|
||||
const props = defineProps({
|
||||
width: {
|
||||
type: String as PropType<"full" | "small" | "auto" | undefined>,
|
||||
default: undefined,
|
||||
required: false,
|
||||
},
|
||||
imagePath: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
mapName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isDetailMap: { type: Boolean, default: undefined, required: false },
|
||||
disallowClick: { type: Boolean, default: undefined, required: false },
|
||||
genHref: {
|
||||
type: Function as PropType<(cityID: number) => string>,
|
||||
default: undefined,
|
||||
required: false,
|
||||
},
|
||||
|
||||
cityPosition: {
|
||||
type: Object as PropType<CityPositionMap>,
|
||||
required: true,
|
||||
},
|
||||
formatCityInfo: {
|
||||
type: Function as PropType<(city: MapCityParsed) => MapCityParsed>,
|
||||
required: true,
|
||||
},
|
||||
|
||||
mapData: {
|
||||
type: Object as PropType<MapResult>,
|
||||
required: true,
|
||||
},
|
||||
|
||||
modelValue: {
|
||||
type: Object as PropType<MapCityParsed>,
|
||||
default: undefined,
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
|
||||
const mapData = toRef(props, "mapData");
|
||||
|
||||
const mapTheme = toRef(props, "mapName");
|
||||
function getTitleColor(): string | undefined {
|
||||
const { startYear, year } = mapData.value;
|
||||
|
||||
if (year < startYear + 1) {
|
||||
return "magenta";
|
||||
}
|
||||
if (year < startYear + 2) {
|
||||
return "orange";
|
||||
}
|
||||
if (year < startYear + 3) {
|
||||
return "yellow";
|
||||
}
|
||||
}
|
||||
|
||||
const drawableMap = ref<MapCityDrawable>(convertCityObjs(props.mapData));
|
||||
const activatedCity = ref<MapCityParsed>();
|
||||
|
||||
function getBeginGameLimitTooltip(): string | undefined {
|
||||
const { startYear, year, month } = mapData.value;
|
||||
if (year > startYear + 3) return undefined;
|
||||
|
||||
const [remainYear, remainMonth] = parseYearMonth(joinYearMonth(startYear + 3, 0) - joinYearMonth(year, month));
|
||||
|
||||
return `초반제한 기간 : ${remainYear}년${remainMonth > 0 ? ` ${remainMonth}개월` : ""} (${startYear + 3}년)`;
|
||||
}
|
||||
|
||||
function getTitleTooltip(): string {
|
||||
const result: string[] = [];
|
||||
const beginLimit = getBeginGameLimitTooltip();
|
||||
if (beginLimit) {
|
||||
result.push(beginLimit);
|
||||
}
|
||||
|
||||
const { startYear, year } = mapData.value;
|
||||
|
||||
const maxTechLevel = gameConstStore.value.gameConst.maxTechLevel;
|
||||
const currentTechLimit = getMaxRelativeTechLevel(startYear, year, maxTechLevel);
|
||||
|
||||
if (currentTechLimit == maxTechLevel) {
|
||||
result.push(`기술등급 제한 : ${currentTechLimit}등급 (최종)`);
|
||||
} else {
|
||||
const nextTechLimitYear = currentTechLimit * TECH_LEVEL_YEAR_GAP + startYear;
|
||||
result.push(`기술등급 제한 : ${currentTechLimit}등급 (${nextTechLimitYear}년 해제)`);
|
||||
}
|
||||
|
||||
return result.join("<br>");
|
||||
}
|
||||
|
||||
function getMapSeasonClassName(): string {
|
||||
const { month } = mapData.value;
|
||||
|
||||
if (month <= 3) {
|
||||
return "map_spring";
|
||||
}
|
||||
if (month <= 6) {
|
||||
return "map_summer";
|
||||
}
|
||||
if (month <= 9) {
|
||||
return "map_fall";
|
||||
}
|
||||
return "map_winter";
|
||||
}
|
||||
|
||||
function convertCityObjs(obj: MapResult): MapCityDrawable {
|
||||
//원본 Obj는 굉장히 간소하게 온다, Object 형태로 변환해서 사용한다.
|
||||
|
||||
function toCityObj([id, level, state, nationID, region, supply]: MapResult["cityList"][0]): MapCityParsedRaw {
|
||||
return {
|
||||
id: id,
|
||||
level: level,
|
||||
state: state,
|
||||
nationID: nationID > 0 ? nationID : undefined,
|
||||
region: region,
|
||||
supply: supply != 0,
|
||||
};
|
||||
}
|
||||
|
||||
function toNationObj([id, name, color, capital]: MapResult["nationList"][0]): MapNationParsed {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
color,
|
||||
capital,
|
||||
};
|
||||
}
|
||||
|
||||
const nationList = convertDictById(obj.nationList.map(toNationObj)); //array of object -> dict
|
||||
|
||||
const spyList = obj.spyList;
|
||||
const shownByGeneralList = new Set(obj.shownByGeneralList);
|
||||
|
||||
const myCity = obj.myCity;
|
||||
const myNation = obj.myNation;
|
||||
|
||||
function mergePositionInfo(city: MapCityParsedRaw): MapCityParsedName {
|
||||
const id = city.id;
|
||||
if (!(id in props.cityPosition)) {
|
||||
throw TypeError(`알수 없는 cityID: ${id}`);
|
||||
}
|
||||
const [name, x, y] = props.cityPosition[id];
|
||||
|
||||
return {
|
||||
...city,
|
||||
name,
|
||||
x,
|
||||
y,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeNationInfo(city: MapCityParsedName): MapCityParsedNation {
|
||||
//nationID 값으로 isCapital, color, nation을 통합
|
||||
|
||||
const nationID = city.nationID;
|
||||
if (nationID === undefined || !(nationID in nationList)) {
|
||||
return {
|
||||
...city,
|
||||
isCapital: false,
|
||||
};
|
||||
}
|
||||
|
||||
const nationObj = nationList[nationID];
|
||||
return {
|
||||
...city,
|
||||
nation: nationObj.name,
|
||||
color: nationObj.color,
|
||||
isCapital: nationObj.capital == city.id,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeClickable(city: MapCityParsedNation): MapCityParsedClickable {
|
||||
//clickable = (defaultCity << 4 ) | (remainSpy << 3) | (ourCity << 2) | (shownByGeneral << 1)
|
||||
const id = city.id;
|
||||
const nationID = city.nationID;
|
||||
|
||||
if (props.disallowClick) {
|
||||
return { ...city, clickable: 0 };
|
||||
}
|
||||
|
||||
let clickable = 16;
|
||||
if (id in spyList) {
|
||||
clickable |= spyList[id] << 3;
|
||||
}
|
||||
if (myNation !== null && nationID == myNation) {
|
||||
clickable |= 4;
|
||||
}
|
||||
if (shownByGeneralList.has(id)) {
|
||||
clickable |= 2;
|
||||
}
|
||||
if (myCity !== null && id == myCity) {
|
||||
clickable |= 2;
|
||||
}
|
||||
|
||||
return {
|
||||
...city,
|
||||
clickable,
|
||||
};
|
||||
}
|
||||
|
||||
const cityList = obj.cityList
|
||||
.map(toCityObj)
|
||||
.map(mergePositionInfo)
|
||||
.map(mergeNationInfo)
|
||||
.map(mergeClickable)
|
||||
.map(window.formatCityInfo);
|
||||
|
||||
const result = {
|
||||
cityList: cityList,
|
||||
myCity: myCity,
|
||||
};
|
||||
emit("parsed", result);
|
||||
|
||||
return result;
|
||||
}
|
||||
watch(
|
||||
() => props.mapData,
|
||||
(mapInfo) => {
|
||||
activatedCity.value = undefined;
|
||||
drawableMap.value = convertCityObjs(mapInfo);
|
||||
}
|
||||
);
|
||||
|
||||
const touchState = ref(0);
|
||||
function cityClick(city: MapCityParsed, $event: MouseEvent | TouchEvent): void {
|
||||
if (cursorType.value == "touch") {
|
||||
if (touchState.value == 1 && activatedCity.value?.id !== city.id) {
|
||||
touchState.value = 0;
|
||||
activatedCity.value = undefined;
|
||||
}
|
||||
if (touchState.value == 0) {
|
||||
touchState.value = 1;
|
||||
activatedCity.value = city;
|
||||
$event.preventDefault();
|
||||
if (!toggleSingleTap.value) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
emit("city-click", city, $event);
|
||||
emit("update:modelValue", city);
|
||||
}
|
||||
|
||||
function clickOutside($event: MouseEvent): void {
|
||||
$event.preventDefault();
|
||||
$event.stopPropagation();
|
||||
if (touchState.value == 1) {
|
||||
touchState.value = 0;
|
||||
activatedCity.value = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function mouseenter(city: MapCityParsed, $event: MouseEvent): void {
|
||||
if (cursorType.value == "mouse") {
|
||||
activatedCity.value = city;
|
||||
touchState.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function mouseleave(city: MapCityParsed, $event: MouseEvent): void {
|
||||
if (cursorType.value == "mouse") {
|
||||
activatedCity.value = undefined;
|
||||
touchState.value = 0;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div class="row form-group number-input-with-info">
|
||||
<label v-if="!right && title" class="col col-form-label">{{ title }}</label>
|
||||
<div class="col">
|
||||
<input
|
||||
ref="input"
|
||||
v-model="rawValue"
|
||||
type="number"
|
||||
:step="step ?? undefined"
|
||||
class="form-control f_tnum"
|
||||
:min="min ?? undefined"
|
||||
:max="max ?? undefined"
|
||||
:style="{ display: editmode ? undefined : 'none' }"
|
||||
@blur="onBlurNumber"
|
||||
@input="updateValue"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control f_tnum"
|
||||
:readonly="readonly"
|
||||
:value="printValue"
|
||||
:style="{ display: !editmode ? undefined : 'none' }"
|
||||
@focus="onFocusText"
|
||||
/>
|
||||
</div>
|
||||
<label v-if="right && title" class="col col-form-label">{{ title }}</label>
|
||||
</div>
|
||||
<div style="text-align: right">
|
||||
<small class="form-text text-muted"><slot /></small>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { clamp } from "lodash";
|
||||
import { defineComponent } from "vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "NumberInputWithInfo",
|
||||
props: {
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
int: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
min: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 0,
|
||||
},
|
||||
max: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
required: false,
|
||||
},
|
||||
step: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
required: false,
|
||||
},
|
||||
modelValue: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
right: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
data() {
|
||||
return {
|
||||
editmode: false,
|
||||
rawValue: this.modelValue,
|
||||
printValue: this.modelValue.toLocaleString(),
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
modelValue: function (newVal: number) {
|
||||
this.rawValue = newVal;
|
||||
this.printValue = newVal.toLocaleString();
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
updateValue() {
|
||||
if (this.readonly) {
|
||||
return;
|
||||
}
|
||||
if (this.int) {
|
||||
this.rawValue = Math.floor(this.rawValue);
|
||||
}
|
||||
this.printValue = this.rawValue.toLocaleString();
|
||||
if (this.min !== undefined || this.max !== undefined) {
|
||||
const clampedValue = clamp(this.rawValue, this.min ?? this.rawValue, this.max ?? this.rawValue);
|
||||
this.$emit("update:modelValue", clampedValue);
|
||||
} else {
|
||||
this.$emit("update:modelValue", this.rawValue);
|
||||
}
|
||||
},
|
||||
onBlurNumber() {
|
||||
this.editmode = false;
|
||||
this.printValue = this.rawValue.toLocaleString();
|
||||
if (this.min !== undefined || this.max !== undefined) {
|
||||
const clampedValue = clamp(this.rawValue, this.min ?? this.rawValue, this.max ?? this.rawValue);
|
||||
if (clampedValue !== this.rawValue) {
|
||||
this.rawValue = clampedValue;
|
||||
this.updateValue();
|
||||
}
|
||||
}
|
||||
},
|
||||
onFocusText() {
|
||||
if (this.readonly) {
|
||||
return;
|
||||
}
|
||||
this.editmode = true;
|
||||
setTimeout(() => {
|
||||
(this.$refs.input as HTMLInputElement).focus();
|
||||
}, 0);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div
|
||||
v-b-tooltip.hover.top
|
||||
:title="`${props.percent}$`"
|
||||
class="sammo-bar"
|
||||
:style="{
|
||||
height: `${props.height}px`,
|
||||
backgroundImage: `url(${imagePath}/pr${props.height - 2}.png)`,
|
||||
backgroundRepeat: 'repeat-x',
|
||||
backgroundPosition: 'center',
|
||||
borderTop: 'solid 1px #888',
|
||||
borderBottom: 'solid 1px #333',
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="sammo-bar-in"
|
||||
:style="{
|
||||
height: `${props.height}px`,
|
||||
backgroundImage: `url(${imagePath}/pb${props.height - 2}.png)`,
|
||||
backgroundRepeat: 'repeat-x',
|
||||
backgroundPosition: 'left center',
|
||||
width: `${clamp(props.percent, 0, 100)}%`,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { clamp } from "lodash";
|
||||
const imagePath = window.pathConfig.gameImage;
|
||||
const props = defineProps<{
|
||||
height: 7 | 10;
|
||||
percent: number;
|
||||
}>();
|
||||
</script>
|
||||
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<span class="time-zone">{{ serverNow }}</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { addMilliseconds } from "date-fns";
|
||||
import { type PropType, ref, onMounted, watch } from "vue";
|
||||
import { formatTime } from "@/util/formatTime";
|
||||
const props = defineProps({
|
||||
serverTime: {
|
||||
type: Object as PropType<Date>,
|
||||
required: false,
|
||||
default: new Date(),
|
||||
},
|
||||
timeFormat: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "HH:mm:ss",
|
||||
},
|
||||
});
|
||||
|
||||
const timeDiff = ref(0);
|
||||
const serverNow = ref("");
|
||||
|
||||
watch(
|
||||
() => props.serverTime,
|
||||
(newValue) => {
|
||||
const clientNow = new Date();
|
||||
timeDiff.value = newValue.getTime() - clientNow.getTime();
|
||||
}
|
||||
);
|
||||
|
||||
function updateNow() {
|
||||
const serverNowObj = addMilliseconds(new Date(), timeDiff.value);
|
||||
serverNow.value = formatTime(serverNowObj, props.timeFormat);
|
||||
setTimeout(() => {
|
||||
updateNow();
|
||||
}, 1000 - serverNowObj.getMilliseconds());
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const clientNow = new Date();
|
||||
timeDiff.value = props.serverTime.getTime() - clientNow.getTime();
|
||||
updateNow();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<table class="simple_nation_list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 44%">국명</th>
|
||||
<th style="width: 23%">국력</th>
|
||||
<th style="width: 15%">장수</th>
|
||||
<th style="width: 15%">속령</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="nation of nations" :key="nation.nation">
|
||||
<td>
|
||||
<span
|
||||
:style="{
|
||||
color: isBrightColor(nation.color) ? '#000' : '#fff',
|
||||
backgroundColor: nation.color,
|
||||
}"
|
||||
>{{ nation.name }}</span
|
||||
>
|
||||
</td>
|
||||
<td style="text-align: right">{{ nation.power.toLocaleString() }}</td>
|
||||
<td style="text-align: right">{{ nation.gennum.toLocaleString() }}</td>
|
||||
<td v-b-tooltip.hover style="text-align: right" :title="(nation.cities ?? []).join(', ')">
|
||||
{{ (nation.cities ?? []).length }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot></tfoot>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { SimpleNationObj } from "@/defs";
|
||||
import type { PropType } from "vue";
|
||||
import { isBrightColor } from "@/util/isBrightColor";
|
||||
|
||||
defineProps({
|
||||
nations: {
|
||||
type: Array as PropType<SimpleNationObj[]>,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.simple_nation_list {
|
||||
width: 100%;
|
||||
|
||||
thead {
|
||||
background-color: #cccccc;
|
||||
color: black;
|
||||
text-align: center;
|
||||
}
|
||||
th {
|
||||
border: 0;
|
||||
border-left: 1px solid gray;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
td {
|
||||
border: 0;
|
||||
border-left: 1px solid gray;
|
||||
padding: 1px 6px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
td:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,516 @@
|
||||
<template>
|
||||
<BButtonToolbar v-if="editable && editor" key-nav class="bg-dark">
|
||||
<BButtonGroup class="mx-1">
|
||||
<BButton v-b-tooltip.hover title="되돌리기" @click="editor?.commands.undo()">
|
||||
<i class="bi bi-arrow-90deg-left" />
|
||||
</BButton>
|
||||
<BButton v-b-tooltip.hover title="재실행" @click="editor?.commands.redo()">
|
||||
<i class="bi bi-arrow-90deg-right" />
|
||||
</BButton>
|
||||
</BButtonGroup>
|
||||
<BButtonGroup class="mx-1">
|
||||
<BButton
|
||||
v-b-tooltip.hover
|
||||
:class="{ 'is-active': editor.isActive('bold') }"
|
||||
title="진하게"
|
||||
@click="editor?.chain().focus().toggleBold().run()"
|
||||
>
|
||||
<i class="bi bi-type-bold" />
|
||||
</BButton>
|
||||
<BButton
|
||||
v-b-tooltip.hover
|
||||
:class="{ 'is-active': editor.isActive('italic') }"
|
||||
title="기울이기"
|
||||
@click="editor?.chain().focus().toggleItalic().run()"
|
||||
>
|
||||
<i class="bi bi-type-italic" />
|
||||
</BButton>
|
||||
<BButton
|
||||
v-b-tooltip.hover
|
||||
:class="{ 'is-active': editor.isActive('underline') }"
|
||||
title="밑줄"
|
||||
@click="editor?.chain().focus().toggleUnderline().run()"
|
||||
>
|
||||
<i class="bi bi-type-underline" />
|
||||
</BButton>
|
||||
<!-- 효과 지우기 -->
|
||||
</BButtonGroup>
|
||||
|
||||
<BButtonGroup class="mx-1">
|
||||
<BDropdown>
|
||||
<template #button-content> 크기 </template>
|
||||
<BDropdownItem @click="editor?.chain().focus().unsetFontSize().run()">
|
||||
<span>기본</span>
|
||||
</BDropdownItem>
|
||||
<BDropdownDivider />
|
||||
<BDropdownItem
|
||||
v-for="sizeItem in fontSize"
|
||||
:key="sizeItem"
|
||||
@click="editor?.chain().focus().setFontSize(sizeItem).run()"
|
||||
>
|
||||
<span
|
||||
:style="{
|
||||
'font-size': sizeItem,
|
||||
'text-decoration': editor.isActive('textStyle', {
|
||||
fontSize: sizeItem,
|
||||
})
|
||||
? 'underline'
|
||||
: undefined,
|
||||
}"
|
||||
>{{ sizeItem }}</span
|
||||
>
|
||||
</BDropdownItem>
|
||||
</BDropdown>
|
||||
<!-- 글꼴 -->
|
||||
</BButtonGroup>
|
||||
|
||||
<BButtonGroup class="mx-1">
|
||||
<BButton
|
||||
v-b-tooltip.hover
|
||||
:class="{ 'is-active': editor.isActive('strike') }"
|
||||
title="가로선"
|
||||
@click="editor?.chain().focus().toggleStrike().run()"
|
||||
>
|
||||
<i class="bi bi-type-strikethrough" />
|
||||
</BButton>
|
||||
<!-- 윗첨자, 아랫첨자 -->
|
||||
</BButtonGroup>
|
||||
|
||||
<BButtonGroup class="mx-1">
|
||||
<BButton
|
||||
v-b-tooltip.hover
|
||||
title="색상 취소"
|
||||
@click="editor?.chain().focus().unsetColor().unsetBackgroundColor().run()"
|
||||
>
|
||||
<i class="bi bi-droplet" />
|
||||
</BButton>
|
||||
<input
|
||||
v-b-tooltip.hover
|
||||
type="color"
|
||||
class="form-control form-control-color"
|
||||
:value="colorConvert(editor.getAttributes('textStyle').color, '#ffffff')"
|
||||
title="글자색"
|
||||
@input="editor?.chain().focus().setColor(($event.target as HTMLInputElement).value).run()"
|
||||
/>
|
||||
<input
|
||||
v-b-tooltip.hover
|
||||
type="color"
|
||||
class="form-control form-control-color"
|
||||
:value="colorConvert(editor.getAttributes('textStyle').backgroundColor, '#000000')"
|
||||
title="배경색"
|
||||
@input="
|
||||
editor?.chain().focus().setBackgroundColor(($event.target as HTMLInputElement).value).run()
|
||||
"
|
||||
/>
|
||||
</BButtonGroup>
|
||||
|
||||
<BButtonGroup class="mx-1">
|
||||
<BButton v-b-tooltip.hover title="이미지 추가" @click="showImageModal = true">
|
||||
<i class="bi bi-image" />
|
||||
</BButton>
|
||||
<!-- 이미지추가 -->
|
||||
<!-- 링크 -->
|
||||
<!-- 영상링크 -->
|
||||
<!-- 표 -->
|
||||
<!-- 구분선 삽입 -->
|
||||
<BButton v-b-tooltip.hover title="구분선" @click="editor?.chain().focus().setHorizontalRule().run()">
|
||||
<i class="bi bi-hr" />
|
||||
</BButton>
|
||||
</BButtonGroup>
|
||||
|
||||
<BButtonGroup class="mx-1">
|
||||
<!-- 글머리 기호 -->
|
||||
<!-- 번호 매기기 -->
|
||||
<BButton
|
||||
v-b-tooltip.hover
|
||||
:class="{ 'is-active': editor.isActive({ textAlign: 'left' }) }"
|
||||
title="왼쪽 정렬"
|
||||
@click="editor?.chain().focus().setTextAlign('left').run()"
|
||||
>
|
||||
<i class="bi bi-text-left" />
|
||||
</BButton>
|
||||
<BButton
|
||||
v-b-tooltip.hover
|
||||
:class="{ 'is-active': editor.isActive({ textAlign: 'center' }) }"
|
||||
title="가운데 정렬"
|
||||
@click="editor?.chain().focus().setTextAlign('center').run()"
|
||||
>
|
||||
<i class="bi bi-text-center" />
|
||||
</BButton>
|
||||
<BButton
|
||||
v-b-tooltip.hover
|
||||
:class="{ 'is-active': editor.isActive({ textAlign: 'right' }) }"
|
||||
title="오른쪽 정렬"
|
||||
@click="editor?.chain().focus().setTextAlign('right').run()"
|
||||
>
|
||||
<i class="bi bi-text-right" />
|
||||
</BButton>
|
||||
<!-- 문단정렬(왼, 가, 오, 양)(내어, 들여) -->
|
||||
</BButtonGroup>
|
||||
|
||||
<BButtonGroup class="mx-1" />
|
||||
|
||||
<BButtonGroup class="mx-1">
|
||||
<!-- 줄간격 (1.0, 1.2, 1.4, 1.5, 1.6, 1.8, 2.0, 3.0) -->
|
||||
</BButtonGroup>
|
||||
|
||||
<BButtonGroup class="mx-1">
|
||||
<!-- 원본 코드 -->
|
||||
</BButtonGroup>
|
||||
</BButtonToolbar>
|
||||
<BubbleMenu
|
||||
v-if="editable && editor"
|
||||
v-show="editor.isActive('custom-image')"
|
||||
:tippyOptions="{ animation: false, maxWidth: 600 }"
|
||||
:editor="editor"
|
||||
>
|
||||
<BButtonToolbar>
|
||||
<BButtonGroup class="mx-1">
|
||||
<BButton
|
||||
v-b-tooltip.hover
|
||||
:class="{
|
||||
'is-active': editor.isActive('custom-image', {
|
||||
size: 'small',
|
||||
}),
|
||||
f_frac: true,
|
||||
}"
|
||||
title="1/4 너비로 채우기"
|
||||
@click="editor?.chain().focus().setImageEx({ size: 'small' }).run()"
|
||||
>
|
||||
1/4
|
||||
</BButton>
|
||||
<BButton
|
||||
v-b-tooltip.hover
|
||||
:class="{
|
||||
'is-active': editor.isActive('custom-image', {
|
||||
size: 'medium',
|
||||
}),
|
||||
f_frac: true,
|
||||
}"
|
||||
title="1/2 너비로 채우기"
|
||||
@click="editor?.chain().focus().setImageEx({ size: 'medium' }).run()"
|
||||
>
|
||||
1/2
|
||||
</BButton>
|
||||
<BButton
|
||||
v-b-tooltip.hover
|
||||
:class="{
|
||||
'is-active': editor.isActive('custom-image', {
|
||||
size: 'large',
|
||||
}),
|
||||
f_frac: true,
|
||||
}"
|
||||
title="가득 채우기"
|
||||
@click="editor?.chain().focus().setImageEx({ size: 'large' }).run()"
|
||||
>
|
||||
1
|
||||
</BButton>
|
||||
<BButton
|
||||
:class="{
|
||||
'is-active': editor.isActive('custom-image', {
|
||||
size: 'original',
|
||||
}),
|
||||
}"
|
||||
@click="editor?.chain().focus().setImageEx({ size: 'original' }).run()"
|
||||
>
|
||||
원본
|
||||
</BButton>
|
||||
</BButtonGroup>
|
||||
<BButtonGroup class="mx-1">
|
||||
<BButton
|
||||
v-b-tooltip.hover
|
||||
:class="{
|
||||
'is-active': editor.isActive('custom-image', {
|
||||
float: 'float-left',
|
||||
}),
|
||||
}"
|
||||
title="왼쪽으로 붙이기"
|
||||
@click="editor?.chain().focus().setImageEx({ align: 'float-left' }).run()"
|
||||
>
|
||||
<i class="bi bi-chevron-bar-left" />
|
||||
</BButton>
|
||||
<BButton
|
||||
v-b-tooltip.hover
|
||||
:class="{
|
||||
'is-active': editor.isActive('custom-image', {
|
||||
float: 'left',
|
||||
}),
|
||||
}"
|
||||
title="왼쪽으로"
|
||||
@click="editor?.chain().focus().setImageEx({ align: 'left' }).run()"
|
||||
>
|
||||
<i class="bi bi-align-start" />
|
||||
</BButton>
|
||||
<BButton
|
||||
v-b-tooltip.hover
|
||||
:class="{
|
||||
'is-active': editor.isActive('custom-image', {
|
||||
float: 'center',
|
||||
}),
|
||||
}"
|
||||
title="가운데로"
|
||||
@click="editor?.chain().focus().setImageEx({ align: 'center' }).run()"
|
||||
>
|
||||
<i class="bi bi-align-center" />
|
||||
</BButton>
|
||||
<BButton
|
||||
v-b-tooltip.hover
|
||||
:class="{
|
||||
'is-active': editor.isActive('custom-image', {
|
||||
float: 'right',
|
||||
}),
|
||||
}"
|
||||
title="오른쪽으로 붙이기"
|
||||
@click="editor?.chain().focus().setImageEx({ align: 'right' }).run()"
|
||||
>
|
||||
<i class="bi bi-align-end" />
|
||||
</BButton>
|
||||
<BButton
|
||||
v-b-tooltip.hover
|
||||
:class="{
|
||||
'is-active': editor.isActive('custom-image', {
|
||||
float: 'float-right',
|
||||
}),
|
||||
}"
|
||||
title="오른쪽으로 붙이기"
|
||||
@click="editor?.chain().focus().setImageEx({ align: 'float-right' }).run()"
|
||||
>
|
||||
<i class="bi bi-chevron-bar-right" />
|
||||
</BButton>
|
||||
</BButtonGroup>
|
||||
</BButtonToolbar>
|
||||
</BubbleMenu>
|
||||
<EditorContent :editor="editor" class="tiptap-editor" />
|
||||
<BModal
|
||||
v-model="showImageModal"
|
||||
title="이미지 추가"
|
||||
okTitle="추가"
|
||||
cancelTitle="취소"
|
||||
@ok="tryAddImage"
|
||||
@show="resetModal"
|
||||
@hidden="resetModal"
|
||||
>
|
||||
<div class="bg-light text-dark">
|
||||
<BFormGroup
|
||||
label-cols-sm="4"
|
||||
label-cols-lg="3"
|
||||
content-cols-sm
|
||||
content-cols-lg="7"
|
||||
description="업로드할 파일을 선택해주세요. (jpg, png, gif, webp)"
|
||||
label="이미지 업로드"
|
||||
label-align="right"
|
||||
:label-for="`${uuid}_image_upload`"
|
||||
>
|
||||
<input
|
||||
:id="`${uuid}_image_upload`"
|
||||
class="form-control"
|
||||
type="file"
|
||||
accept=".jpg,.jpeg,.png,.gif,.webp"
|
||||
@change="chooseImage"
|
||||
/>
|
||||
</BFormGroup>
|
||||
<BFormGroup
|
||||
label-cols-sm="4"
|
||||
label-cols-lg="3"
|
||||
content-cols-sm
|
||||
content-cols-lg="7"
|
||||
description="링크할 이미지 주소를 입력해주세요."
|
||||
label="이미지 링크"
|
||||
label-align="right"
|
||||
:label-for="`${uuid}_image_link`"
|
||||
>
|
||||
<BFormInput v-model="imageLink" />
|
||||
</BFormGroup>
|
||||
</div>
|
||||
</BModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
//import "@scss/common/bootstrap5.scss";
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { Editor, EditorContent, BubbleMenu } from "@tiptap/vue-3";
|
||||
import { FontSize } from "@/tiptap-ext/FontSize";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Underline from "@tiptap/extension-underline";
|
||||
import TextStyle from "@tiptap/extension-text-style";
|
||||
import TextAlign from "@tiptap/extension-text-align";
|
||||
import Color from "@tiptap/extension-color";
|
||||
//import Image from "@tiptap/extension-image";
|
||||
import CustomImage from "@/tiptap-ext/CustomImage";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import { BackgroundColor } from "@/tiptap-ext/BackgroundColor";
|
||||
import {
|
||||
BButtonGroup,
|
||||
BButtonToolbar,
|
||||
BButton,
|
||||
BDropdown,
|
||||
BDropdownItem,
|
||||
BDropdownDivider,
|
||||
BModal,
|
||||
BFormGroup,
|
||||
BFormInput,
|
||||
} from "bootstrap-vue-3";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { unwrap } from "@/util/unwrap";
|
||||
import { getBase64FromFileObject } from "@/util/getBase64FromFileObject";
|
||||
import { isObject, isString } from "lodash";
|
||||
import type { AxiosError } from "axios";
|
||||
import { SammoAPI } from "@/SammoAPI";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
editable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["ready", "update:modelValue"]);
|
||||
|
||||
const uuid = ref(uuidv4());
|
||||
const editor = ref<InstanceType<typeof Editor>>();
|
||||
//const fontList = ref(["Pretendard", "맑은 고딕", "궁서", "돋움"]);
|
||||
const fontSize = ref(["8px", "10px", "12px", "14px", "18px", "22px", "28px", "36px", "48px", "72px"]);
|
||||
const imageUploadFiles = ref(null as FileList | null);
|
||||
const imageLink = ref("");
|
||||
const showImageModal = ref(false);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value: string) => {
|
||||
const isSame = editor.value?.getHTML() === value;
|
||||
|
||||
if (isSame) {
|
||||
return;
|
||||
}
|
||||
|
||||
editor.value?.commands.setContent(value, false);
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.editable,
|
||||
(value: boolean) => {
|
||||
if (!editor.value) {
|
||||
return;
|
||||
}
|
||||
editor.value.options.editable = value;
|
||||
if (value == true) {
|
||||
editor.value.commands.focus();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
const vEditor = new Editor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Underline,
|
||||
FontSize,
|
||||
TextStyle,
|
||||
TextAlign.configure({
|
||||
types: ["heading", "paragraph"],
|
||||
}),
|
||||
Color.configure({
|
||||
types: ["textStyle"],
|
||||
}),
|
||||
BackgroundColor.configure({
|
||||
types: ["textStyle"],
|
||||
}),
|
||||
CustomImage,
|
||||
Link,
|
||||
],
|
||||
editable: props.editable,
|
||||
content: props.modelValue,
|
||||
onUpdate: () => {
|
||||
emit("update:modelValue", editor.value?.getHTML());
|
||||
},
|
||||
onCreate: () => {
|
||||
emit("ready");
|
||||
},
|
||||
});
|
||||
editor.value = vEditor;
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
editor.value?.destroy();
|
||||
});
|
||||
|
||||
function chooseImage(e: Event) {
|
||||
const target = unwrap(e.target) as HTMLInputElement;
|
||||
imageUploadFiles.value = target.files;
|
||||
}
|
||||
|
||||
function colorConvert(val: string | undefined, defaultVal: string) {
|
||||
if (!val) {
|
||||
return defaultVal;
|
||||
}
|
||||
if (val.startsWith("rgb")) {
|
||||
const rgb = val.split("(")[1].split(")")[0].split(",");
|
||||
const vals: string[] = [];
|
||||
for (const subColor of rgb) {
|
||||
const hexSubColor = parseInt(subColor).toString(16);
|
||||
if (hexSubColor.length == 1) {
|
||||
vals.push("0");
|
||||
}
|
||||
vals.push(hexSubColor);
|
||||
}
|
||||
return `#${vals.join("")}`;
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
async function tryAddImage(bvModalEvt: Event) {
|
||||
if (imageUploadFiles.value === null || imageUploadFiles.value.length == 0) {
|
||||
addImageLink(bvModalEvt);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetImage = unwrap(unwrap(imageUploadFiles.value).item(0));
|
||||
let imageResult: {
|
||||
result: true;
|
||||
path: string;
|
||||
};
|
||||
try {
|
||||
const base64Binary = await getBase64FromFileObject(targetImage);
|
||||
imageResult = await SammoAPI.Misc.UploadImage({
|
||||
imageData: base64Binary,
|
||||
});
|
||||
} catch (e) {
|
||||
if (isString(e)) {
|
||||
alert(e);
|
||||
bvModalEvt.preventDefault();
|
||||
}
|
||||
|
||||
if (isObject(e) && "response" in e) {
|
||||
const axiosErr = e as AxiosError;
|
||||
if (axiosErr.response?.status === 413) {
|
||||
alert("허용 용량을 초과했습니다.");
|
||||
bvModalEvt.preventDefault();
|
||||
}
|
||||
}
|
||||
console.error(e);
|
||||
return false;
|
||||
}
|
||||
|
||||
const imagePath = imageResult.path;
|
||||
editor.value?.chain().focus().setImageEx({ src: imagePath }).run();
|
||||
}
|
||||
|
||||
function addImageLink(bvModalEvt: Event) {
|
||||
if (!imageLink.value) {
|
||||
alert("업로드할 이미지를 선택하거나, 이미지 주소를 입력해주세요.");
|
||||
bvModalEvt.preventDefault();
|
||||
return false;
|
||||
}
|
||||
editor.value?.chain().focus().setImageEx({ src: imageLink.value }).run();
|
||||
}
|
||||
|
||||
function resetModal() {
|
||||
imageLink.value = "";
|
||||
imageUploadFiles.value = null;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<div :class="['bg0', 'back_bar', teleportZone ? 'back_bar_teleport' : undefined]">
|
||||
<button type="button" class="btn btn-sammo-base2 back_btn" @click="back">
|
||||
{{ props.type == "close" ? "창 닫기" : "돌아가기" }}</button
|
||||
><button v-if="reloadable" type="button" class="btn btn-sammo-base2 reload_btn" @click="reload">갱신</button>
|
||||
<div v-else />
|
||||
<h2 class="title">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<template v-if="hasSlot">
|
||||
<slot></slot>
|
||||
</template>
|
||||
<div v-else-if="teleportZone" :id="teleportZone" class="teleport-zone"></div>
|
||||
<template v-else>
|
||||
<div> </div>
|
||||
<b-button
|
||||
v-if="toggleSearch !== undefined"
|
||||
class="btn-toggle-zoom"
|
||||
:variant="toggleSearch ? 'info' : 'secondary'"
|
||||
:pressed="toggleSearch"
|
||||
@click="toggleSearch = !toggleSearch"
|
||||
>
|
||||
{{ toggleSearch ? "검색 켜짐" : "검색 꺼짐" }}
|
||||
</b-button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import "@scss/game_bg.scss";
|
||||
import { type PropType, ref, watch, useSlots } from "vue";
|
||||
import VueTypes from "vue-types";
|
||||
|
||||
const props = defineProps({
|
||||
title: VueTypes.string.isRequired,
|
||||
type: {
|
||||
type: String as PropType<"normal" | "chief" | "close">,
|
||||
default: "normal",
|
||||
required: false,
|
||||
},
|
||||
searchable: {
|
||||
type: Boolean,
|
||||
default: undefined,
|
||||
required: false,
|
||||
},
|
||||
reloadable: {
|
||||
type: Boolean,
|
||||
default: undefined,
|
||||
required: false,
|
||||
},
|
||||
teleportZone: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
|
||||
const slots = useSlots();
|
||||
console.log(slots);
|
||||
const hasSlot = !!slots['default'];
|
||||
|
||||
const emit = defineEmits(["update:searchable", "reload"]);
|
||||
|
||||
const toggleSearch = ref(props.searchable);
|
||||
|
||||
watch(toggleSearch, (val) => {
|
||||
emit("update:searchable", val);
|
||||
});
|
||||
|
||||
function back() {
|
||||
if (props.type === "normal") {
|
||||
location.href = "./";
|
||||
} else if (props.type == "chief") {
|
||||
location.href = "v_chiefCenter.php";
|
||||
} else {
|
||||
//TODO: window.close하려면 부모창이 있어야함!
|
||||
window.close();
|
||||
}
|
||||
}
|
||||
function reload() {
|
||||
emit("reload");
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.back_bar {
|
||||
max-width: 1000px;
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
display: grid;
|
||||
grid-template-columns: 90px 90px 1fr 90px 90px;
|
||||
position: relative;
|
||||
height: 24pt;
|
||||
}
|
||||
|
||||
.back_bar.back_bar_teleport {
|
||||
grid-template-columns: 90px 90px 1fr 180px;
|
||||
}
|
||||
|
||||
.reload_btn {
|
||||
height: 24pt;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.teleport-zone {
|
||||
height: 24pt;
|
||||
}
|
||||
|
||||
.back_btn {
|
||||
height: 24pt;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.btn-toggle-zoom {
|
||||
height: 24pt;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.title {
|
||||
text-align: center;
|
||||
line-height: 24pt;
|
||||
font-size: 18pt;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user