파일 이식(0.31.1)

This commit is contained in:
2022-07-08 00:11:41 +09:00
parent db08cec2cb
commit 96d76a06ef
217 changed files with 29966 additions and 2 deletions
+393
View File
@@ -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>
+254
View File
@@ -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>
+460
View File
@@ -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>
+105
View File
@@ -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>
+31
View File
@@ -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>
+32
View File
@@ -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>
+969
View File
@@ -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" />&nbsp;잘라내기 </BDropdownItem>
<BDropdownItem @click="clipboardCopy"> <i class="bi bi-files" />&nbsp;복사하기 </BDropdownItem>
<BDropdownItem @click="clipboardPaste">
<i class="bi bi-clipboard-fill" />&nbsp;붙여넣기
</BDropdownItem>
<BDropdownDivider />
<BDropdownItem @click="setStoredActions">
<i class="bi bi-bookmark-plus-fill" />&nbsp;보관하기
</BDropdownItem>
<BDropdownItem @click="subRepeatCommand">
<i class="bi bi-arrow-repeat" />&nbsp;반복하기
</BDropdownItem>
<BDropdownDivider />
<BDropdownItem @click="eraseSelectedTurnList"> <i class="bi bi-eraser" />&nbsp;비우기 </BDropdownItem>
<BDropdownItem @click="eraseAndPullCommand">
<i class="bi bi-arrow-bar-up" />&nbsp;지우고 당기기
</BDropdownItem>
<BDropdownItem @click="pushEmptyCommand">
<i class="bi bi-arrow-bar-down" />&nbsp;뒤로 밀기
</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>
+218
View File
@@ -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>
+221
View File
@@ -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>
+266
View File
@@ -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>
+1243
View File
@@ -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" />&nbsp;보관하기</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>
+148
View File
@@ -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>
+137
View File
@@ -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>
+154
View File
@@ -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>
+493
View File
@@ -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>
+131
View File
@@ -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>
+35
View File
@@ -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>
+46
View File
@@ -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>
+72
View File
@@ -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>
+516
View File
@@ -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>
+125
View File
@@ -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>&nbsp;</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>