Compare commits
202 Commits
3fbcdaaf56
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e81fdc24bb | |||
| 778de8ca14 | |||
| 00c37c0a37 | |||
| a56580ce27 | |||
| 11f886cd71 | |||
| ac07236936 | |||
| 9aa37b355e | |||
| 12ad7d36ff | |||
| c77a6ff811 | |||
| 0a82fca6d3 | |||
| 1e57e7c33e | |||
| c484948a5e | |||
| 4748b15cc4 | |||
| 9ccba70ede | |||
| 1868b3e248 | |||
| 9f2627faf8 | |||
| a7b1fb902c | |||
| 6afce2ef58 | |||
| fff01ff30f | |||
| a576a93a0b | |||
| a33426f3c2 | |||
| 0dc309642c | |||
| 8dc1a2dc3c | |||
| e477242f16 | |||
| f75156bc62 | |||
| e379f490a4 | |||
| c210ed7fac | |||
| 92b7f60c75 | |||
| 7e7c638ef1 | |||
| b19ef017d6 | |||
| 8bcbf3bd1d | |||
| 540d4814d9 | |||
| 884412f5a9 | |||
| 4814d24efb | |||
| 34095e0d38 | |||
| bbad25b55b | |||
| c76f4be928 | |||
| d89ff4ea7f | |||
| 7a7dba010e | |||
| b81282b42e | |||
| 617d3b281e | |||
| c52e92e3ce | |||
| a9e981baa0 | |||
| 8fdcc75826 | |||
| d82f6b6965 | |||
| 3a3711b713 | |||
| c1778b3d49 | |||
| 514c28b900 | |||
| 09a3295920 | |||
| 400737dab8 | |||
| 27f146b945 | |||
| fb9a2f11fe | |||
| 4e211c8834 | |||
| 79bef2775c | |||
| 3fd4f5080d | |||
| f677a1d61b | |||
| d5aafc88a9 | |||
| 52a4b99214 | |||
| ff4c9c6bf0 | |||
| 2756bbb2c2 | |||
| 6c99fa0401 | |||
| c38f44c182 | |||
| 167788ac15 | |||
| 2c64cca921 | |||
| 894b23166c | |||
| e3a5eeb50d | |||
| 5887ecb638 | |||
| 78c96f8bce | |||
| 5ddee59227 | |||
| 94992afbe3 | |||
| 717eaa6ed8 | |||
| 9fb78329cc | |||
| af9465c127 | |||
| 5c0b83a0a3 | |||
| b1da083557 | |||
| a1bffa1cdb | |||
| 98ce81dfb2 | |||
| f115381955 | |||
| 088ea5d929 | |||
| ee6bbfd442 | |||
| 75f70cfd25 | |||
| 1f1821d607 | |||
| 98c818f028 | |||
| 0ea65867a8 | |||
| 2b59f8719a | |||
| 4e4a700ced | |||
| c432450455 | |||
| 5082cfaac9 | |||
| 9dd60ae054 | |||
| ac6c51a714 | |||
| 7d608c19e7 | |||
| a48e49ab9c | |||
| 9bd1ced9d4 | |||
| 4fca2712bf | |||
| c2a09f1c2a | |||
| c2c8f2a65b | |||
| 4cb3de356f | |||
| 0e883dd688 | |||
| 4536d34b92 | |||
| a9cd258af8 | |||
| e78f59b78a | |||
| 7167640e43 | |||
| 866ff0a42b | |||
| b2304916d6 | |||
| 7f83ee2ee2 | |||
| 7d33b77410 | |||
| 66f88ef1b1 | |||
| 2b513a91b0 | |||
| 0026cba23d | |||
| c587fb75f2 | |||
| b9eedf4f07 | |||
| a5e365328c | |||
| 7253b864d9 | |||
| 0ce205a4a0 | |||
| c3205a3e74 | |||
| 575d4dc5ab | |||
| 2c728c7037 | |||
| 14b2f01ef1 | |||
| 1c882e0d1c | |||
| f8e7c95c8b | |||
| 7bd48b5e8d | |||
| 1ac7539dd0 | |||
| eb74ef389e | |||
| dad7bcfbed | |||
| 892fda3f47 | |||
| 2a798744fb | |||
| ac8e41fcce | |||
| 80fdc45174 | |||
| dabadaa1c9 | |||
| d64cb69f1e | |||
| 6a675c28bc | |||
| 7c645b0d0b | |||
| 6587e4f522 | |||
| cd75aa5b13 | |||
| a483580906 | |||
| e8898f76f0 | |||
| 51a37342dd | |||
| 205aef7a3c | |||
| 4a0da46f2c | |||
| 158914048b | |||
| 5cce3e6eca | |||
| 2b80724692 | |||
| 3ac39dcd45 | |||
| 20defc5b0f | |||
| dcdb24c591 | |||
| e499c5aee2 | |||
| 76131aac07 | |||
| adfafb94e4 | |||
| e48fdd3c5c | |||
| 0e1cb94be0 | |||
| ef627d06bc | |||
| 092b7a9763 | |||
| 9dea0b08a6 | |||
| b80d43c375 | |||
| f9eb368fe5 | |||
| cd52e36f70 | |||
| 78c07c7806 | |||
| 6580924bbe | |||
| c1f00da176 | |||
| 3de8f53e0f | |||
| 4301c84b33 | |||
| 145af06874 | |||
| c889a813f3 | |||
| d4ded694d6 | |||
| 7d946f49c4 | |||
| 94e8d03aa2 | |||
| 5c84af12ea | |||
| 018e59a492 | |||
| aebb8b90a4 | |||
| 7b765d884e | |||
| 0e378c2c24 | |||
| 46351fe76c | |||
| 3a08658970 | |||
| 88fdd5207a | |||
| 0b5d1a6a22 | |||
| 9ac9296a12 | |||
| c2e729b534 | |||
| 875eab1dc6 | |||
| c9b23df46b | |||
| cd649473d3 | |||
| dc9be7db98 | |||
| c75f3b6321 | |||
| afa4020b31 | |||
| b078f30bf3 | |||
| 6697bde5ed | |||
| c00bd956e7 | |||
| ac682f168d | |||
| 45f202ab23 | |||
| a08c9782eb | |||
| fb0143f5e4 | |||
| b78501208e | |||
| b92afbe3e1 | |||
| dcb3c445bd | |||
| de685af94a | |||
| 7210f55d42 | |||
| b80c100953 | |||
| 298b5c645d | |||
| d9e2751a4f | |||
| 16c827cb18 | |||
| 7b39195873 | |||
| f81e10c53c | |||
| 8e1be17f29 |
@@ -1,2 +0,0 @@
|
|||||||
EVEAL_API_URL=/api/
|
|
||||||
EVEPRAISAL_URL=/appraisal/
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
eveal_api_url=https://api.eveal.shendai.rip/
|
|
||||||
evepraisal_api_url=https://evepraisal.shendai.rip/
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,6 +11,7 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
docker-compose.yml
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
FROM node:18-alpine as build
|
FROM node:18-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY . ./
|
COPY . ./
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM nginx
|
FROM nginx:alpine
|
||||||
|
ENV NGINX_ENVSUBST_OUTPUT_DIR=/usr/share/nginx/html
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
RUN mkdir etc/nginx/templates && \
|
||||||
|
mv /usr/share/nginx/html/index.html /etc/nginx/templates/index.html.template
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
9
nginx.conf
Normal file
9
nginx.conf
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
server {
|
||||||
|
listen 80 http2;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ $uri.html /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
2811
package-lock.json
generated
2811
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
26
package.json
26
package.json
@@ -1,27 +1,39 @@
|
|||||||
{
|
{
|
||||||
"name": "eveal-frontend",
|
"name": "gemory",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite --host --debug",
|
||||||
"build": "vue-tsc && vite build",
|
"build": "vue-tsc && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@heroicons/vue": "^2.0.18",
|
||||||
|
"@vueuse/components": "^10.5.0",
|
||||||
"@vueuse/core": "^10.2.1",
|
"@vueuse/core": "^10.2.1",
|
||||||
"@vueuse/integrations": "^10.2.1",
|
"@vueuse/integrations": "^10.2.1",
|
||||||
"axios": "^1.4.0",
|
"axios": "^1.4.0",
|
||||||
"vue": "^3.3.4"
|
"axios-rate-limit": "^1.3.1",
|
||||||
|
"gemory": "file:",
|
||||||
|
"loglevel": "^1.8.1",
|
||||||
|
"loglevel-plugin-prefix": "^0.8.4",
|
||||||
|
"oidc-client-ts": "^3.0.1",
|
||||||
|
"pinia": "^2.1.6",
|
||||||
|
"vue": "^3.3.4",
|
||||||
|
"vue-router": "^4.2.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.4.5",
|
"@types/node": "^20.4.5",
|
||||||
"@vitejs/plugin-vue": "^4.2.3",
|
"@vitejs/plugin-vue": "^5.0.4",
|
||||||
"autoprefixer": "^10.4.14",
|
"autoprefixer": "^10.4.14",
|
||||||
"postcss": "^8.4.27",
|
"postcss": "^8.4.27",
|
||||||
"tailwindcss": "^3.3.3",
|
"tailwindcss": "^3.3.3",
|
||||||
"typescript": "^5.0.2",
|
"typescript": "^5.0.2",
|
||||||
"vite": "^4.4.5",
|
"vite": "^6.3.5",
|
||||||
"vue-tsc": "^1.8.5"
|
"vite-plugin-runtime-env": "^0.1.1",
|
||||||
|
"vitest": "^3.1.3",
|
||||||
|
"vue-tsc": "^2.0.18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
|
'postcss-import': {},
|
||||||
|
'tailwindcss/nesting': {},
|
||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
},
|
},
|
||||||
|
|||||||
29
src/App.vue
29
src/App.vue
@@ -1,7 +1,32 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Reprocess } from './reprocess';
|
import { useAuthStore } from '@/auth';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { RouterView, useRoute } from 'vue-router';
|
||||||
|
import { Sidebar } from './sidebar';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
const hideSidebar = computed(() => {
|
||||||
|
return !authStore.isLoggedIn || route.name === 'callback' || route.name === 'about';
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Reprocess />
|
<template v-if="hideSidebar">
|
||||||
|
<RouterView />
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<Sidebar />
|
||||||
|
<div class="main-container">
|
||||||
|
<RouterView />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="postcss">
|
||||||
|
div.main-container {
|
||||||
|
@apply px-4 sm:ml-64;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|||||||
49
src/auth.ts
Normal file
49
src/auth.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import log from "loglevel";
|
||||||
|
import { Log, User, UserManager, WebStorageStateStore } from "oidc-client-ts";
|
||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
|
||||||
|
Log.setLogger(log);
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
const userManager = new UserManager({
|
||||||
|
authority: import.meta.env.VITE_AUTH_AUTHORITY,
|
||||||
|
client_id: import.meta.env.VITE_AUTH_CLIENT_ID,
|
||||||
|
client_secret: import.meta.env.VITE_AUTH_CLIENT_SECRET,
|
||||||
|
redirect_uri: import.meta.env.VITE_AUTH_REDIRECT_URI,
|
||||||
|
scope: import.meta.env.VITE_AUTH_SCOPE,
|
||||||
|
stateStore: new WebStorageStateStore({ store: window.localStorage }),
|
||||||
|
userStore: new WebStorageStateStore({ store: window.localStorage })
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = ref<User>();
|
||||||
|
const isLoggedIn = computed(() => user.value?.expired === false);
|
||||||
|
const accessToken = computed(() => user.value?.access_token);
|
||||||
|
const username = computed(() => user.value?.profile.name ?? "");
|
||||||
|
const userId = computed(() => user.value?.profile.sub ?? "");
|
||||||
|
|
||||||
|
const redirect = async () => {
|
||||||
|
await userManager.signinRedirect();
|
||||||
|
log.info("Redirecting to login page");
|
||||||
|
}
|
||||||
|
const login = async () => {
|
||||||
|
await userManager.signinCallback();
|
||||||
|
log.debug("Logged in");
|
||||||
|
}
|
||||||
|
const logout = async () => {
|
||||||
|
await userManager.signoutRedirect();
|
||||||
|
log.debug("Logged out");
|
||||||
|
}
|
||||||
|
|
||||||
|
const setUser = (u?: User | null) => {
|
||||||
|
if (u) {
|
||||||
|
user.value = u;
|
||||||
|
log.debug("User loaded", u.profile.name);
|
||||||
|
} else {
|
||||||
|
user.value = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
userManager.events.addUserLoaded(setUser);
|
||||||
|
userManager.getUser().then(setUser);
|
||||||
|
return { redirect, login, logout, isLoggedIn, accessToken, username, userId };
|
||||||
|
});
|
||||||
23
src/components/ClipboardButton.vue
Normal file
23
src/components/ClipboardButton.vue
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { copyToClipboard } from '@/utils';
|
||||||
|
import { ClipboardIcon } from '@heroicons/vue/24/outline';
|
||||||
|
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value?: string;
|
||||||
|
}
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const doCopy = () => {
|
||||||
|
if (!props.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
copyToClipboard(props.value);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button class="btn-icon" title="Copy to clipboard" @click="doCopy">
|
||||||
|
<ClipboardIcon />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
59
src/components/Dropdown.vue
Normal file
59
src/components/Dropdown.vue
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import { vOnClickOutside } from '@vueuse/components';
|
||||||
|
import { useEventListener } from '@vueuse/core';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
const isOpen = ref(false);
|
||||||
|
|
||||||
|
useEventListener('keyup', e => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
isOpen.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="dropdown" :class="{'dropdown-open': isOpen, 'dropdown-close': !isOpen}" v-on-click-outside="() => isOpen = false">
|
||||||
|
<button @click="isOpen = !isOpen">
|
||||||
|
<slot name="button" />
|
||||||
|
<Transition name="flip">
|
||||||
|
<ChevronDownIcon v-if="!isOpen" class="chevron" />
|
||||||
|
<ChevronUpIcon v-else class="chevron" />
|
||||||
|
</Transition>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Transition name="fade">
|
||||||
|
<div v-if="isOpen" class="relative">
|
||||||
|
<div class="z-10 divide-y rounded-b-md absolute">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="postcss">
|
||||||
|
.chevron {
|
||||||
|
@apply w-4 h-4 ms-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from, .fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-active, .fade-leave-active {
|
||||||
|
@apply transition-opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flip-enter-from, .flip-leave-to {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flip-enter-active {
|
||||||
|
@apply transition-transform;
|
||||||
|
}
|
||||||
|
.flip-leave-active {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
9
src/components/LoadingSpinner.vue
Normal file
9
src/components/LoadingSpinner.vue
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<template>
|
||||||
|
<div role="status">
|
||||||
|
<svg aria-hidden="true" class="inline w-6 h-6 text-slate-500 animate-spin fill-slate-100" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor"/>
|
||||||
|
<path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill"/>
|
||||||
|
</svg>
|
||||||
|
<span class="sr-only">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
45
src/components/Modal.vue
Normal file
45
src/components/Modal.vue
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { vOnClickOutside } from '@vueuse/components';
|
||||||
|
import { useEventListener } from '@vueuse/core';
|
||||||
|
import { watch } from 'vue';
|
||||||
|
|
||||||
|
const open = defineModel('open', { default: false });
|
||||||
|
|
||||||
|
watch(open, value => {
|
||||||
|
if (value) {
|
||||||
|
document.body.classList.add('overflow-hidden');
|
||||||
|
} else {
|
||||||
|
document.body.classList.remove('overflow-hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
useEventListener('keyup', e => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
open.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Transition name="fade">
|
||||||
|
<template v-if="open">
|
||||||
|
<div class="fixed inset-0 z-10">
|
||||||
|
<div class="absolute bg-black opacity-80 inset-0 z-0" />
|
||||||
|
<div class="absolute grid inset-0">
|
||||||
|
<div class="justify-self-center m-auto" v-on-click-outside="() => open = false">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="postcss">
|
||||||
|
.fade-enter-from, .fade-leave-to {
|
||||||
|
@apply opacity-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-active, .fade-leave-active {
|
||||||
|
@apply transition-opacity;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
19
src/components/ProgressBar.vue
Normal file
19
src/components/ProgressBar.vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const percentage = computed(() => (props.value / props.total) * 100);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full bg-gray-600 rounded-full h-2.5">
|
||||||
|
<div class="bg-emerald-600 h-2.5 rounded-full" :style="{ width: percentage + '%'}" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
17
src/components/SliderCheckbox.vue
Normal file
17
src/components/SliderCheckbox.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
const modelValue = defineModel({ default: false });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<label class="flex items-center relative w-max cursor-pointer select-none">
|
||||||
|
<input type="checkbox" class="appearance-none transition-colors cursor-pointer w-10 h-5 rounded-full checked:bg-emerald-500 peer" v-model="modelValue" />
|
||||||
|
<span class="w-5 h-5 right-5 absolute rounded-full transform transition-transform bg-slate-100 peer-checked:bg-emerald-200" />
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="postcss">
|
||||||
|
input:checked ~ span:last-child {
|
||||||
|
--tw-translate-x: 1.25rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
8
src/components/index.ts
Normal file
8
src/components/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export { default as ClipboardButton } from './ClipboardButton.vue';
|
||||||
|
export { default as Dropdown } from './Dropdown.vue';
|
||||||
|
export { default as LoadingSpinner } from './LoadingSpinner.vue';
|
||||||
|
export { default as Modal } from './Modal.vue';
|
||||||
|
export { default as ProgressBar } from './ProgressBar.vue';
|
||||||
|
export { default as SliderCheckbox } from './SliderCheckbox.vue';
|
||||||
|
export { default as Tooltip } from './tooltip/Tooltip.vue';
|
||||||
|
|
||||||
48
src/components/table/SortableHeader.vue
Normal file
48
src/components/table/SortableHeader.vue
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { HeaderComponent, SortDirection } from './sort';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
currentSortKey: string | null;
|
||||||
|
sortDirection?: SortDirection | null;
|
||||||
|
showColumn?: (k: string) => boolean;
|
||||||
|
unsortable?: boolean;
|
||||||
|
sortKey: string;
|
||||||
|
headerComponent?: HeaderComponent;
|
||||||
|
}
|
||||||
|
interface Emit {
|
||||||
|
(e: 'sort', key: string, direction: SortDirection): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
showColumn: () => () => true,
|
||||||
|
unsortable: false,
|
||||||
|
headerComponent: 'th',
|
||||||
|
});
|
||||||
|
const emit = defineEmits<Emit>();
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<component v-if="showColumn(sortKey)" :is="headerComponent" class="sort-header">
|
||||||
|
<slot />
|
||||||
|
<template v-if="!unsortable">
|
||||||
|
<span class="asc" :class="{'opacity-20': (currentSortKey != sortKey || sortDirection != 'asc')}" @click="emit('sort', sortKey, 'asc')">▲</span>
|
||||||
|
<span class="desc" :class="{'opacity-20': (currentSortKey != sortKey || sortDirection != 'desc')}" @click="emit('sort', sortKey, 'desc')">▼</span>
|
||||||
|
</template>
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="postcss">
|
||||||
|
.sort-header {
|
||||||
|
@apply relative h-8 pe-3;
|
||||||
|
}
|
||||||
|
span.asc, span.desc {
|
||||||
|
@apply absolute end-1 cursor-pointer text-xs transition-opacity;
|
||||||
|
}
|
||||||
|
span.asc {
|
||||||
|
@apply top-0.5;
|
||||||
|
}
|
||||||
|
span.desc {
|
||||||
|
@apply bottom-0.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
95
src/components/table/VirtualScrollTable.vue
Normal file
95
src/components/table/VirtualScrollTable.vue
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useElementBounding, useVirtualList } from '@vueuse/core';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
list?: any[];
|
||||||
|
itemHeight: number;
|
||||||
|
headerHeight?: number;
|
||||||
|
footerHeight?: number;
|
||||||
|
bottom?: string; // FIXME: use css variable
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
list: () => [],
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const { list: values, containerProps, wrapperProps } = useVirtualList(computed(() => props.list), {
|
||||||
|
itemHeight: () => props.itemHeight,
|
||||||
|
overscan: 3
|
||||||
|
})
|
||||||
|
|
||||||
|
const tableTop = ref<HTMLSpanElement | null>(null);
|
||||||
|
const { bottom: offset } = useElementBounding(tableTop);
|
||||||
|
const ypx = computed(() => {
|
||||||
|
let y = (offset.value ?? 0) + 'px';
|
||||||
|
|
||||||
|
if (props.bottom) {
|
||||||
|
y = `calc(${y} + ${props.bottom})`;
|
||||||
|
}
|
||||||
|
return y;
|
||||||
|
})
|
||||||
|
const computedHeaderHeight = computed(() => {
|
||||||
|
const h = props.headerHeight ?? props.itemHeight ?? 0;
|
||||||
|
|
||||||
|
return h + 'px';
|
||||||
|
})
|
||||||
|
const computedFooterHeight = computed(() => {
|
||||||
|
const h = props.footerHeight ?? 0;
|
||||||
|
|
||||||
|
return h + 'px';
|
||||||
|
})
|
||||||
|
const computedWrapperProps = computed(() => ({
|
||||||
|
...wrapperProps.value,
|
||||||
|
style: {
|
||||||
|
...wrapperProps.value.style,
|
||||||
|
height: `calc(${wrapperProps.value.style.height} + ${computedHeaderHeight.value} + ${computedFooterHeight.value} + 1px)`
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
const itemHeightStyle = computed(() => {
|
||||||
|
const h = props.itemHeight ?? 0;
|
||||||
|
|
||||||
|
return h + 'px';
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span ref="tableTop" class="h-0" />
|
||||||
|
<div v-if="list.length > 0" v-bind="containerProps" class="table-container">
|
||||||
|
<div v-bind="computedWrapperProps">
|
||||||
|
<table>
|
||||||
|
<slot :list="values" />
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<slot v-else name="empty" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="postcss">
|
||||||
|
div.table-container {
|
||||||
|
@apply bg-slate-600;
|
||||||
|
max-height: calc(100vh - v-bind(ypx));
|
||||||
|
:deep(>div) {
|
||||||
|
@apply bg-slate-800;
|
||||||
|
>table {
|
||||||
|
>thead {
|
||||||
|
@apply sticky z-10;
|
||||||
|
top: -1px;
|
||||||
|
}
|
||||||
|
>tfoot {
|
||||||
|
@apply bg-slate-600 sticky z-10;
|
||||||
|
bottom: -1px;
|
||||||
|
}
|
||||||
|
>*>tr, >*>tr>td {
|
||||||
|
height: v-bind(itemHeightStyle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
margin-top: v-bind(computedHeaderHeight);
|
||||||
|
margin-bottom: v-bind(computedFooterHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
6
src/components/table/index.ts
Normal file
6
src/components/table/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export * from './sort';
|
||||||
|
|
||||||
|
export { default as SortableHeader } from './SortableHeader.vue';
|
||||||
|
export { default as VirtualScrollTable } from './VirtualScrollTable.vue';
|
||||||
|
|
||||||
|
|
||||||
44
src/components/table/sort.spec.ts
Normal file
44
src/components/table/sort.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useSort } from './sort'
|
||||||
|
|
||||||
|
describe('useSort', () => {
|
||||||
|
const array = ref([{ key1: 'b', key2: 'a' }, { key1: 'a', key2: 'b' }])
|
||||||
|
|
||||||
|
test('Returns expected properties with default options', () => {
|
||||||
|
const { sortedArray, headerProps, showColumn } = useSort(array)
|
||||||
|
|
||||||
|
expect(sortedArray).toBeDefined()
|
||||||
|
expect(headerProps).toBeDefined()
|
||||||
|
expect(showColumn).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Returns expected properties with custom options', () => {
|
||||||
|
const { sortedArray, headerProps, showColumn } = useSort(array, { defaultSortKey: 'key1', defaultSortDirection: 'asc' })
|
||||||
|
|
||||||
|
expect(sortedArray.value[0].key1).toBe('a')
|
||||||
|
expect(headerProps.value.currentSortKey).toBe('key1')
|
||||||
|
expect(headerProps.value.sortDirection).toBe('asc')
|
||||||
|
expect(showColumn('key1')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Sorts array in ascending order', () => {
|
||||||
|
const { sortedArray, headerProps } = useSort(array, { defaultSortKey: 'key1', defaultSortDirection: 'asc' })
|
||||||
|
|
||||||
|
headerProps.value.onSort('key1', 'asc')
|
||||||
|
expect(sortedArray.value[0].key1).toBe('a')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Sorts array in descending order', () => {
|
||||||
|
const { sortedArray, headerProps } = useSort(array, { defaultSortKey: 'key1', defaultSortDirection: 'desc' })
|
||||||
|
|
||||||
|
headerProps.value.onSort('key1', 'desc')
|
||||||
|
expect(sortedArray.value[0].key1).toBe('b')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Hides ignored columns', () => {
|
||||||
|
const { showColumn } = useSort(array, { ignoredColums: ['key1'] })
|
||||||
|
|
||||||
|
expect(showColumn('key1')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
48
src/components/table/sort.ts
Normal file
48
src/components/table/sort.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { Component, DefineComponent, MaybeRefOrGetter, computed, ref, toValue } from "vue";
|
||||||
|
|
||||||
|
export type HeaderComponent = Component | DefineComponent | string;
|
||||||
|
export type SortDirection = "asc" | "desc";
|
||||||
|
export type UseSortOptions = {
|
||||||
|
defaultSortKey?: string;
|
||||||
|
defaultSortDirection?: SortDirection;
|
||||||
|
ignoredColums?: MaybeRefOrGetter<string[]>;
|
||||||
|
headerComponent?: HeaderComponent;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSort = <T>(array: MaybeRefOrGetter<T[]>, options?: UseSortOptions) => {
|
||||||
|
const sortKey = ref<string | null>(options?.defaultSortKey ?? null);
|
||||||
|
const sortDirection = ref<SortDirection | null>(options?.defaultSortDirection ?? null);
|
||||||
|
const sortBy = (key: string, direction: SortDirection) => {
|
||||||
|
sortKey.value = key;
|
||||||
|
sortDirection.value = direction;
|
||||||
|
};
|
||||||
|
const showColumn = (sortKey: string) => !toValue(options?.ignoredColums)?.includes(sortKey);
|
||||||
|
const headerProps = computed(() => ({
|
||||||
|
onSort: sortBy,
|
||||||
|
showColumn,
|
||||||
|
currentSortKey: sortKey.value,
|
||||||
|
sortDirection: sortDirection.value,
|
||||||
|
headerComponent: options?.headerComponent,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const sortedArray = computed(() => toValue(array).toSorted((a, b) => {
|
||||||
|
if (sortKey.value === null || sortDirection.value === null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aValue = (a as any)[sortKey.value];
|
||||||
|
const bValue = (b as any)[sortKey.value];
|
||||||
|
|
||||||
|
if (aValue === bValue) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortDirection.value === "asc") {
|
||||||
|
return aValue > bValue ? 1 : -1;
|
||||||
|
} else {
|
||||||
|
return aValue > bValue ? -1 : 1;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { sortedArray, headerProps, showColumn };
|
||||||
|
}
|
||||||
46
src/components/tooltip/Tooltip.vue
Normal file
46
src/components/tooltip/Tooltip.vue
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { vElementHover } from '@vueuse/components';
|
||||||
|
import { useElementBounding } from '@vueuse/core';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useSharedWindowSize } from './tooltip';
|
||||||
|
|
||||||
|
const open = defineModel('open', { default: false });
|
||||||
|
|
||||||
|
const { width, height } = useSharedWindowSize();
|
||||||
|
const mainDiv = ref<HTMLDivElement | null>(null);
|
||||||
|
const { top, left } = useElementBounding(mainDiv);
|
||||||
|
|
||||||
|
const positions = computed(() => {
|
||||||
|
if (top.value < height.value / 2) {
|
||||||
|
if (left.value < width.value / 2) {
|
||||||
|
return ['top', 'left'];
|
||||||
|
}
|
||||||
|
return ['top', 'right'];
|
||||||
|
}
|
||||||
|
if (left.value < width.value / 2) {
|
||||||
|
return ['bottom', 'left'];
|
||||||
|
}
|
||||||
|
return ['bottom', 'right'];
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="mainDiv" clas="flex flex-col items-center justify-center" :class="{
|
||||||
|
'open': open,
|
||||||
|
'tooltip-top': positions.includes('top'),
|
||||||
|
'tooltip-bottom': positions.includes('bottom'),
|
||||||
|
'tooltip-left': positions.includes('left'),
|
||||||
|
'tooltip-right': positions.includes('right')
|
||||||
|
}">
|
||||||
|
<div v-element-hover="(h: boolean) => open = h" class="m-auto header">
|
||||||
|
<slot name="header" />
|
||||||
|
</div>
|
||||||
|
<div v-if="open" class="m-auto">
|
||||||
|
<div class="z-10 relative">
|
||||||
|
<div class="absolute">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
3
src/components/tooltip/tooltip.ts
Normal file
3
src/components/tooltip/tooltip.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { createSharedComposable, useWindowSize } from "@vueuse/core";
|
||||||
|
|
||||||
|
export const useSharedWindowSize = createSharedComposable(useWindowSize);
|
||||||
23
src/formaters.spec.ts
Normal file
23
src/formaters.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import { formatEveDate, formatIsk } from './formaters'
|
||||||
|
|
||||||
|
describe('formatIsk', () => {
|
||||||
|
test('Formats ISK correctly', () => {
|
||||||
|
expect(formatIsk(123456789)).toBe('123.456.789,00 ISK')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatEveDate', () => {
|
||||||
|
test('Formats EVE date correctly', () => {
|
||||||
|
const date = new Date(Date.UTC(2022, 0, 1, 0, 0))
|
||||||
|
expect(formatEveDate(date)).toBe('2022.01.01 00:00')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Returns empty string for undefined date', () => {
|
||||||
|
expect(formatEveDate()).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Returns empty string for null date', () => {
|
||||||
|
expect(formatEveDate(null)).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,10 +1,21 @@
|
|||||||
export const iskFormater = new Intl.NumberFormat("is-IS", {
|
const iskFormater = new Intl.NumberFormat("is-IS", {
|
||||||
style: "currency",
|
|
||||||
currency: "ISK",
|
|
||||||
minimumFractionDigits: 2,
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const formatIsk = (value: number | bigint) => iskFormater.format(value) + " ISK";
|
||||||
|
|
||||||
export const percentFormater = new Intl.NumberFormat("en-US", {
|
export const percentFormater = new Intl.NumberFormat("en-US", {
|
||||||
style: "percent",
|
style: "percent",
|
||||||
minimumFractionDigits: 0
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const timeFormat = new Intl.NumberFormat("en-US", {
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
minimumIntegerDigits: 2
|
||||||
|
});
|
||||||
|
|
||||||
|
export const formatEveDate = (date?: Date | null) => !date ? '' : `${date.getUTCFullYear()}.${timeFormat.format(date.getUTCMonth() + 1)}.${timeFormat.format(date.getUTCDate())} ${timeFormat.format(date.getUTCHours())}:${timeFormat.format(date.getUTCMinutes())}`;
|
||||||
8
src/logger.ts
Normal file
8
src/logger.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import log from "loglevel";
|
||||||
|
import { apply, reg } from "loglevel-plugin-prefix";
|
||||||
|
|
||||||
|
export function initLogger() {
|
||||||
|
log.setLevel(import.meta.env.VITE_LOG_LEVEL);
|
||||||
|
reg(log);
|
||||||
|
apply(log, {template: '[%t] %l:'});
|
||||||
|
}
|
||||||
37
src/main.ts
37
src/main.ts
@@ -1,5 +1,34 @@
|
|||||||
import { createApp } from 'vue'
|
import { useAuthStore } from "@/auth";
|
||||||
import App from './App.vue'
|
import { createPinia } from 'pinia';
|
||||||
import './style.css'
|
import { createApp } from 'vue';
|
||||||
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
|
import App from './App.vue';
|
||||||
|
import { initLogger } from './logger';
|
||||||
|
import { routes } from './routes';
|
||||||
|
import './style.css';
|
||||||
|
|
||||||
createApp(App).mount('#app')
|
initLogger();
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
const pinia = createPinia();
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(pinia);
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
router.beforeEach(async to => {
|
||||||
|
if (to.name === 'callback') {
|
||||||
|
await authStore.login();
|
||||||
|
return { name: 'home' };
|
||||||
|
} else if (!authStore.isLoggedIn) {
|
||||||
|
await authStore.redirect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(router);
|
||||||
|
|
||||||
|
app.mount('#app');
|
||||||
|
|||||||
3
src/marbas/MarbasObject.ts
Normal file
3
src/marbas/MarbasObject.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export type MarbasObject = {
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
3
src/marbas/index.ts
Normal file
3
src/marbas/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './MarbasObject';
|
||||||
|
export * from './marbasService';
|
||||||
|
|
||||||
56
src/marbas/marbasService.ts
Normal file
56
src/marbas/marbasService.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { useAuthStore } from "@/auth";
|
||||||
|
import { logResource } from "@/service";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
export const marbasAxiosInstance = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_MARBAS_URL,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
marbasAxiosInstance.interceptors.request.use(async r => {
|
||||||
|
if (!authStore.isLoggedIn) {
|
||||||
|
await authStore.redirect();
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = authStore.accessToken;
|
||||||
|
|
||||||
|
if (accessToken) {
|
||||||
|
r.headers.Authorization = `Bearer ${accessToken}`;
|
||||||
|
}
|
||||||
|
if (!r.params?.page_size) {
|
||||||
|
r.params = { ...r.params, page_size: 250 };
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
})
|
||||||
|
logResource(marbasAxiosInstance)
|
||||||
|
marbasAxiosInstance.interceptors.response.use(async r => {
|
||||||
|
if (r.status === 401) {
|
||||||
|
await authStore.redirect();
|
||||||
|
|
||||||
|
return marbasAxiosInstance.request(r.config);
|
||||||
|
}
|
||||||
|
|
||||||
|
let next: string = r.data?.next;
|
||||||
|
let results = r.data?.results;
|
||||||
|
|
||||||
|
if (next) {
|
||||||
|
if (!next.startsWith(import.meta.env.VITE_MARBAS_URL)) { // FIME remove once the API is fixed
|
||||||
|
next = import.meta.env.VITE_MARBAS_URL + next.replace(/http(s)?:\/\/[^/]+\//g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
results = results.concat((await marbasAxiosInstance.request({
|
||||||
|
...r.config,
|
||||||
|
url: next,
|
||||||
|
baseURL: '',
|
||||||
|
})).data);
|
||||||
|
}
|
||||||
|
if (results) {
|
||||||
|
r.data = results;
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
})
|
||||||
35
src/market/RegionalMarketCache.spec.ts
Normal file
35
src/market/RegionalMarketCache.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import { RegionalMarketCache } from './RegionalMarketCache'
|
||||||
|
|
||||||
|
describe('RegionalMarketCache', () => {
|
||||||
|
test('should cache and retrieve values', async () => {
|
||||||
|
const cache = new RegionalMarketCache<string>(1000)
|
||||||
|
|
||||||
|
cache.set(1, 1, 'test')
|
||||||
|
expect(cache.get(1, 1)).toBe('test')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should remove values', async () => {
|
||||||
|
const cache = new RegionalMarketCache<string>(1000)
|
||||||
|
|
||||||
|
cache.set(1, 1, 'test')
|
||||||
|
cache.remove(1, 1)
|
||||||
|
expect(cache.get(1, 1)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should compute values if absent', async () => {
|
||||||
|
const cache = new RegionalMarketCache<string>(1000)
|
||||||
|
const value = await cache.computeIfAbsent(1, 1, () => Promise.resolve('test'))
|
||||||
|
|
||||||
|
expect(value).toBe('test')
|
||||||
|
expect(cache.get(1, 1)).toBe('test')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should expire values', async () => {
|
||||||
|
const cache = new RegionalMarketCache<string>(1)
|
||||||
|
|
||||||
|
cache.set(1, 1, 'test')
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10))
|
||||||
|
expect(cache.get(1, 1)).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
50
src/market/RegionalMarketCache.ts
Normal file
50
src/market/RegionalMarketCache.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
class CacheEntry<T> {
|
||||||
|
public value: T;
|
||||||
|
public expiration: Date;
|
||||||
|
|
||||||
|
constructor(value: T, expiration: Date) {
|
||||||
|
this.value = value;
|
||||||
|
this.expiration = expiration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExpirationSupplier<T> = (v: T) => Date;
|
||||||
|
|
||||||
|
export class RegionalMarketCache<T> {
|
||||||
|
private cache: Record<number, Record<number, CacheEntry<T>>>;
|
||||||
|
private expirationSupplier: (v: T) => Date;
|
||||||
|
|
||||||
|
constructor(expiration: ExpirationSupplier<T> | number) {
|
||||||
|
this.cache = {};
|
||||||
|
this.expirationSupplier = expiration instanceof Function ? expiration : () => new Date(Date.now() + expiration);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get(regionId: number, typeId: number): T | undefined {
|
||||||
|
const entry = this.cache[regionId]?.[typeId];
|
||||||
|
|
||||||
|
if (entry && entry.expiration > new Date()) {
|
||||||
|
return entry.value;
|
||||||
|
}
|
||||||
|
this.remove(regionId, typeId);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public set(regionId: number, typeId: number, value: T): void {
|
||||||
|
this.cache[regionId] = this.cache[regionId] ?? {};
|
||||||
|
this.cache[regionId][typeId] = new CacheEntry(value, this.expirationSupplier(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
public remove(regionId: number, typeId: number): void {
|
||||||
|
delete this.cache[regionId]?.[typeId];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async computeIfAbsent(regionId: number, typeId: number, supplier: () => (Promise<T> | T)): Promise<T> {
|
||||||
|
let value = this.get(regionId, typeId);
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
value = await supplier();
|
||||||
|
this.set(regionId, typeId, value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
10
src/market/acquisition/AcquiredType.ts
Normal file
10
src/market/acquisition/AcquiredType.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { MarketType } from "..";
|
||||||
|
import { MarbasAcquiredType } from "./acquisition";
|
||||||
|
|
||||||
|
export type AcquiredType = Omit<MarbasAcquiredType, 'type'> & {
|
||||||
|
type: MarketType,
|
||||||
|
buy: number,
|
||||||
|
sell: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const acquiredTypesToSorted = <T extends {date: Date} = AcquiredType>(array: T[], reverse?: boolean) => array.toSorted((a, b) => reverse ? b.date.getTime() - a.date.getTime() : a.date.getTime() - b.date.getTime())
|
||||||
97
src/market/acquisition/AcquisitionQuantilsTooltip.vue
Normal file
97
src/market/acquisition/AcquisitionQuantilsTooltip.vue
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { LoadingSpinner, Tooltip } from '@/components';
|
||||||
|
import { formatIsk } from '@/formaters';
|
||||||
|
import { getHistory, getHistoryQuartils } from '@/market';
|
||||||
|
import { ArrowTrendingDownIcon, ArrowTrendingUpIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import { computedAsync } from '@vueuse/core';
|
||||||
|
import { ref, watchEffect } from 'vue';
|
||||||
|
|
||||||
|
const trendingScale = 3;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: number;
|
||||||
|
buy: number;
|
||||||
|
sell: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const open = ref(false);
|
||||||
|
|
||||||
|
const q1 = ref(0);
|
||||||
|
const median = ref(0);
|
||||||
|
const q3 = ref(0);
|
||||||
|
const lineColor = ref('');
|
||||||
|
const history = computedAsync(() => getHistory(props.id), []);
|
||||||
|
|
||||||
|
watchEffect(async () => {
|
||||||
|
if (!open.value || !props.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const quartils = getHistoryQuartils(history.value);
|
||||||
|
|
||||||
|
q1.value = quartils.q1;
|
||||||
|
median.value = quartils.median;
|
||||||
|
q3.value = quartils.q3;
|
||||||
|
|
||||||
|
if (props.buy >= quartils.q3) {
|
||||||
|
lineColor.value = 'line-blue';
|
||||||
|
} else if (props.sell >= quartils.q3) {
|
||||||
|
lineColor.value = 'line-green';
|
||||||
|
} else {
|
||||||
|
lineColor.value = '';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Tooltip v-model:open="open" class="tooltip">
|
||||||
|
<template #header>
|
||||||
|
<LoadingSpinner v-if="history.length < trendingScale" />
|
||||||
|
<ArrowTrendingUpIcon v-else-if="history[0].average > history[trendingScale - 1].average" />
|
||||||
|
<ArrowTrendingDownIcon v-else />
|
||||||
|
</template>
|
||||||
|
<template #default>
|
||||||
|
<div class="bg-slate-500 -left-1/2 relative tooltip-content" v-if="history.length > 0">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Q1</th>
|
||||||
|
<th>Median</th>
|
||||||
|
<th>Q3</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr :class="lineColor">
|
||||||
|
<td class="text-right text-nowrap">{{ formatIsk(q1) }}</td>
|
||||||
|
<td class="text-right text-nowrap">{{ formatIsk(median) }}</td>
|
||||||
|
<td class="text-right text-nowrap">{{ formatIsk(q3) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Tooltip>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="postcss">
|
||||||
|
.tooltip {
|
||||||
|
@apply ms-auto;
|
||||||
|
>:deep(div.header) {
|
||||||
|
@apply btn-icon px-2;
|
||||||
|
}
|
||||||
|
&.open {
|
||||||
|
&.tooltip-top>:deep(div.header) {
|
||||||
|
@apply rounded-t-md bg-slate-600;
|
||||||
|
}
|
||||||
|
&.tooltip-bottom {
|
||||||
|
.tooltip-content {
|
||||||
|
bottom: 79px;
|
||||||
|
}
|
||||||
|
>:deep(div.header) {
|
||||||
|
@apply rounded-b-md bg-slate-600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
252
src/market/acquisition/AcquisitionResultTable.vue
Normal file
252
src/market/acquisition/AcquisitionResultTable.vue
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { SortableHeader, useSort, VirtualScrollTable } from '@/components/table';
|
||||||
|
import { formatEveDate, formatIsk, percentFormater } from '@/formaters';
|
||||||
|
import { MarketType, MarketTypeLabel, TaxInput, useMarketTaxStore } from "@/market";
|
||||||
|
import { MinusIcon, PlusIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import { useStorage } from '@vueuse/core';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { AcquiredType } from './AcquiredType';
|
||||||
|
import AcquisitionQuantilsTooltip from './AcquisitionQuantilsTooltip.vue';
|
||||||
|
|
||||||
|
type Result = {
|
||||||
|
id: number;
|
||||||
|
type: MarketType;
|
||||||
|
name: string;
|
||||||
|
buy: number;
|
||||||
|
sell: number;
|
||||||
|
price: number;
|
||||||
|
remaining: number;
|
||||||
|
quantity: number;
|
||||||
|
precentProfit: number;
|
||||||
|
iskProfit: number;
|
||||||
|
date: Date;
|
||||||
|
acquisitions: AcquiredType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items?: AcquiredType[];
|
||||||
|
infoOnly?: boolean;
|
||||||
|
showAll?: boolean;
|
||||||
|
ignoredColums?: string[] | string;
|
||||||
|
defaultSortKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'buy', type: AcquiredType[], price: number, buy: number, sell: number): void;
|
||||||
|
(e: 'sell', type: AcquiredType[]): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
items: () => [],
|
||||||
|
infoOnly: false,
|
||||||
|
showAll: false,
|
||||||
|
ignoredColums: () => [],
|
||||||
|
defaultSortKey: 'precentProfit',
|
||||||
|
});
|
||||||
|
defineEmits<Emits>();
|
||||||
|
|
||||||
|
const columnsToIgnore = computed(() => {
|
||||||
|
const ic = typeof props.ignoredColums === 'string' ? [props.ignoredColums] : props.ignoredColums;
|
||||||
|
|
||||||
|
if (props.infoOnly && !ic.includes('buttons')) {
|
||||||
|
return [...ic, 'buttons'];
|
||||||
|
}
|
||||||
|
return ic;
|
||||||
|
});
|
||||||
|
|
||||||
|
const marketTaxStore = useMarketTaxStore();
|
||||||
|
|
||||||
|
const threshold = useStorage('market-acquisition-threshold', 10);
|
||||||
|
const filter = ref("");
|
||||||
|
const { sortedArray, headerProps, showColumn } = useSort<Result>(computed(() => {
|
||||||
|
const filteredItems = props.items.filter(r => r.type.name.toLowerCase().includes(filter.value.toLowerCase()));
|
||||||
|
|
||||||
|
if (props.showAll) {
|
||||||
|
return filteredItems.map(r => {
|
||||||
|
const precentProfit = marketTaxStore.calculateProfit(r.price, r.sell);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
type: r.type,
|
||||||
|
name: r.type.name,
|
||||||
|
buy: r.buy,
|
||||||
|
sell: r.sell,
|
||||||
|
price: r.price,
|
||||||
|
remaining: r.remaining,
|
||||||
|
quantity: r.quantity,
|
||||||
|
precentProfit,
|
||||||
|
iskProfit: r.price * precentProfit * r.remaining,
|
||||||
|
date: r.date,
|
||||||
|
acquisitions: [r]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const list: Result[] = [];
|
||||||
|
const groups = Map.groupBy(filteredItems, r => r.type.id);
|
||||||
|
|
||||||
|
groups.forEach((group, typeID) => {
|
||||||
|
const first = group[0];
|
||||||
|
|
||||||
|
if (!first) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = group.reduce((acc, r) => acc + r.quantity, 0);
|
||||||
|
const totalRemaining = group.reduce((acc, r) => acc + r.remaining, 0);
|
||||||
|
const price = group.reduce((acc, r) => acc + r.price * r.remaining, 0) / totalRemaining;
|
||||||
|
const precentProfit = marketTaxStore.calculateProfit(price, first.sell);
|
||||||
|
|
||||||
|
list.push({
|
||||||
|
id: typeID,
|
||||||
|
type: first.type,
|
||||||
|
name: first.type.name,
|
||||||
|
buy: first.buy,
|
||||||
|
sell: first.sell,
|
||||||
|
price: price,
|
||||||
|
remaining: totalRemaining,
|
||||||
|
quantity: total,
|
||||||
|
precentProfit,
|
||||||
|
iskProfit: price * precentProfit * totalRemaining,
|
||||||
|
date: first.date,
|
||||||
|
acquisitions: group
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return list;
|
||||||
|
}), {
|
||||||
|
defaultSortKey: props.defaultSortKey,
|
||||||
|
defaultSortDirection: 'desc',
|
||||||
|
ignoredColums: columnsToIgnore
|
||||||
|
})
|
||||||
|
const getLineColor = (result: Result) => {
|
||||||
|
if (result.precentProfit >= (threshold.value / 100)) {
|
||||||
|
return 'line-green';
|
||||||
|
} else if (result.precentProfit < 0) {
|
||||||
|
return 'line-red';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const total = computed(() => {
|
||||||
|
if (sortedArray.value.length <= 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = sortedArray.value[0];
|
||||||
|
|
||||||
|
if (!first) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sameItem = sortedArray.value.every(r => r.type.id === first.type.id);
|
||||||
|
const quantity = sameItem ? sortedArray.value.reduce((acc, r) => acc + r.quantity, 0) : 0;
|
||||||
|
const totalRemaining = sameItem ? sortedArray.value.reduce((acc, r) => acc + r.remaining, 0) : 0;
|
||||||
|
const price = sortedArray.value.reduce((acc, r) => acc + r.price * r.remaining, 0) / totalRemaining;
|
||||||
|
const precentProfit = marketTaxStore.calculateProfit(price, first.sell);
|
||||||
|
const iskProfit = sortedArray.value.reduce((acc, r) => acc + r.iskProfit, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sameItem,
|
||||||
|
price,
|
||||||
|
remaining: totalRemaining,
|
||||||
|
quantity,
|
||||||
|
precentProfit,
|
||||||
|
iskProfit
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex" v-if="!infoOnly">
|
||||||
|
<div class="flex justify-self-end mb-2 mt-4 ms-auto">
|
||||||
|
<TaxInput />
|
||||||
|
<div class="end">
|
||||||
|
<span>Profit Threshold: </span>
|
||||||
|
<input type="number" min="0" max="1000" step="1" v-model="threshold" />
|
||||||
|
</div>
|
||||||
|
<div class="end">
|
||||||
|
<span>Filter: </span>
|
||||||
|
<input type="search" class="w-96" v-model="filter" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<VirtualScrollTable :list="sortedArray" :itemHeight="33" :footerHeight="!!total ? 33 : 0" bottom="1rem">
|
||||||
|
<template #default="{ list }">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="name">Item</SortableHeader>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="buy">Buy</SortableHeader>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="sell">Sell</SortableHeader>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="date">Bought at</SortableHeader>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="price">Bought Price</SortableHeader>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="remaining">Remaining Amount</SortableHeader>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="precentProfit">Profit (%)</SortableHeader>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="iskProfit">Profit (ISK)</SortableHeader>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="buttons" unsortable />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="r in list" :key="r.index" :class="getLineColor(r.data)">
|
||||||
|
<td v-if="showColumn('name')">
|
||||||
|
<div class="flex">
|
||||||
|
<MarketTypeLabel :id="r.data.type.id" :name="r.data.name" />
|
||||||
|
<AcquisitionQuantilsTooltip :id="r.data.type.id" :buy="r.data.buy" :sell="r.data.sell" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td v-if="showColumn('buy')" class="text-right">{{ formatIsk(r.data.buy) }}</td>
|
||||||
|
<td v-if="showColumn('sell')" class="text-right">{{ formatIsk(r.data.sell) }}</td>
|
||||||
|
<td v-if="showColumn('date')" class="text-right">{{ formatEveDate(r.data.date) }}</td>
|
||||||
|
<td v-if="showColumn('price')" class="text-right">{{ formatIsk(r.data.price) }}</td>
|
||||||
|
<td v-if="showColumn('remaining')" class="text-right">{{ r.data.remaining }}/{{ r.data.quantity }}</td>
|
||||||
|
<td v-if="showColumn('precentProfit')" class="text-right">{{ percentFormater.format(r.data.precentProfit) }}</td>
|
||||||
|
<td v-if="showColumn('iskProfit')" class="text-right">{{ formatIsk(r.data.iskProfit) }}</td>
|
||||||
|
<td v-if="showColumn('buttons')" class="text-right">
|
||||||
|
<button class="btn-icon me-1" @click="$emit('buy', r.data.acquisitions, r.data.price, r.data.buy, r.data.sell)"><PlusIcon /></button>
|
||||||
|
<button class="btn-icon me-1" @click="$emit('sell', r.data.acquisitions)"><MinusIcon /></button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
<tfoot v-if="!!total">
|
||||||
|
<tr>
|
||||||
|
<td v-if="showColumn('name')">Total</td>
|
||||||
|
<td v-if="showColumn('buy')">
|
||||||
|
<template v-if="!showColumn('name')">Total</template>
|
||||||
|
</td>
|
||||||
|
<td v-if="showColumn('sell')">
|
||||||
|
<template v-if="!showColumn('name') && !showColumn('buy')">Total</template>
|
||||||
|
</td>
|
||||||
|
<td v-if="showColumn('date')">
|
||||||
|
<template v-if="!showColumn('name') && !showColumn('buy') && !showColumn('sell')">Total</template>
|
||||||
|
</td>
|
||||||
|
<td v-if="showColumn('price')" class="text-right">
|
||||||
|
<template v-if="total.sameItem">
|
||||||
|
{{ formatIsk(total.price) }}
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
<td v-if="showColumn('remaining')" class="text-right">
|
||||||
|
<template v-if="total.sameItem">
|
||||||
|
{{ total.remaining }}/{{ total.quantity }}
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
<td v-if="showColumn('precentProfit')" class="text-right">
|
||||||
|
<template v-if="total.sameItem">
|
||||||
|
{{ percentFormater.format(total.precentProfit) }}
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
<td v-if="showColumn('iskProfit')" class="text-right">{{ formatIsk(total.iskProfit) }}</td>
|
||||||
|
<td v-if="showColumn('buttons')" />
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</template>
|
||||||
|
<template #empty>
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
<span>No items found</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</VirtualScrollTable>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="postcss">
|
||||||
|
div.end {
|
||||||
|
@apply justify-self-end ms-2;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
69
src/market/acquisition/BuyModal.vue
Normal file
69
src/market/acquisition/BuyModal.vue
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Modal } from '@/components';
|
||||||
|
import { formatIsk } from '@/formaters';
|
||||||
|
import { MarketType, MarketTypeLabel } from '@/market';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useAcquiredTypesStore } from './acquisition';
|
||||||
|
|
||||||
|
|
||||||
|
const acquiredTypesStore = useAcquiredTypesStore();
|
||||||
|
|
||||||
|
const modalOpen = ref<boolean>(false);
|
||||||
|
const type = ref<MarketType>();
|
||||||
|
const suggestions = ref<Record<string, number>>({});
|
||||||
|
const price = ref(1000000);
|
||||||
|
const count = ref(1);
|
||||||
|
|
||||||
|
const open = (t: MarketType, s?: Record<string, number> | number) => {
|
||||||
|
type.value = t;
|
||||||
|
count.value = 1;
|
||||||
|
|
||||||
|
if (typeof s === 'number') {
|
||||||
|
suggestions.value = {};
|
||||||
|
price.value = s;
|
||||||
|
} else if (s) {
|
||||||
|
suggestions.value = s;
|
||||||
|
price.value = Object.values(s)[0];
|
||||||
|
} else {
|
||||||
|
suggestions.value = {};
|
||||||
|
price.value = 1000000;
|
||||||
|
}
|
||||||
|
modalOpen.value = true;
|
||||||
|
}
|
||||||
|
const add = () => {
|
||||||
|
const id = type.value?.id;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
modalOpen.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
acquiredTypesStore.addAcquiredType(id, count.value, price.value);
|
||||||
|
modalOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ open });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal v-model:open="modalOpen">
|
||||||
|
<div class="bg-slate-800 rounded">
|
||||||
|
<MarketTypeLabel v-if="type" class="m-1" :id="type.id" :name="type.name" hideCopy />
|
||||||
|
<hr />
|
||||||
|
<div class="flex p-4">
|
||||||
|
<div class="flex me-2">
|
||||||
|
<span>Price: </span>
|
||||||
|
<div class="ms-2">
|
||||||
|
<input type="number" min="0" step="1" v-model="price" @keyup.enter="add" />
|
||||||
|
<div class="px-2 mt-2 bg-slate-600 hover:bg-slate-700 border rounded cursor-pointer" v-for="(p, n) of suggestions" :key="n" @click="price = p">{{ n }}: {{ formatIsk(p) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex me-2 mb-auto">
|
||||||
|
<span>Count: </span>
|
||||||
|
<input class="ms-2" type="number" min="0" step="1" v-model="count" @keyup.enter="add" />
|
||||||
|
</div>
|
||||||
|
<button class="mb-auto" @click="add">Add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
68
src/market/acquisition/SellModal.vue
Normal file
68
src/market/acquisition/SellModal.vue
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Modal } from '@/components';
|
||||||
|
import { MarketType, MarketTypeLabel } from '@/market';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { AcquiredType, acquiredTypesToSorted } from './AcquiredType';
|
||||||
|
import { useAcquiredTypesStore } from './acquisition';
|
||||||
|
|
||||||
|
|
||||||
|
const acquiredTypesStore = useAcquiredTypesStore();
|
||||||
|
|
||||||
|
const modalOpen = ref<boolean>(false);
|
||||||
|
const type = ref<MarketType>();
|
||||||
|
const count = ref(1);
|
||||||
|
const types = ref<AcquiredType[]>([]);
|
||||||
|
|
||||||
|
const open = (t: AcquiredType[]) => {
|
||||||
|
if (t.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
types.value = acquiredTypesToSorted(t);
|
||||||
|
type.value = t[0].type;
|
||||||
|
count.value = 1;
|
||||||
|
modalOpen.value = true;
|
||||||
|
}
|
||||||
|
const remove = async () => {
|
||||||
|
if (!types.value) {
|
||||||
|
modalOpen.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let c = count.value;
|
||||||
|
|
||||||
|
for (const type of types.value) {
|
||||||
|
const remaining = type.remaining;
|
||||||
|
|
||||||
|
await acquiredTypesStore.removeAcquiredType(type.id, c);
|
||||||
|
c -= remaining;
|
||||||
|
if (c <= 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
modalOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ open });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal v-model:open="modalOpen">
|
||||||
|
<div class="bg-slate-800 rounded">
|
||||||
|
<MarketTypeLabel v-if="type" class="m-1" :id="type.id" :name="type.name" hideCopy />
|
||||||
|
<hr />
|
||||||
|
<div class="flex p-4">
|
||||||
|
<div class="flex me-2 mb-auto">
|
||||||
|
<span>Count: </span>
|
||||||
|
<div class="ms-2">
|
||||||
|
<input type="number" min="0" step="1" v-model="count" @keyup.enter="remove" />
|
||||||
|
<div>
|
||||||
|
<button class="px-2 mt-2 bg-slate-600 hover:bg-slate-700 border rounded cursor-pointer" @click="count = types.reduce((acc, t) => acc + t.remaining, 0)">All</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="mb-auto" @click="remove">Remove</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
75
src/market/acquisition/acquisition.ts
Normal file
75
src/market/acquisition/acquisition.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { marbasAxiosInstance, MarbasObject } from "@/marbas";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import log from "loglevel";
|
||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
|
||||||
|
export type AcquiredTypeSource = 'bo' | 'so' | 'prod' | 'misc';
|
||||||
|
|
||||||
|
export type MarbasAcquiredType = MarbasObject & {
|
||||||
|
type: number;
|
||||||
|
quantity: number;
|
||||||
|
remaining: number;
|
||||||
|
price: number;
|
||||||
|
date: Date;
|
||||||
|
source: AcquiredTypeSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawMarbasAcquiredType = Omit<MarbasAcquiredType, 'date'> & {
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type InsertableRawMarbasAcquiredType = Omit<MarbasAcquiredType, 'id' | 'date'>;
|
||||||
|
|
||||||
|
const mapRawMarbasAcquiredType = (raw: RawMarbasAcquiredType): MarbasAcquiredType => ({
|
||||||
|
...raw,
|
||||||
|
date: raw.date ? new Date(raw.date) : new Date()
|
||||||
|
});
|
||||||
|
|
||||||
|
const endpoint = '/api/acquisitions/';
|
||||||
|
|
||||||
|
export const useAcquiredTypesStore = defineStore('market-acquisition', () => {
|
||||||
|
const acquiredTypes = ref<MarbasAcquiredType[]>([]);
|
||||||
|
|
||||||
|
const types = computed(() => acquiredTypes.value.filter(item => item.remaining > 0));
|
||||||
|
const addAcquiredType = async (type: number, quantity: number, price: number, source?: AcquiredTypeSource) => {
|
||||||
|
const newItem = mapRawMarbasAcquiredType((await marbasAxiosInstance.post<RawMarbasAcquiredType, AxiosResponse<RawMarbasAcquiredType>, InsertableRawMarbasAcquiredType>(endpoint, {
|
||||||
|
type: type,
|
||||||
|
quantity: quantity,
|
||||||
|
remaining: quantity,
|
||||||
|
price: price,
|
||||||
|
source: source ?? 'misc',
|
||||||
|
})).data);
|
||||||
|
|
||||||
|
acquiredTypes.value = [...acquiredTypes.value, newItem];
|
||||||
|
log.info(`Acquired type ${newItem.id} with quantity ${newItem.quantity} and price ${newItem.price}`, newItem);
|
||||||
|
};
|
||||||
|
const removeAcquiredType = async (id: number, quantity: number) => {
|
||||||
|
const found = acquiredTypes.value.find(t => t.id === id);
|
||||||
|
|
||||||
|
if (!found) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = {
|
||||||
|
...found,
|
||||||
|
remaining: Math.max(0, found.remaining - quantity)
|
||||||
|
};
|
||||||
|
|
||||||
|
acquiredTypes.value = acquiredTypes.value.map(i => {
|
||||||
|
if (i.id === item.id) {
|
||||||
|
return item;
|
||||||
|
} else {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await marbasAxiosInstance.put(`${endpoint}${item.id}/`, item);
|
||||||
|
log.info(`Acquired type ${item.id} remaining: ${item.remaining}`, item);
|
||||||
|
};
|
||||||
|
|
||||||
|
const refresh = () => marbasAxiosInstance.get<RawMarbasAcquiredType[]>(endpoint).then(res => acquiredTypes.value = res.data.map(mapRawMarbasAcquiredType));
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
|
||||||
|
return { acquiredTypes: types, addAcquiredType, removeAcquiredType, refresh };
|
||||||
|
});
|
||||||
7
src/market/acquisition/index.ts
Normal file
7
src/market/acquisition/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export * from './AcquiredType';
|
||||||
|
export * from './acquisition';
|
||||||
|
|
||||||
|
export { default as AcquisitionResultTable } from './AcquisitionResultTable.vue';
|
||||||
|
export { default as BuyModal } from './BuyModal.vue';
|
||||||
|
export { default as SellModal } from './SellModal.vue';
|
||||||
|
|
||||||
11
src/market/appraisal/MarketTypePrice.ts
Normal file
11
src/market/appraisal/MarketTypePrice.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { MarketType } from "../type";
|
||||||
|
|
||||||
|
|
||||||
|
export type MarketTypePrice = {
|
||||||
|
type: MarketType;
|
||||||
|
buy: number;
|
||||||
|
sell: number;
|
||||||
|
orderCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PriceGetter = (types: MarketType[]) => Promise<MarketTypePrice[]>;
|
||||||
45
src/market/appraisal/appraisal.ts
Normal file
45
src/market/appraisal/appraisal.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { RegionalMarketCache } from '../RegionalMarketCache';
|
||||||
|
import { jitaId } from '../market';
|
||||||
|
import { MarketType } from "../type";
|
||||||
|
import { MarketTypePrice } from './MarketTypePrice';
|
||||||
|
import { getEvepraisalPrices } from './evepraisal';
|
||||||
|
import { getfuzzworkPrices } from './fuzzwork';
|
||||||
|
|
||||||
|
const cacheDuration = 1000 * 60 * 5; // 5 minutes
|
||||||
|
const priceGetters = {
|
||||||
|
evepraisal: getEvepraisalPrices,
|
||||||
|
fuzzwork: getfuzzworkPrices
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useApraisalStore = defineStore('appraisal', () => {
|
||||||
|
const cache: RegionalMarketCache<MarketTypePrice> = new RegionalMarketCache(cacheDuration);
|
||||||
|
|
||||||
|
const getPricesUncached = priceGetters.fuzzwork;
|
||||||
|
|
||||||
|
const getPrice = async (type: MarketType, regionId?: number): Promise<MarketTypePrice> => (await getPrices([type], regionId))[0];
|
||||||
|
const getPrices = async (types: MarketType[], regionId?: number): Promise<MarketTypePrice[]> => {
|
||||||
|
const cached: MarketTypePrice[] = [];
|
||||||
|
const uncached: MarketType[] = [];
|
||||||
|
const rId = regionId ?? jitaId;
|
||||||
|
|
||||||
|
types.forEach(t => {
|
||||||
|
const cachedPrice = cache.get(rId, t.id);
|
||||||
|
|
||||||
|
if (cachedPrice) {
|
||||||
|
cached.push(cachedPrice);
|
||||||
|
} else {
|
||||||
|
uncached.push(t);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (uncached.length > 0) {
|
||||||
|
const prices = await getPricesUncached(uncached);
|
||||||
|
|
||||||
|
prices.forEach(p => cache.set(rId, p.type.id, p));
|
||||||
|
return [ ...cached, ...prices ];
|
||||||
|
}
|
||||||
|
return cached;
|
||||||
|
};
|
||||||
|
return { getPrice, getPrices };
|
||||||
|
});
|
||||||
31
src/market/appraisal/evepraisal.ts
Normal file
31
src/market/appraisal/evepraisal.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { logResource } from '@/service';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { MarketType } from "../type";
|
||||||
|
import { PriceGetter } from './MarketTypePrice';
|
||||||
|
|
||||||
|
export const evepraisalAxiosInstance = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_EVEPRAISAL_URL,
|
||||||
|
headers: {
|
||||||
|
'accept': 'application/json',
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
})
|
||||||
|
logResource(evepraisalAxiosInstance)
|
||||||
|
|
||||||
|
const batchSize = 100;
|
||||||
|
|
||||||
|
export const getEvepraisalPrices: PriceGetter = async types => {
|
||||||
|
const batches = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < types.length; i += batchSize) {
|
||||||
|
batches.push(evepraisalAxiosInstance.post(`/appraisal.json?market=jita&persist=no&raw_textarea=${types.slice(i, i + batchSize).map(t => t.name).join("%0A")}`));
|
||||||
|
}
|
||||||
|
return (await Promise.all(batches))
|
||||||
|
.flatMap(b => b.data.appraisal.items)
|
||||||
|
.map((item: any) => ({
|
||||||
|
type: types.find(t => t.name === item.typeName) as MarketType,
|
||||||
|
buy: item.prices.buy.max,
|
||||||
|
sell: item.prices.sell.min,
|
||||||
|
orderCount: item.prices.all.order_count
|
||||||
|
}));
|
||||||
|
};
|
||||||
39
src/market/appraisal/fuzzwork.ts
Normal file
39
src/market/appraisal/fuzzwork.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { logResource } from '@/service';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { MarketType } from "../type";
|
||||||
|
import { PriceGetter } from './MarketTypePrice';
|
||||||
|
|
||||||
|
export const fuzzworkAxiosInstance = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_FUZZWORK_URL,
|
||||||
|
headers: {
|
||||||
|
'accept': 'application/json',
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
})
|
||||||
|
logResource(fuzzworkAxiosInstance)
|
||||||
|
|
||||||
|
const batchSize = 100;
|
||||||
|
|
||||||
|
export const getfuzzworkPrices: PriceGetter = async types => {
|
||||||
|
const batches = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < types.length; i += batchSize) {
|
||||||
|
batches.push(fuzzworkAxiosInstance.post(`/aggregates/?station=60003760&types=${types.slice(i, i + batchSize).map(t => t.id).join(",")}`));
|
||||||
|
}
|
||||||
|
return (await Promise.all(batches))
|
||||||
|
.flatMap(b => Object.entries(b.data))
|
||||||
|
.map(entry => {
|
||||||
|
const id = doParseInt(entry[0]);
|
||||||
|
const prices = entry[1] as any;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: types.find(t => t.id === id) as MarketType,
|
||||||
|
buy: doParseFloat(prices?.buy?.max),
|
||||||
|
sell: doParseFloat(prices?.sell?.min),
|
||||||
|
orderCount: doParseInt(prices?.buy?.order_count) + doParseInt(prices?.sell?.order_count)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const doParseInt = (s?: string) => s ? parseInt(s) : 0;
|
||||||
|
const doParseFloat = (s?: string) => s ? parseFloat(s) : 0;
|
||||||
2
src/market/appraisal/index.ts
Normal file
2
src/market/appraisal/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './MarketTypePrice';
|
||||||
|
export * from './appraisal';
|
||||||
30
src/market/history/EsiMarketOrderHistory.ts
Normal file
30
src/market/history/EsiMarketOrderHistory.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { esiAxiosInstance } from "@/service";
|
||||||
|
import { RegionalMarketCache } from '../RegionalMarketCache';
|
||||||
|
import { jitaId } from "../market";
|
||||||
|
|
||||||
|
|
||||||
|
export type EsiMarketOrderHistory = {
|
||||||
|
average: number;
|
||||||
|
date: string;
|
||||||
|
highest: number;
|
||||||
|
lowest: number;
|
||||||
|
order_count: number;
|
||||||
|
volume: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO use pinia store
|
||||||
|
const historyCache: RegionalMarketCache<EsiMarketOrderHistory[]> = new RegionalMarketCache(() => {
|
||||||
|
const date = new Date();
|
||||||
|
|
||||||
|
if (date.getUTCHours() >= 11) {
|
||||||
|
date.setUTCDate(date.getUTCDate() + 1);
|
||||||
|
}
|
||||||
|
date.setUTCHours(11, 0, 0, 0);
|
||||||
|
return date;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getHistory = async (typeId: number, regionId?: number): Promise<EsiMarketOrderHistory[]> => {
|
||||||
|
const rId = regionId ?? jitaId;
|
||||||
|
|
||||||
|
return historyCache.computeIfAbsent(rId, typeId, async () => (await esiAxiosInstance.get(`/markets/${rId}/history/`, { params: { type_id: typeId } })).data);
|
||||||
|
}
|
||||||
59
src/market/history/HistoryQuartils.ts
Normal file
59
src/market/history/HistoryQuartils.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { EsiMarketOrderHistory } from "@/market";
|
||||||
|
|
||||||
|
export type HistoryQuartils = {
|
||||||
|
totalVolume: number,
|
||||||
|
q1: number,
|
||||||
|
median: number,
|
||||||
|
q3: number,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getHistoryQuartils = (history: EsiMarketOrderHistory[], days?: number): HistoryQuartils => {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const volumes = history
|
||||||
|
.flatMap(h => {
|
||||||
|
const volume = h.volume;
|
||||||
|
|
||||||
|
if (volume === 0 || (days && new Date(h.date).getTime() < now - days * 24 * 60 * 60 * 1000)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const e = estimateVolume(h);
|
||||||
|
|
||||||
|
return [[h.highest, e], [h.lowest, volume - e]];
|
||||||
|
})
|
||||||
|
.filter(h => h[1] > 0)
|
||||||
|
.sort((a, b) => a[0] - b[0]);
|
||||||
|
|
||||||
|
const totalVolume = volumes.reduce((acc, [_, v]) => acc + v, 0);
|
||||||
|
const quartilVolume = totalVolume / 4;
|
||||||
|
const quartils: [number, number, number] = [0, 0, 0];
|
||||||
|
|
||||||
|
let currentVolume = 0;
|
||||||
|
let quartil = 0;
|
||||||
|
|
||||||
|
for (const [price, volume] of volumes) {
|
||||||
|
currentVolume += volume;
|
||||||
|
|
||||||
|
if (currentVolume >= quartilVolume * (quartil + 1)) {
|
||||||
|
quartils[quartil] = price;
|
||||||
|
if (quartil === 2) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
quartil++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
totalVolume,
|
||||||
|
q1: quartils[0],
|
||||||
|
median: quartils[1],
|
||||||
|
q3: quartils[2],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const estimateVolume = (history: EsiMarketOrderHistory): number => {
|
||||||
|
if (history.volume === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.max(1, Math.round(history.volume * ((history.average - history.lowest) / (history.highest - history.lowest))));
|
||||||
|
}
|
||||||
2
src/market/history/index.ts
Normal file
2
src/market/history/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './EsiMarketOrderHistory';
|
||||||
|
export * from './HistoryQuartils';
|
||||||
8
src/market/index.ts
Normal file
8
src/market/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export * from './RegionalMarketCache';
|
||||||
|
export * from './history';
|
||||||
|
export * from './tax';
|
||||||
|
export * from './type';
|
||||||
|
|
||||||
|
export * from './appraisal';
|
||||||
|
export * from './market';
|
||||||
|
|
||||||
1
src/market/market.ts
Normal file
1
src/market/market.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const jitaId = 10000002;
|
||||||
24
src/market/tax/TaxInput.vue
Normal file
24
src/market/tax/TaxInput.vue
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
import { useMarketTaxStore } from "./tax";
|
||||||
|
|
||||||
|
const { brokerFee, scc } = storeToRefs(useMarketTaxStore());
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="end">
|
||||||
|
<span>Broker Fee: </span>
|
||||||
|
<input type="number" min="1" max="3" step="0.01" v-model="brokerFee" />
|
||||||
|
</div>
|
||||||
|
<div class="end">
|
||||||
|
<span>SCC: </span>
|
||||||
|
<input type="number" min="3.6" max="8" step="0.01" v-model="scc" >
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="postcss">
|
||||||
|
div.end {
|
||||||
|
@apply justify-self-end ms-2;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
4
src/market/tax/index.ts
Normal file
4
src/market/tax/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './tax';
|
||||||
|
|
||||||
|
export { default as TaxInput } from './TaxInput.vue';
|
||||||
|
|
||||||
12
src/market/tax/tax.ts
Normal file
12
src/market/tax/tax.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { useLocalStorage } from "@vueuse/core";
|
||||||
|
import { defineStore } from "pinia";
|
||||||
|
|
||||||
|
export const useMarketTaxStore = defineStore("marketTax", () => {
|
||||||
|
const brokerFee = useLocalStorage("market-brokerFee", 1.5);
|
||||||
|
const scc = useLocalStorage("market-scc", 3.6);
|
||||||
|
|
||||||
|
const applyTaxes = (price: number, sellOrder?: boolean) => sellOrder ? price * (1 - (brokerFee.value + scc.value) / 100) : price * (1 + brokerFee.value / 100);
|
||||||
|
const calculateProfit = (buy: number, sell: number) => (applyTaxes(sell, true) / applyTaxes(buy)) - 1;
|
||||||
|
|
||||||
|
return { brokerFee, scc, applyTaxes, calculateProfit };
|
||||||
|
});
|
||||||
174
src/market/tracking/TrackingResultTable.vue
Normal file
174
src/market/tracking/TrackingResultTable.vue
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { SliderCheckbox } from '@/components';
|
||||||
|
import { SortableHeader, useSort, VirtualScrollTable } from '@/components/table';
|
||||||
|
import { formatIsk, percentFormater } from '@/formaters';
|
||||||
|
import { getHistoryQuartils, MarketType, MarketTypeLabel, TaxInput, useMarketTaxStore } from "@/market";
|
||||||
|
import { BookmarkSlashIcon, ShoppingCartIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import { useStorage } from '@vueuse/core';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useAcquiredTypesStore } from '../acquisition';
|
||||||
|
import { TrackingResult } from './tracking';
|
||||||
|
|
||||||
|
type Result = {
|
||||||
|
type: MarketType;
|
||||||
|
typeID: number;
|
||||||
|
name: string;
|
||||||
|
buy: number;
|
||||||
|
sell: number;
|
||||||
|
q1: number;
|
||||||
|
median: number;
|
||||||
|
q3: number;
|
||||||
|
acquisitions: number;
|
||||||
|
profit: number;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items?: TrackingResult[];
|
||||||
|
infoOnly?: boolean;
|
||||||
|
ignoredColums?: string[] | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'buy', type: MarketType, buy: number, sell: number): void;
|
||||||
|
(e: 'remove', type: MarketType): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scoreFormater = new Intl.NumberFormat("en-US", {
|
||||||
|
maximumFractionDigits: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
items: () => [],
|
||||||
|
infoOnly: false,
|
||||||
|
ignoredColums: () => []
|
||||||
|
});
|
||||||
|
defineEmits<Emits>();
|
||||||
|
|
||||||
|
const marketTaxStore = useMarketTaxStore();
|
||||||
|
const acquiredTypesStore = useAcquiredTypesStore();
|
||||||
|
|
||||||
|
const days = useStorage('market-tracking-days', 365);
|
||||||
|
const threshold = useStorage('market-tracking-threshold', 10);
|
||||||
|
const filter = ref("");
|
||||||
|
const onlyCheap = ref(false);
|
||||||
|
const columnsToIgnore = computed(() => {
|
||||||
|
const ic = typeof props.ignoredColums === 'string' ? [props.ignoredColums] : props.ignoredColums;
|
||||||
|
|
||||||
|
if (props.infoOnly && !ic.includes('buttons')) {
|
||||||
|
return [...ic, 'buttons'];
|
||||||
|
}
|
||||||
|
return ic;
|
||||||
|
});
|
||||||
|
const { sortedArray, headerProps, showColumn } = useSort<Result>(computed(() => props.items
|
||||||
|
.filter(r => r.type.name.toLowerCase().includes(filter.value.toLowerCase()))
|
||||||
|
.map(r => {
|
||||||
|
const quartils = getHistoryQuartils(r.history, days.value);
|
||||||
|
const profit = quartils.q1 === 0 || quartils.q3 === 0 ? 0 : marketTaxStore.calculateProfit(quartils.q1, quartils.q3);
|
||||||
|
const score = profit <= 0 ? 0 : Math.sqrt((Math.pow(quartils.totalVolume, 1.1) * Math.pow(quartils.q1, 1.2) * Math.pow(profit, 0.5) * Math.pow(Math.max(1, r.orderCount), -0.7)) / days.value);
|
||||||
|
const acquisitions = columnsToIgnore.value.includes('acquisitions') ? 0 : acquiredTypesStore.acquiredTypes
|
||||||
|
.filter(t => t.type === r.type.id)
|
||||||
|
.reduce((a, b) => a + b.remaining, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: r.type,
|
||||||
|
typeID: r.type.id,
|
||||||
|
name: r.type.name,
|
||||||
|
buy: r.buy,
|
||||||
|
sell: r.sell,
|
||||||
|
q1: quartils.q1,
|
||||||
|
median: quartils.median,
|
||||||
|
q3: quartils.q3,
|
||||||
|
acquisitions,
|
||||||
|
profit,
|
||||||
|
score
|
||||||
|
};
|
||||||
|
}).filter(r => !onlyCheap.value || (r.buy <= r.q1 && r.profit >= (threshold.value / 100)))), {
|
||||||
|
defaultSortKey: 'score',
|
||||||
|
defaultSortDirection: 'desc',
|
||||||
|
ignoredColums: columnsToIgnore
|
||||||
|
})
|
||||||
|
const getLineColor = (result: Result) => {
|
||||||
|
if (props.infoOnly) {
|
||||||
|
return '';
|
||||||
|
} else if (result.profit < (threshold.value / 100)) {
|
||||||
|
return 'line-red';
|
||||||
|
} else if (result.sell > 0 && result.sell <= result.q1) {
|
||||||
|
return 'line-blue';
|
||||||
|
} else if (result.buy <= result.q1) {
|
||||||
|
return 'line-green';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="!infoOnly" class="flex mb-2 mt-4">
|
||||||
|
<div class="flex justify-self-end ms-auto">
|
||||||
|
<TaxInput />
|
||||||
|
<div class="end">
|
||||||
|
<span>Profit Threshold: </span>
|
||||||
|
<input type="number" min="0" max="1000" step="1" v-model="threshold" />
|
||||||
|
</div>
|
||||||
|
<div class="end">
|
||||||
|
<span>Days: </span>
|
||||||
|
<input type="number" min="1" max="365" step="1" v-model="days" />
|
||||||
|
</div>
|
||||||
|
<div class="end flex">
|
||||||
|
<SliderCheckbox class="me-1" v-model="onlyCheap" /> Show only cheap items
|
||||||
|
</div>
|
||||||
|
<div class="end">
|
||||||
|
<span>Filter: </span>
|
||||||
|
<input type="search" class="w-96" v-model="filter" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<VirtualScrollTable :list="sortedArray" :itemHeight="33" bottom="1rem">
|
||||||
|
<template #default="{ list }">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="name">Item</SortableHeader>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="buy">Buy</SortableHeader>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="sell">Sell</SortableHeader>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="q1">Q1</SortableHeader>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="median">Median</SortableHeader>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="q3">Q3</SortableHeader>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="profit">Profit</SortableHeader>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="score">Score</SortableHeader>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="acquisitions">Acquisitions</SortableHeader>
|
||||||
|
<SortableHeader v-bind="headerProps" sortKey="buttons" unsortable />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="r in list" :key="r.data.typeID" :class="getLineColor(r.data)">
|
||||||
|
<td v-if="showColumn('name')">
|
||||||
|
<MarketTypeLabel :id="r.data.typeID" :name="r.data.name" />
|
||||||
|
</td>
|
||||||
|
<td v-if="showColumn('buy')" class="text-right">{{ formatIsk(r.data.buy) }}</td>
|
||||||
|
<td v-if="showColumn('sell')" class="text-right">{{ formatIsk(r.data.sell) }}</td>
|
||||||
|
<td v-if="showColumn('q1')" class="text-right">{{ formatIsk(r.data.q1) }}</td>
|
||||||
|
<td v-if="showColumn('median')" class="text-right">{{ formatIsk(r.data.median) }}</td>
|
||||||
|
<td v-if="showColumn('q3')" class="text-right">{{ formatIsk(r.data.q3) }}</td>
|
||||||
|
<td v-if="showColumn('profit')" class="text-right">{{ percentFormater.format(r.data.profit) }}</td>
|
||||||
|
<td v-if="showColumn('score')" class="text-right">{{ scoreFormater.format(r.data.score) }}</td>
|
||||||
|
<td v-if="showColumn('acquisitions')" class="text-right">{{ r.data.acquisitions }}</td>
|
||||||
|
<td v-if="showColumn('buttons')" class="text-right">
|
||||||
|
<button class="btn-icon me-1" title="Add acquisitions" @click="$emit('buy', r.data.type, r.data.buy, r.data.sell)"><ShoppingCartIcon /></button>
|
||||||
|
<button class="btn-icon me-1" title="Untrack" @click="$emit('remove', r.data.type)"><BookmarkSlashIcon /></button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</template>
|
||||||
|
<template #empty>
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
<span>No items found</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</VirtualScrollTable>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="postcss">
|
||||||
|
div.end {
|
||||||
|
@apply justify-self-end ms-2;
|
||||||
|
}
|
||||||
|
</style>../history/HistoryQuartils
|
||||||
4
src/market/tracking/index.ts
Normal file
4
src/market/tracking/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './tracking';
|
||||||
|
|
||||||
|
export { default as TrackingResultTable } from './TrackingResultTable.vue';
|
||||||
|
|
||||||
50
src/market/tracking/tracking.ts
Normal file
50
src/market/tracking/tracking.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { marbasAxiosInstance, MarbasObject } from "@/marbas";
|
||||||
|
import { EsiMarketOrderHistory, getHistory, MarketType, MarketTypePrice } from "@/market";
|
||||||
|
import log from "loglevel";
|
||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
|
||||||
|
export type TrackingResult = {
|
||||||
|
type: MarketType;
|
||||||
|
history: EsiMarketOrderHistory[];
|
||||||
|
buy: number,
|
||||||
|
sell: number,
|
||||||
|
orderCount: number,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MarbasTrackedType = MarbasObject & {
|
||||||
|
type: number
|
||||||
|
};
|
||||||
|
|
||||||
|
const endpoint = '/api/types_tracking/';
|
||||||
|
|
||||||
|
export const useMarketTrackingStore = defineStore('marketTracking', () => {
|
||||||
|
const trackedTypes = ref<MarbasTrackedType[]>([]);
|
||||||
|
|
||||||
|
const types = computed(() => trackedTypes.value.map(item => item.type) ?? []);
|
||||||
|
const addType = async (type: number) => {
|
||||||
|
const found = trackedTypes.value.find(item => item.type === type);
|
||||||
|
|
||||||
|
if (!found) {
|
||||||
|
trackedTypes.value = [...trackedTypes.value, (await marbasAxiosInstance.post<MarbasTrackedType>(endpoint, { type })).data];
|
||||||
|
log.info(`Tracking type ${type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const removeType = async (type: number) => {
|
||||||
|
const found = trackedTypes.value.find(item => item.type === type);
|
||||||
|
|
||||||
|
if (!found) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
trackedTypes.value = trackedTypes.value.filter(t => t.id !== found.id);
|
||||||
|
await marbasAxiosInstance.delete(`${endpoint}${found.id}`);
|
||||||
|
log.info(`Stopped tracking type ${type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
marbasAxiosInstance.get<MarbasTrackedType[]>(endpoint).then(res => trackedTypes.value = res.data);
|
||||||
|
|
||||||
|
return { types, addType, removeType };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createResult = async (id: number, price: MarketTypePrice): Promise<TrackingResult> => ({ history: await getHistory(id), ...price });
|
||||||
57
src/market/type/MarketType.ts
Normal file
57
src/market/type/MarketType.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { marbasAxiosInstance } from "@/marbas";
|
||||||
|
|
||||||
|
export type MarketType = {
|
||||||
|
id: number;
|
||||||
|
group_id: number;
|
||||||
|
marketgroup_id: number;
|
||||||
|
name: string;
|
||||||
|
published: boolean;
|
||||||
|
description: string;
|
||||||
|
basePrice: number;
|
||||||
|
icon_id: number;
|
||||||
|
volume: number;
|
||||||
|
portionSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getMarketType = async (type: string | number): Promise<MarketType> => (await getMarketTypes([type]))[0];
|
||||||
|
export const getMarketTypes = async (types: (string | number)[]): Promise<MarketType[]> => {
|
||||||
|
if (types.length === 0) {
|
||||||
|
return [];
|
||||||
|
} else if (types.length === 1 && typeof types[0] === "number") {
|
||||||
|
return [(await marbasAxiosInstance.get<MarketType>(`/sde/types/${types[0]}/`)).data];
|
||||||
|
}
|
||||||
|
return (await marbasAxiosInstance.post<MarketType[]>("/api/types/search", types.map(t => {
|
||||||
|
if (typeof t === "number") {
|
||||||
|
return { id: t };
|
||||||
|
} else {
|
||||||
|
return { name: t };
|
||||||
|
}
|
||||||
|
}))).data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blueprintMarketGrous = [ // TODO add all groups
|
||||||
|
2,
|
||||||
|
2157,
|
||||||
|
2159,
|
||||||
|
2339,
|
||||||
|
2160,
|
||||||
|
211,
|
||||||
|
1016,
|
||||||
|
339,
|
||||||
|
2290,
|
||||||
|
357,
|
||||||
|
1530,
|
||||||
|
359,
|
||||||
|
1531,
|
||||||
|
1532,
|
||||||
|
1533,
|
||||||
|
358
|
||||||
|
]
|
||||||
|
|
||||||
|
export const searchMarketTypes = async (search: string): Promise<MarketType[]> => {
|
||||||
|
return (await marbasAxiosInstance.post<MarketType[]>("/api/types/search", [{
|
||||||
|
name__icontains: search,
|
||||||
|
marketgroup_id___not: null,
|
||||||
|
marketgroup_id__in___not: blueprintMarketGrous,
|
||||||
|
}])).data;
|
||||||
|
}
|
||||||
124
src/market/type/MarketTypeInput.vue
Normal file
124
src/market/type/MarketTypeInput.vue
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { vOnClickOutside } from '@vueuse/components';
|
||||||
|
import { useVirtualList } from '@vueuse/core';
|
||||||
|
import log from 'loglevel';
|
||||||
|
import { nextTick, ref, watch, watchEffect } from 'vue';
|
||||||
|
import { MarketType, searchMarketTypes } from './MarketType';
|
||||||
|
import MarketTypeLabel from "./MarketTypeLabel.vue";
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'submit'): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelValue = defineModel<MarketType>();
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
const isOpen = ref(false);
|
||||||
|
const name = ref('');
|
||||||
|
const suggestions = ref<MarketType[]>([]);
|
||||||
|
const currentIndex = ref(-1);
|
||||||
|
const {list, scrollTo, containerProps, wrapperProps} = useVirtualList(suggestions, {
|
||||||
|
itemHeight: 24,
|
||||||
|
overscan: 3
|
||||||
|
});
|
||||||
|
|
||||||
|
const moveDown = () => {
|
||||||
|
if (currentIndex.value < 0 || currentIndex.value >= suggestions.value.length - 1) {
|
||||||
|
currentIndex.value = 0;
|
||||||
|
} else if (currentIndex.value < suggestions.value.length - 1) {
|
||||||
|
currentIndex.value++;
|
||||||
|
}
|
||||||
|
scrollTo(currentIndex.value);
|
||||||
|
}
|
||||||
|
const moveUp = () => {
|
||||||
|
if (currentIndex.value <= 0) {
|
||||||
|
currentIndex.value = suggestions.value.length - 1;
|
||||||
|
} else if (currentIndex.value > 0) {
|
||||||
|
currentIndex.value--;
|
||||||
|
}
|
||||||
|
scrollTo(currentIndex.value);
|
||||||
|
}
|
||||||
|
const select = (type?: MarketType) => {
|
||||||
|
log.debug('Select:', type);
|
||||||
|
modelValue.value = type;
|
||||||
|
currentIndex.value = -1;
|
||||||
|
suggestions.value = [];
|
||||||
|
isOpen.value = false;
|
||||||
|
name.value = type?.name ?? '';
|
||||||
|
if (document.activeElement instanceof HTMLElement) {
|
||||||
|
document.activeElement.blur();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const submit = async () => {
|
||||||
|
if (currentIndex.value >= 0 && currentIndex.value < suggestions.value.length) {
|
||||||
|
const v = suggestions.value[currentIndex.value];
|
||||||
|
|
||||||
|
select(v);
|
||||||
|
await nextTick();
|
||||||
|
} else if (modelValue.value === undefined && suggestions.value.length > 0) {
|
||||||
|
select(suggestions.value[0]);
|
||||||
|
await nextTick();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modelValue.value === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit('submit');
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => modelValue.value, async v => {
|
||||||
|
if (v === undefined) {
|
||||||
|
name.value = '';
|
||||||
|
} else {
|
||||||
|
name.value = v.name;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
watchEffect(async () => {
|
||||||
|
const search = name.value.split('\t')[0];
|
||||||
|
|
||||||
|
if (!isOpen.value || search.length < 3) {
|
||||||
|
suggestions.value = [];
|
||||||
|
} else {
|
||||||
|
suggestions.value = await searchMarketTypes(search);
|
||||||
|
scrollTo(0);
|
||||||
|
}
|
||||||
|
currentIndex.value = -1;
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div @click="() => isOpen = true" v-on-click-outside="() => isOpen = false">
|
||||||
|
<div class="fake-input">
|
||||||
|
<img v-if="modelValue?.id" :src="`https://images.evetech.net/types/${modelValue.id}/icon?size=32`" alt="" />
|
||||||
|
<input type="text" v-model="name" @keyup.enter="submit" @keyup.down="moveDown" @keyup.up="moveUp" />
|
||||||
|
</div>
|
||||||
|
<div v-if="suggestions.length > 1" class="z-20 absolute w-96">
|
||||||
|
<div v-bind="containerProps" class="rounded-b" style="height: 300px">
|
||||||
|
<div v-bind="wrapperProps">
|
||||||
|
<div v-for="s in list" :key="s.index" class="hover:bg-slate-700" :class="{'bg-slate-500': s.index !== currentIndex, 'bg-emerald-500': s.index === currentIndex}" @click="select(s.data)">
|
||||||
|
<MarketTypeLabel :id="s.data.id" :name="s.data.name" class="whitespace-nowrap overflow-hidden cursor-pointer" hideCopy />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="postcss">
|
||||||
|
.fake-input {
|
||||||
|
@apply w-96 flex border bg-slate-500 rounded px-1 py-0.5;
|
||||||
|
|
||||||
|
&:has(> input:focus-visible) {
|
||||||
|
outline: -webkit-focus-ring-color auto 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
> input {
|
||||||
|
@apply w-full border-none bg-transparent block focus-visible:outline-none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
> img {
|
||||||
|
@apply inline-block w-5 h-5 mt-1 me-1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
36
src/market/type/MarketTypeLabel.vue
Normal file
36
src/market/type/MarketTypeLabel.vue
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ClipboardButton } from '@/components';
|
||||||
|
import { InformationCircleIcon } from '@heroicons/vue/24/outline';
|
||||||
|
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
name?: string;
|
||||||
|
id?: number;
|
||||||
|
hideCopy?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
name: "",
|
||||||
|
id: 0,
|
||||||
|
hideCopy: false
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="id || name" class="flex flex-row">
|
||||||
|
<img v-if="id" :src="`https://images.evetech.net/types/${id}/icon?size=32`" class="inline-block w-5 h-5 me-1 mt-1" alt="" />
|
||||||
|
<template v-if="name">
|
||||||
|
{{ name }}
|
||||||
|
<RouterLink v-if="id" :to="{ name: 'market-types', params: { type: id } }" class="button btn-icon ms-1 me-1 mt-1" title="Show item info">
|
||||||
|
<InformationCircleIcon />
|
||||||
|
</RouterLink>
|
||||||
|
<ClipboardButton v-if="!hideCopy" :value="name" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="postcss">
|
||||||
|
button:deep(>svg), .button:deep(>svg) {
|
||||||
|
@apply !w-4 !h-4;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
4
src/market/type/index.ts
Normal file
4
src/market/type/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './MarketType';
|
||||||
|
export { default as MarketTypeLabel } from './MarketTypeLabel.vue';
|
||||||
|
export { default as MarketTypeInput } from './MarketTypeInput.vue';
|
||||||
|
|
||||||
8
src/pages/About.vue
Normal file
8
src/pages/About.vue
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<span>EVE Online and the EVE logo are the registered trademarks of CCP hf. All rights are reserved worldwide. All other trademarks are the property of their respective owners. EVE Online, the EVE logo, EVE and all associated logos and designs are the intellectual property of CCP hf. All artwork, screenshots, characters, vehicles, storylines, world facts or other recognizable features of the intellectual property relating to these trademarks are likewise the intellectual property of CCP hf. CCP hf. has granted permission to Eveal to use EVE Online and all associated logos and designs for promotional and information purposes on its website but does not endorse, and is not in any way affiliated with, Eveal. CCP is in no way responsible for the content on or functioning of this website, nor can it be liable for any damage arising from the use of this website.</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
11
src/pages/Characters.vue
Normal file
11
src/pages/Characters.vue
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const addCharacter = () => {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid mb-2 mt-4">
|
||||||
|
<button class="justify-self-end" @click="addCharacter">Add chacarcter</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
6
src/pages/Index.vue
Normal file
6
src/pages/Index.vue
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div></div>
|
||||||
|
</template>
|
||||||
30
src/pages/Market.vue
Normal file
30
src/pages/Market.vue
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterLink, RouterView } from 'vue-router';
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="flex border-b-2 border-emerald-500">
|
||||||
|
<RouterLink :to="{name: 'market-types'}" class="tab">
|
||||||
|
<span>Item Info</span>
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink to="/market/tracking" class="tab">
|
||||||
|
<span>Tracking</span>
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink to="/market/acquisitions" class="tab">
|
||||||
|
<span>Acquisitions</span>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
<RouterView />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="postcss">
|
||||||
|
a.tab {
|
||||||
|
@apply flex items-center px-4 me-2 rounded-t-md bg-slate-600 hover:bg-slate-700;
|
||||||
|
&.router-link-active {
|
||||||
|
@apply bg-emerald-500 hover:bg-emerald-700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,37 +1,35 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ReprocessInput, ReprocessItemValues, ReprocessResultTable, reprocess } from '@/reprocess';
|
||||||
import { useStorage } from '@vueuse/core';
|
import { useStorage } from '@vueuse/core';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import ReprocessInput from './ReprocessInput.vue';
|
|
||||||
import ReprocessResultTable from './ReprocessResultTable.vue';
|
|
||||||
import { ReprocessItemValues, reprocess } from './reprocess';
|
|
||||||
|
|
||||||
const items = ref("");
|
const items = ref("");
|
||||||
const minerals = ref("");
|
const materials = ref("");
|
||||||
const efficiency = useStorage('reprocess-efficiency', 0.55);
|
const efficiency = useStorage('reprocess-efficiency', 0.55);
|
||||||
|
|
||||||
const result = ref<ReprocessItemValues[]>([]);
|
const result = ref<ReprocessItemValues[]>([]);
|
||||||
|
|
||||||
const send = async () => result.value = await reprocess(items.value, minerals.value, efficiency.value);
|
const send = async () => result.value = await reprocess(items.value, materials.value, efficiency.value);
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="grid mb-2 mt-4 px-4">
|
<div class="grid mb-2 mt-4">
|
||||||
<div class="justify-self-end">
|
<div class="justify-self-end">
|
||||||
<span>Reprocess efficiency: </span>
|
<span>Reprocess efficiency: </span>
|
||||||
<input type="number" min="0" max="1" step="0.05" v-model="efficiency" />
|
<input type="number" min="0" max="1" step="0.05" v-model="efficiency" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-stretch px-4">
|
<div class="flex items-stretch">
|
||||||
<ReprocessInput name="Item JSON" v-model="items" />
|
<ReprocessInput name="Item JSON" v-model="items" />
|
||||||
<ReprocessInput name="Mineral JSON" v-model="minerals" />
|
<ReprocessInput name="Materials JSON" v-model="materials" />
|
||||||
</div>
|
</div>
|
||||||
<div class="grid my-2 px-4">
|
<div class="grid my-2">
|
||||||
<button class="justify-self-end" @click="send">Send</button>
|
<button class="justify-self-end" @click="send">Send</button>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="result.length > 0">
|
<template v-if="result.length > 0">
|
||||||
<hr />
|
<hr />
|
||||||
<div class="grid mt-2 px-4">
|
<div class="grid mt-2">
|
||||||
<ReprocessResultTable :result="result" />
|
<ReprocessResultTable :result="result" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
11
src/pages/Tools.vue
Normal file
11
src/pages/Tools.vue
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { HaulerTank, ModuleDamage } from '@/tools';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mt-4">
|
||||||
|
<HaulerTank />
|
||||||
|
<hr class="mb-4">
|
||||||
|
<ModuleDamage />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
47
src/pages/market/Acquisitions.vue
Normal file
47
src/pages/market/Acquisitions.vue
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { MarketTypePrice, getMarketTypes, useApraisalStore } from "@/market";
|
||||||
|
import { AcquiredType, AcquisitionResultTable, BuyModal, SellModal, useAcquiredTypesStore } from '@/market/acquisition';
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
|
||||||
|
const buyModal = ref<typeof BuyModal>();
|
||||||
|
const sellModal = ref<typeof SellModal>();
|
||||||
|
|
||||||
|
const apraisalStore = useApraisalStore();
|
||||||
|
const acquiredTypesStore = useAcquiredTypesStore();
|
||||||
|
const items = ref<AcquiredType[]>([]);
|
||||||
|
|
||||||
|
const refresh = async () => await acquiredTypesStore.refresh();
|
||||||
|
|
||||||
|
watch(() => acquiredTypesStore.acquiredTypes, async itms => {
|
||||||
|
if (itms.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prices = await apraisalStore.getPrices(await getMarketTypes(itms.map(i => i.type)));
|
||||||
|
|
||||||
|
items.value = itms.map(i => {
|
||||||
|
const price = prices.find(p => p.type.id === i.type) as MarketTypePrice;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...i,
|
||||||
|
type: price.type,
|
||||||
|
buy: price.buy,
|
||||||
|
sell: price.sell
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="flex">
|
||||||
|
<button class="ms-auto" @click="refresh">Refresh</button>
|
||||||
|
</div>
|
||||||
|
<template v-if="items.length > 0">
|
||||||
|
<AcquisitionResultTable :items="items" @buy="(types, price, buy, sell) => buyModal?.open(types[0].type, { 'Price': price, 'Buy': buy, 'Sell': sell })" @sell="types => sellModal?.open(types)" ignoredColums="date" />
|
||||||
|
<BuyModal ref="buyModal" />
|
||||||
|
<SellModal ref="sellModal" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
82
src/pages/market/Tracking.vue
Normal file
82
src/pages/market/Tracking.vue
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Modal, ProgressBar } from "@/components";
|
||||||
|
import { MarketType, MarketTypeInput, MarketTypePrice, getHistory, getMarketTypes, useApraisalStore } from "@/market";
|
||||||
|
import { BuyModal } from '@/market/acquisition';
|
||||||
|
import { TrackingResult, TrackingResultTable, createResult, useMarketTrackingStore } from '@/market/tracking';
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
|
||||||
|
|
||||||
|
const buyModal = ref<typeof BuyModal>();
|
||||||
|
|
||||||
|
const item = ref<MarketType>();
|
||||||
|
|
||||||
|
const apraisalStore = useApraisalStore();
|
||||||
|
const marketTrackingStore = useMarketTrackingStore();
|
||||||
|
const items = ref<TrackingResult[]>([]);
|
||||||
|
const addOrRelaod = async (type: MarketType) => {
|
||||||
|
const typeID = type.id;
|
||||||
|
const [history, price] = await Promise.all([
|
||||||
|
getHistory(typeID),
|
||||||
|
apraisalStore.getPrice(type)
|
||||||
|
]);
|
||||||
|
const itm = {
|
||||||
|
type,
|
||||||
|
history,
|
||||||
|
buy: price.buy,
|
||||||
|
sell: price.sell,
|
||||||
|
orderCount: price.orderCount
|
||||||
|
};
|
||||||
|
|
||||||
|
if (items.value.some(i => i.type.id === typeID)) {
|
||||||
|
items.value = items.value.map(i => i.type.id === typeID ? itm : i);
|
||||||
|
} else {
|
||||||
|
items.value = [ ...items.value, itm];
|
||||||
|
marketTrackingStore.addType(typeID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const addItem = async () => {
|
||||||
|
if (!item.value) {
|
||||||
|
// TODO error
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addOrRelaod(item.value);
|
||||||
|
item.value = undefined;
|
||||||
|
}
|
||||||
|
const removeItem = (type: MarketType) => {
|
||||||
|
items.value = items.value.filter(i => i.type.id !== type.id);
|
||||||
|
marketTrackingStore.removeType(type.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => marketTrackingStore.types, async t => {
|
||||||
|
const typesToLoad = t.filter(t => !items.value.some(i => i.type.id === t));
|
||||||
|
|
||||||
|
if (typesToLoad.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prices = await apraisalStore.getPrices(await getMarketTypes(typesToLoad));
|
||||||
|
|
||||||
|
typesToLoad.forEach(async i => items.value.push(await createResult(i, prices.find(p => p.type.id === i) as MarketTypePrice)));
|
||||||
|
}, { immediate: true });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid mb-2 mt-4">
|
||||||
|
<div class="w-auto flex">
|
||||||
|
<span>Item: </span>
|
||||||
|
<MarketTypeInput class="ms-2" v-model="item" @submit="addItem"/>
|
||||||
|
<button class="justify-self-end ms-2" @click="addItem">Add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template v-if="items.length > 0">
|
||||||
|
<hr />
|
||||||
|
<TrackingResultTable :items="items" @buy="(type, buy, sell) => buyModal?.open(type, { 'Buy': buy, 'Sell': sell })" @remove="removeItem" />
|
||||||
|
<BuyModal ref="buyModal" />
|
||||||
|
<Modal :open="items.length > 0 && items.length < marketTrackingStore.types.length">
|
||||||
|
<div class="ms-auto me-auto mb-2 w-96">
|
||||||
|
<ProgressBar :value="items.length" :total="marketTrackingStore.types.length" />
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
121
src/pages/market/TypeInfo.vue
Normal file
121
src/pages/market/TypeInfo.vue
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ClipboardButton } from '@/components';
|
||||||
|
import { MarketType, MarketTypeInput, getMarketType, useApraisalStore } from "@/market";
|
||||||
|
import { AcquisitionResultTable, BuyModal, useAcquiredTypesStore } from '@/market/acquisition';
|
||||||
|
import { TrackingResultTable, createResult, useMarketTrackingStore } from '@/market/tracking';
|
||||||
|
import { BookmarkIcon, BookmarkSlashIcon, ShoppingCartIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import { computedAsync } from '@vueuse/core/index.cjs';
|
||||||
|
import log from "loglevel";
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
|
||||||
|
const buyModal = ref<typeof BuyModal>();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const item = ref<MarketType>();
|
||||||
|
const inputItem = ref<MarketType>();
|
||||||
|
|
||||||
|
const apraisalStore = useApraisalStore();
|
||||||
|
const price = computedAsync(() => item.value ? apraisalStore.getPrice(item.value) : undefined);
|
||||||
|
const marketTrackingStore = useMarketTrackingStore();
|
||||||
|
const result = computedAsync(async () => item.value && price.value ? await createResult(item.value?.id, price.value) : undefined);
|
||||||
|
const acquiredTypesStore = useAcquiredTypesStore();
|
||||||
|
|
||||||
|
const isTracked = computed(() => item.value ? marketTrackingStore.types.includes(item.value.id) : false);
|
||||||
|
const acquisitions = computed(() => {
|
||||||
|
const p = price.value;
|
||||||
|
|
||||||
|
return !p ?[] : acquiredTypesStore.acquiredTypes
|
||||||
|
.filter(t => t.type === item.value?.id)
|
||||||
|
.map(i => ({
|
||||||
|
...i,
|
||||||
|
type: p.type,
|
||||||
|
buy: p.buy,
|
||||||
|
sell: p.sell
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
const toogleTracking = () => {
|
||||||
|
if (!item.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isTracked.value) {
|
||||||
|
marketTrackingStore.removeType(item.value.id);
|
||||||
|
} else {
|
||||||
|
marketTrackingStore.addType(item.value.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const view = () => {
|
||||||
|
if (!inputItem.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push({
|
||||||
|
name: 'market-types',
|
||||||
|
params: {
|
||||||
|
type: inputItem.value.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(useRoute(), async route => {
|
||||||
|
if (route.params.type) {
|
||||||
|
const id = parseInt(typeof route.params.type === 'string' ? route.params.type : route.params.type[0]);
|
||||||
|
|
||||||
|
item.value = await getMarketType(id);
|
||||||
|
inputItem.value = item.value;
|
||||||
|
log.info('Loaded item:', item.value);
|
||||||
|
} else {
|
||||||
|
item.value = undefined;
|
||||||
|
inputItem.value = undefined;
|
||||||
|
log.info('No item to load');
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid mb-2 mt-4">
|
||||||
|
<div class="w-auto flex">
|
||||||
|
<span>Item: </span>
|
||||||
|
<MarketTypeInput class="ms-2" v-model="inputItem" @submit="view"/>
|
||||||
|
<button class="justify-self-end ms-2" @click="view">View</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template v-if="item">
|
||||||
|
<hr>
|
||||||
|
<div class="p-2 mb-4 flex">
|
||||||
|
<img v-if="item.id" :src="`https://images.evetech.net/types/${item.id}/icon?size=64`" class="type-image inline-block me-2" alt="" />
|
||||||
|
<div class="inline-block align-top w-full">
|
||||||
|
<div class="flex">
|
||||||
|
<span class="text-lg font-semibold">{{ item.name }}</span>
|
||||||
|
<div class="ms-auto">
|
||||||
|
<ClipboardButton class="ms-1" :value="item.name" />
|
||||||
|
<button v-if="price" class="btn-icon ms-1" title="Add acquisitions" @click="buyModal?.open(item, { 'Buy': price.buy, 'Sell': price.sell })"><ShoppingCartIcon /></button>
|
||||||
|
<button class="btn-icon ms-1" :title="isTracked ? 'Untrack' : 'Track'" @click="toogleTracking">
|
||||||
|
<BookmarkSlashIcon v-if="isTracked" />
|
||||||
|
<BookmarkIcon v-else />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="item.description" class="text-sm">{{ item.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="result" class="mb-4">
|
||||||
|
<span>Market Info:</span>
|
||||||
|
<TrackingResultTable :items="[result]" infoOnly :ignoredColums="['name', 'acquisitions']" />
|
||||||
|
</div>
|
||||||
|
<div v-if="acquisitions && acquisitions.length > 0">
|
||||||
|
<span>Acquisitions:</span>
|
||||||
|
<AcquisitionResultTable :items="acquisitions" infoOnly showAll :ignoredColums="['name', 'buy', 'sell']" defaultSortKey="date"/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<BuyModal ref="buyModal" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="postcss">
|
||||||
|
img.type-image {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
19
src/reprocess/BuySellSlider.vue
Normal file
19
src/reprocess/BuySellSlider.vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
const modelValue = defineModel({ default: false });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<label class="flex items-center relative w-max cursor-pointer select-none">
|
||||||
|
<input type="checkbox" class="appearance-none transition-colors cursor-pointer w-14 h-7 rounded-full" v-model="modelValue" />
|
||||||
|
<span class="absolute font-medium text-xs right-1"> Buy </span>
|
||||||
|
<span class="absolute font-medium text-xs right-8"> Sell </span>
|
||||||
|
<span class="w-7 h-7 right-7 absolute rounded-full transform transition-transform bg-slate-100" />
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="postcss">
|
||||||
|
input:checked ~ span:last-child {
|
||||||
|
--tw-translate-x: 1.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,22 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { evepraisalAxiosInstance } from '@/service';
|
import { evepraisalAxiosInstance } from '@/market/appraisal/evepraisal';
|
||||||
import { useVModel } from '@vueuse/core';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
name: string;
|
name: string;
|
||||||
modelValue?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Emits {
|
const modelValue = defineModel({ default: '' });
|
||||||
(e: 'update:modelValue', value: string): void;
|
defineProps<Props>();
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
modelValue: ''
|
|
||||||
});
|
|
||||||
const emit = defineEmits<Emits>();
|
|
||||||
|
|
||||||
const value = useVModel(props, 'modelValue', emit);
|
|
||||||
|
|
||||||
const loadFromId = async (e: Event) => {
|
const loadFromId = async (e: Event) => {
|
||||||
const input = e.target as HTMLInputElement;
|
const input = e.target as HTMLInputElement;
|
||||||
@@ -31,7 +21,7 @@ const loadFromId = async (e: Event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
value.value = JSON.stringify(response.data);
|
modelValue.value = JSON.stringify(response.data);
|
||||||
input.value = '';
|
input.value = '';
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -39,6 +29,6 @@ const loadFromId = async (e: Event) => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex-1 mx-1">
|
<div class="flex-1 mx-1">
|
||||||
<span>{{ name }}</span><input type="text" class="ms-2" @change="loadFromId" placeholder="id evepraisal" />
|
<span>{{ name }}</span><input type="text" class="ms-2" @change="loadFromId" placeholder="id evepraisal" />
|
||||||
<textarea class="mt-1" v-model="value" />
|
<textarea class="mt-1" v-model="modelValue" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { iskFormater, percentFormater } from '@/formaters';
|
import { SortableHeader, useSort, VirtualScrollTable } from '@/components/table';
|
||||||
|
import { formatIsk, percentFormater } from '@/formaters';
|
||||||
|
import { MarketTypeLabel } from '@/market/type';
|
||||||
import { useStorage } from '@vueuse/core';
|
import { useStorage } from '@vueuse/core';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
import BuySellSlider from './BuySellSlider.vue';
|
||||||
import { ReprocessItemValues } from './reprocess';
|
import { ReprocessItemValues } from './reprocess';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -12,40 +15,62 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
result: () => []
|
result: () => []
|
||||||
});
|
});
|
||||||
|
|
||||||
const computedResult = computed(() => props.result.map(r =>({
|
const threshold = useStorage('reprocess-threshold', 0);
|
||||||
...r,
|
const useSellOrderForMarket = useStorage('reprocess-market-use-sell-order', false);
|
||||||
buy_ratio: (r.buy_reprocess / r.buy) - 1,
|
const useSellOrderForMaterials = useStorage('reprocess-materials-use-sell-order', false);
|
||||||
sell_ratio: (r.sell_reprocess / r.sell) - 1
|
|
||||||
})).sort((a, b) => a.name.localeCompare(b.name)))
|
|
||||||
|
|
||||||
const threshold = useStorage('reprocess-threshold', 100);
|
const { sortedArray, headerProps } = useSort(computed(() => props.result.map(r => {
|
||||||
|
const market = useSellOrderForMarket.value ? r.sell : r.buy;
|
||||||
|
const materials = useSellOrderForMaterials.value ? r.sell_reprocess : r.buy_reprocess;
|
||||||
|
const ratio = market === 0 ? 1 : ((materials / market) - 1);
|
||||||
|
|
||||||
|
return { ...r, market, materials, ratio };
|
||||||
|
})), {
|
||||||
|
defaultSortKey: 'name',
|
||||||
|
defaultSortDirection: 'asc'
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="grid mb-2 mt-4">
|
<div class="flex justify-self-end mb-2 mt-4 ms-auto">
|
||||||
<div class="justify-self-end">
|
<div class="justify-self-end me-4 flex flex-col">
|
||||||
|
<div class="flex mb-1">
|
||||||
|
<BuySellSlider v-model="useSellOrderForMarket" class="me-2" /> Market
|
||||||
|
</div>
|
||||||
|
<div class="flex">
|
||||||
|
<BuySellSlider v-model="useSellOrderForMaterials" class="me-2" /> Materials
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="justify-self-end mt-auto mb-auto">
|
||||||
<span>Threshold: </span>
|
<span>Threshold: </span>
|
||||||
<input type="number" min="-100" max="1000" step="1" v-model="threshold" />
|
<input type="number" min="-100" max="100" step="1" v-model="threshold" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<table>
|
<VirtualScrollTable :list="sortedArray" :itemHeight="33" bottom="1rem">
|
||||||
|
<template #default="{ list }">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Item</th>
|
<SortableHeader v-bind="headerProps" sortKey="name">Item</SortableHeader>
|
||||||
<th>Buy</th>
|
<SortableHeader v-bind="headerProps" sortKey="market">Market</SortableHeader>
|
||||||
<th>Buy reprocess</th>
|
<SortableHeader v-bind="headerProps" sortKey="materials">Materials</SortableHeader>
|
||||||
<th>Sell</th>
|
<SortableHeader v-bind="headerProps" sortKey="ratio">Percent</SortableHeader>
|
||||||
<th>Sell reprocess</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="r in computedResult" :key="r.typeID" :class="{'bg-emerald-500': r.buy_ratio * threshold >= 100 }">
|
<tr v-for="r in list" :key="r.data.typeID" :class="{'line-green': r.data.ratio >= threshold / 100 }">
|
||||||
<td>{{ r.name }}</td>
|
<td>
|
||||||
<td class="text-right">{{ iskFormater.format(r.buy) }}</td>
|
<MarketTypeLabel :id="r.data.typeID" :name="r.data.name" />
|
||||||
<td class="text-right">{{ iskFormater.format(r.buy_reprocess) }} ({{percentFormater.format(r.buy_ratio)}})</td>
|
</td>
|
||||||
<td class="text-right">{{ iskFormater.format(r.sell) }}</td>
|
<td class="text-right">{{ formatIsk(r.data.market) }}</td>
|
||||||
<td class="text-right">{{ iskFormater.format(r.sell_reprocess) }} ({{percentFormater.format(r.sell_ratio)}})</td>
|
<td class="text-right">{{ formatIsk(r.data.materials) }}</td>
|
||||||
|
<td class="text-right">{{ percentFormater.format(r.data.ratio) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</template>
|
||||||
</template>@/formaters
|
<template #empty>
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
<span>No items found</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</VirtualScrollTable>
|
||||||
|
</template>
|
||||||
|
|||||||
@@ -1,2 +1,5 @@
|
|||||||
export { default as Reprocess } from './Reprocess.vue'
|
export * from './reprocess';
|
||||||
export * from './reprocess'
|
|
||||||
|
export { default as ReprocessInput } from './ReprocessInput.vue';
|
||||||
|
export { default as ReprocessResultTable } from './ReprocessResultTable.vue';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { apiAxiosInstance } from "@/service";
|
import { marbasAxiosInstance } from "@/marbas";
|
||||||
|
|
||||||
export type ReprocessItemValues = {
|
export type ReprocessItemValues = {
|
||||||
typeID: number;
|
typeID: number;
|
||||||
@@ -22,7 +22,7 @@ export const reprocess = async (items: string, minerals: string, efficiency?: nu
|
|||||||
};
|
};
|
||||||
const source = JSON.stringify(sourceJson);
|
const source = JSON.stringify(sourceJson);
|
||||||
|
|
||||||
const response = await apiAxiosInstance.post('/reprocess', source, {params: {efficiency: efficiency ?? 0.55}});
|
const response = await marbasAxiosInstance.post('/reprocess/', source, {params: {efficiency: efficiency ?? 0.55}});
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|||||||
16
src/routes.ts
Normal file
16
src/routes.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { RouteRecordRaw } from 'vue-router';
|
||||||
|
|
||||||
|
export const routes: RouteRecordRaw[] = [
|
||||||
|
{ path: '/', name: 'home', component: () => import('@/pages/Index.vue') },
|
||||||
|
{ path: '/callback', name: 'callback', component: () => import('@/pages/Index.vue') },
|
||||||
|
{ path: '/reprocess', component: () => import('@/pages/Reprocess.vue') },
|
||||||
|
{ path: '/market', component: () => import('@/pages/Market.vue'), children: [
|
||||||
|
{ path: '', redirect: '/market/types' },
|
||||||
|
{ path: 'types/:type?', name: 'market-types', component: () => import('@/pages/market/TypeInfo.vue') },
|
||||||
|
{ path: 'tracking', component: () => import('@/pages/market/Tracking.vue') },
|
||||||
|
{ path: 'acquisitions', component: () => import('@/pages/market/Acquisitions.vue') },
|
||||||
|
] },
|
||||||
|
{ path: '/tools', component: () => import('@/pages/Tools.vue') },
|
||||||
|
{ path: '/characters', component: () => import('@/pages/Characters.vue') },
|
||||||
|
{ path: '/about', name: 'about', component: () => import('@/pages/About.vue') },
|
||||||
|
];
|
||||||
@@ -1,20 +1,25 @@
|
|||||||
import axios from 'axios';
|
import axios, { AxiosInstance } from 'axios';
|
||||||
|
import rateLimit from 'axios-rate-limit';
|
||||||
|
import log from 'loglevel';
|
||||||
|
|
||||||
const evealApiUrl = process.env.EVEAL_API_URL;
|
export const logResource = (a: AxiosInstance) => {
|
||||||
const evepraisalUrl = process.env.EVEPRAISAL_URL;
|
a.interceptors.response.use(r => {
|
||||||
|
log.debug(`[${r.config.method?.toUpperCase()}] ${r.config.url}`);
|
||||||
|
return r;
|
||||||
|
}, e => {
|
||||||
|
if (!e?.config) {
|
||||||
|
log.error(e.message, e);
|
||||||
|
}
|
||||||
|
log.error(`[${e.config?.method?.toUpperCase()}] ${e.config?.url} failed with ${e.response?.status} ${e.response?.statusText}`, e);
|
||||||
|
return Promise.reject(e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const apiAxiosInstance = axios.create({
|
export const esiAxiosInstance = rateLimit(axios.create({
|
||||||
baseURL: evealApiUrl,
|
baseURL: import.meta.env.VITE_ESI_URL,
|
||||||
headers: {
|
headers: {
|
||||||
'accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
},
|
},
|
||||||
})
|
}), { maxRPS: 10 })
|
||||||
|
logResource(esiAxiosInstance)
|
||||||
export const evepraisalAxiosInstance = axios.create({
|
|
||||||
baseURL: evepraisalUrl,
|
|
||||||
headers: {
|
|
||||||
'accept': 'application/json',
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|||||||
73
src/sidebar/Sidebar.vue
Normal file
73
src/sidebar/Sidebar.vue
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useAuthStore } from '@/auth';
|
||||||
|
import { Dropdown } from '@/components';
|
||||||
|
import { RouterLink } from 'vue-router';
|
||||||
|
|
||||||
|
const links = [
|
||||||
|
{ name: "Market", path: "/market" },
|
||||||
|
{ name: "Reprocess", path: "/reprocess" },
|
||||||
|
{ name: "Tools", path: "/tools" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
await authStore.logout();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<aside class="fixed top-0 left-0 w-64 h-screen transition-transform -translate-x-full sm:translate-x-0">
|
||||||
|
<div class="h-full px-3 py-4 overflow-y-auto bg-slate-700 flex flex-col">
|
||||||
|
<div class="mb-2 border-b-2 border-emerald-500">
|
||||||
|
<Dropdown class="mb-2 user-dropdown">
|
||||||
|
<template #button>
|
||||||
|
<span>{{ authStore.username }}</span>
|
||||||
|
</template>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<RouterLink class="sidebar-button py-0.5 px-2" to="/characters">Characters</RouterLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<RouterLink class="sidebar-button py-0.5 px-2" :to="{name: 'about'}">About EVE Online</RouterLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="sidebar-button py-0.5 px-2 text-amber-700" @click="logout">Logout</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
<ul class="space-y-2 font-medium">
|
||||||
|
<li v-for="link in links" :key="link.name">
|
||||||
|
<RouterLink :to="link.path" class="sidebar-button p-2">
|
||||||
|
<span>{{ link.name }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="postcss">
|
||||||
|
.sidebar-button {
|
||||||
|
@apply flex items-center rounded-md hover:bg-slate-800 cursor-pointer;
|
||||||
|
}
|
||||||
|
.router-link-active {
|
||||||
|
@apply bg-emerald-500 hover:bg-emerald-700;
|
||||||
|
}
|
||||||
|
.user-dropdown {
|
||||||
|
@apply w-full;
|
||||||
|
:deep(>div) {
|
||||||
|
@apply w-full;
|
||||||
|
>div {
|
||||||
|
@apply w-full bg-slate-800;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
:deep(>button) {
|
||||||
|
@apply bg-slate-700 hover:bg-slate-800 border-none flex items-center w-full;
|
||||||
|
}
|
||||||
|
&.dropdown-open:deep(>button) {
|
||||||
|
@apply bg-slate-800 rounded-b-none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1
src/sidebar/index.ts
Normal file
1
src/sidebar/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as Sidebar } from './Sidebar.vue';
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
span, table, input, th, tr, td, button, div, hr {
|
||||||
@apply border-slate-600 text-slate-100 placeholder-slate-400;
|
@apply border-slate-600 text-slate-100 placeholder-slate-400;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
@apply py-0.5 px-2 border rounded bg-slate-600 hover:bg-slate-800;
|
@apply py-0.5 px-2 border rounded bg-slate-600 hover:bg-slate-700;
|
||||||
}
|
}
|
||||||
input {
|
input {
|
||||||
@apply border bg-slate-500 rounded px-1;
|
@apply border bg-slate-500 rounded px-1;
|
||||||
@@ -21,24 +21,57 @@
|
|||||||
@apply border rounded bg-slate-500 w-full;
|
@apply border rounded bg-slate-500 w-full;
|
||||||
}
|
}
|
||||||
|
|
||||||
table {
|
table, .table {
|
||||||
@apply table-auto border-collapse border-slate-500 w-full;
|
@apply table-auto border-collapse border-slate-500 w-full;
|
||||||
}
|
}
|
||||||
th {
|
.table-header {
|
||||||
|
@apply table-cell;
|
||||||
|
}
|
||||||
|
.table-cell {
|
||||||
|
@apply pt-px pb-px;
|
||||||
|
}
|
||||||
|
th, .table-header {
|
||||||
@apply border bg-slate-600 px-1;
|
@apply border bg-slate-600 px-1;
|
||||||
}
|
}
|
||||||
td {
|
td, .table-cell {
|
||||||
@apply border px-1;
|
@apply border px-1;
|
||||||
}
|
}
|
||||||
|
tr, .table-row {
|
||||||
|
@apply hover:bg-slate-900;
|
||||||
|
|
||||||
|
&.line-red {
|
||||||
|
@apply bg-amber-900 hover:bg-amber-950;
|
||||||
|
}
|
||||||
|
&.line-blue {
|
||||||
|
@apply bg-sky-600 hover:bg-sky-800;
|
||||||
|
}
|
||||||
|
&.line-green {
|
||||||
|
@apply bg-emerald-500 hover:bg-emerald-600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tfoot>tr>td {
|
||||||
|
@apply font-semibold;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
@apply w-3;
|
@apply w-3;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
@apply bg-slate-500;
|
@apply bg-slate-500 rounded;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
@apply bg-slate-600 hover:bg-slate-800;
|
@apply bg-slate-600 hover:bg-slate-700;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
}
|
}
|
||||||
|
input[type=search] {
|
||||||
|
@apply search-cancel:appearance-none search-cancel:w-4 search-cancel:h-4 search-cancel:bg-[url('/svg/search-cancel.svg')];
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
@apply p-0 border-none bg-transparent hover:text-slate-400 hover:bg-transparent;
|
||||||
|
> svg {
|
||||||
|
@apply w-6 h-6;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
31
src/tools/HaulerTank.vue
Normal file
31
src/tools/HaulerTank.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { formatIsk } from '@/formaters';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
const tornadosDPS = 24000;
|
||||||
|
const tornadosCost = 100000000;
|
||||||
|
|
||||||
|
const fitCost = ref(0);
|
||||||
|
const fitEHP = ref(0);
|
||||||
|
|
||||||
|
const haulableValue = computed(() => formatIsk(Math.ceil((fitEHP.value * 1000) / tornadosDPS) * tornadosCost - (fitCost.value * 1000000)));
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="font-bold text-lg">Haulable Value</span>
|
||||||
|
<div class="grid grid-cols-3 mb-2 mt-2">
|
||||||
|
<div class="flex">
|
||||||
|
<span>Cost (million ISK): </span>
|
||||||
|
<input type="number" class="flex-auto ms-1 me-2" step="1" v-model="fitCost" />
|
||||||
|
</div>
|
||||||
|
<div class="flex ms-2">
|
||||||
|
<span>EHP (thousand EHP): </span>
|
||||||
|
<input type="number" class="flex-auto ms-1 me-2" step="1" v-model="fitEHP" />
|
||||||
|
</div>
|
||||||
|
<div class="ms-2">
|
||||||
|
<span>Haulable Value: </span>
|
||||||
|
<span>{{ haulableValue }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
32
src/tools/ModuleDamage.vue
Normal file
32
src/tools/ModuleDamage.vue
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
const percentFormater = new Intl.NumberFormat("en-US", {
|
||||||
|
style: "percent",
|
||||||
|
minimumFractionDigits: 2
|
||||||
|
});
|
||||||
|
|
||||||
|
const damageBonus = ref(10);
|
||||||
|
const rateOfFireBonus = ref(10.50);
|
||||||
|
|
||||||
|
const totalBonus = computed(() => percentFormater.format((1 + (damageBonus.value / 100)) / (1 - (rateOfFireBonus.value / 100)) - 1));
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="font-bold text-lg">Module Damage Bunus</span>
|
||||||
|
<div class="grid grid-cols-3 mb-2 mt-2">
|
||||||
|
<div class="flex">
|
||||||
|
<span>Damage Bonus (%): </span>
|
||||||
|
<input type="number" class="flex-auto ms-1 me-2" step="0.001" v-model="damageBonus" />
|
||||||
|
</div>
|
||||||
|
<div class="flex ms-2">
|
||||||
|
<span>Rate of Fire Bonus (%): </span>
|
||||||
|
<input type="number" class="flex-auto ms-1 me-2" step="0.001" v-model="rateOfFireBonus" />
|
||||||
|
</div>
|
||||||
|
<div class="ms-2">
|
||||||
|
<span>Total Bonus: </span>
|
||||||
|
<span>{{ totalBonus }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
2
src/tools/index.ts
Normal file
2
src/tools/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as HaulerTank } from './HaulerTank.vue';
|
||||||
|
export { default as ModuleDamage } from './ModuleDamage.vue';
|
||||||
1
src/utils.ts
Normal file
1
src/utils.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const copyToClipboard = (s: string) => navigator.clipboard.writeText(s);
|
||||||
3
svg/search-cancel.svg
Normal file
3
svg/search-cancel.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="#F1F5F9" class="w-6 h-6">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 215 B |
@@ -7,6 +7,10 @@ export default {
|
|||||||
theme: {
|
theme: {
|
||||||
extend: {},
|
extend: {},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [
|
||||||
|
require('tailwindcss/plugin')(({ addVariant }) => {
|
||||||
|
addVariant('search-cancel', '&::-webkit-search-cancel-button');
|
||||||
|
}),
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2020",
|
"target": "ESNext",
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import vue from '@vitejs/plugin-vue';
|
import vue from '@vitejs/plugin-vue';
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import { defineConfig, loadEnv } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
import runtimeEnv from 'vite-plugin-runtime-env';
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig({
|
||||||
const env = loadEnv(mode, process.cwd(), '');
|
plugins: [
|
||||||
|
runtimeEnv(),
|
||||||
return {
|
vue(),
|
||||||
plugins: [vue()],
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'src': path.resolve(__dirname, './src/'),
|
'src': path.resolve(__dirname, './src/'),
|
||||||
@@ -14,27 +15,11 @@ export default defineConfig(({ mode }) => {
|
|||||||
},
|
},
|
||||||
extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.vue']
|
extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.vue']
|
||||||
},
|
},
|
||||||
define: {
|
|
||||||
'process.env.EVEAL_API_URL': JSON.stringify(env.EVEAL_API_URL),
|
|
||||||
'process.env.EVEPRAISAL_URL': JSON.stringify(env.EVEPRAISAL_URL),
|
|
||||||
},
|
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
proxy: {
|
watch: {
|
||||||
'/api/': {
|
usePolling: true
|
||||||
target: 'https://api.eveal.shendai.rip/',
|
|
||||||
changeOrigin: true,
|
|
||||||
followRedirects: true,
|
|
||||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
|
||||||
},
|
|
||||||
'/appraisal/': {
|
|
||||||
target: 'https://evepraisal.shendai.rip/',
|
|
||||||
changeOrigin: true,
|
|
||||||
followRedirects: true,
|
|
||||||
rewrite: (path) => path.replace(/^\/appraisal/, ''),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
};
|
|
||||||
})
|
|
||||||
|
|||||||
Reference in New Issue
Block a user