Introduce new UI design; upgrade to Vue 3 tech stack
BIN
.DS_Store
vendored
Normal file
|
|
@ -6,9 +6,10 @@ module.exports = {
|
|||
},
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:vue/recommended",
|
||||
"plugin:vue/vue3-recommended",
|
||||
"plugin:prettier/recommended",
|
||||
"prettier",
|
||||
"plugin:storybook/recommended",
|
||||
],
|
||||
rules: {
|
||||
"vue/require-default-prop": "off",
|
||||
|
|
|
|||
5
.github/workflows/default.yml
vendored
|
|
@ -72,6 +72,11 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: "16"
|
||||
|
||||
- name: Cache NPM modules
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
|
|
|
|||
1
.gitignore
vendored
|
|
@ -24,3 +24,4 @@ ca-cert.srl
|
|||
server-key.pem
|
||||
ca-key.pem
|
||||
cmd/wip
|
||||
fly.toml
|
||||
|
|
@ -1,4 +1,23 @@
|
|||
const { loadConfigFromFile } = require("vite");
|
||||
const path = require("path");
|
||||
|
||||
module.exports = {
|
||||
stories: ["../assets/js/**/*.stories.mdx", "../assets/js/**/*.stories.@(js|jsx|ts|tsx)"],
|
||||
addons: ["@storybook/addon-links", "@storybook/addon-essentials", "@storybook/addon-postcss"],
|
||||
framework: "@storybook/vue3",
|
||||
core: {
|
||||
builder: "storybook-builder-vite",
|
||||
},
|
||||
staticDirs: ["../assets"],
|
||||
async viteFinal(config, b) {
|
||||
const { config: userConfig } = await loadConfigFromFile(
|
||||
path.resolve(__dirname, "../vite.config.ts")
|
||||
);
|
||||
const vuePlugin = userConfig.plugins.find((p) => p.name === "vite:vue");
|
||||
|
||||
const vuePluginIndex = config.plugins.findIndex((p) => p.name === "vite:vue");
|
||||
config.plugins[vuePluginIndex] = vuePlugin;
|
||||
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
<link href="/css/bootstrap.min.css" rel="stylesheet" />
|
||||
<script src="/js/bootstrap.min.js" async></script>
|
||||
|
||||
<link href="/css/app.css" rel="stylesheet" />
|
||||
|
|
@ -1,4 +1,26 @@
|
|||
import "bootstrap/dist/css/bootstrap.min.css";
|
||||
import "bootstrap";
|
||||
import smoothscroll from "smoothscroll-polyfill";
|
||||
import { app } from "@storybook/vue3";
|
||||
import i18n from "../assets/js/i18n";
|
||||
import "../assets/css/app.css";
|
||||
|
||||
smoothscroll.polyfill();
|
||||
app.use(i18n);
|
||||
|
||||
export const parameters = {
|
||||
actions: { argTypesRegex: "^on[A-Z].*" },
|
||||
}
|
||||
backgrounds: {
|
||||
default: "dark",
|
||||
values: [
|
||||
{
|
||||
name: "light",
|
||||
value: "#ffffff",
|
||||
},
|
||||
{
|
||||
name: "dark",
|
||||
value: "#28293e",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ RUN make install-ui
|
|||
|
||||
# build ui
|
||||
COPY assets assets
|
||||
COPY vue.config.js vue.config.js
|
||||
COPY vite.config.js vite.config.js
|
||||
COPY .eslintrc.js .eslintrc.js
|
||||
RUN make clean ui
|
||||
|
||||
|
|
|
|||
41
README.md
|
|
@ -41,14 +41,51 @@ Evcc is an extensible EV Charge Controller with PV integration implemented in [G
|
|||
|
||||
You'll find everything you need in our [documentation](https://docs.evcc.io/) (German).
|
||||
|
||||
## Build
|
||||
## Contribute
|
||||
|
||||
To build EVCC from source, [Go][2] 1.18 and [Node][3] 16 are required:
|
||||
To build evcc from source, [Go][2] 1.18 and [Node][3] 16 are required.
|
||||
|
||||
Build and run go backend. The UI becomes available at http://127.0.0.1:7070/
|
||||
|
||||
```sh
|
||||
make
|
||||
./evcc
|
||||
```
|
||||
|
||||
For frontend development start the Vue toolchain in dev-mode. Open http://127.0.0.1:7071/ to get to the livelreloading development server. It pulls its data from port 7070 (see above).
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run start
|
||||
```
|
||||
|
||||
### Code formatting
|
||||
|
||||
We use linters (golangci-lint, Prettier) to keep a coherent source code formatting. It's recommended to use the format-on-save feature of your editor. For VSCode use the [Go](https://marketplace.visualstudio.com/items?itemName=golang.Go), [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) and [Veture](https://marketplace.visualstudio.com/items?itemName=octref.vetur) extension. You can manually reformat your code by running:
|
||||
|
||||
```sh
|
||||
make lint
|
||||
make lint-ui
|
||||
```
|
||||
|
||||
### Changing UI code
|
||||
|
||||
To ensure reproducability the build frontend artifacts are part of the source code repository. If you've made changes to frontend code, please make sure to rebuild the production assets before you commit.
|
||||
|
||||
```sh
|
||||
make ui
|
||||
```
|
||||
|
||||
### Changing templates
|
||||
|
||||
Evcc supports a massive amount of different devices. To keep our documentation and website in sync with the latest software the core project (this repo) generates meta-data that's pushed to the `docs` and `evcc.io` repository. Make sure to update this meta-data every time you make changes to a templates.
|
||||
|
||||
```sh
|
||||
make docs
|
||||
```
|
||||
|
||||
If you miss one of the above steps Gitub Actions will likely trigger a **Porcelain** error.
|
||||
|
||||
## Sponsorship
|
||||
|
||||
Evcc believes in open source software. We're committed to provide best in class EV charging experience.
|
||||
|
|
|
|||
BIN
assets/.DS_Store
vendored
Normal file
|
|
@ -1,14 +1,67 @@
|
|||
@font-face {
|
||||
font-family: "Montserrat";
|
||||
src: url("../font/Montserrat-Medium.woff2") format("woff2");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Montserrat";
|
||||
src: url("../font/Montserrat-Bold.woff2") format("woff2");
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
:root {
|
||||
--evcc-green: #66d85a;
|
||||
--evcc-dark-green: #3aba2c;
|
||||
--evcc-yellow: #ffe000;
|
||||
--evcc-green: #baffcb;
|
||||
--evcc-dark-green: #0fde41;
|
||||
--evcc-yellow: #faf000;
|
||||
--bs-gray-dark: #28293e;
|
||||
--bs-gray-medium: #93949e;
|
||||
|
||||
--evcc-grid: var(--bs-gray-dark);
|
||||
--evcc-self: var(--evcc-dark-green);
|
||||
--evcc-export: var(--evcc-yellow);
|
||||
|
||||
--evcc-transition-slow: 1000ms;
|
||||
--evcc-transition-medium: 500ms;
|
||||
--evcc-transition-fast: 250ms;
|
||||
--evcc-transition-very-fast: 100ms;
|
||||
|
||||
--bs-primary: var(--evcc-dark-green);
|
||||
--bs-primary-rgb: 58, 186, 44;
|
||||
|
||||
--bs-body-font-size: 14px;
|
||||
--bs-font-sans-serif: Montserrat, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
|
||||
Arial, "Noto Sans", "Liberation Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
"Segoe UI Symbol", "Noto Color Emoji";
|
||||
}
|
||||
|
||||
html {
|
||||
/* prevents content jump when scrollbar is activate */
|
||||
width: 100vw;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--evcc-gray-dark);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-size: 1.25rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h3,
|
||||
h4 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.bg-primary {
|
||||
|
|
@ -27,22 +80,33 @@ a {
|
|||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.rounded {
|
||||
border-radius: 10px !important;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-primary:focus {
|
||||
background-color: var(--bs-gray-dark);
|
||||
border-color: var(--bs-gray-dark);
|
||||
}
|
||||
|
||||
.btn-primary:hover,
|
||||
.btn-primary:active {
|
||||
background-color: var(--evcc-dark-green);
|
||||
border-color: var(--evcc-dark-green);
|
||||
}
|
||||
.btn-outline-primary {
|
||||
.btn-outline-primary,
|
||||
.btn-outline-primary:focus {
|
||||
color: var(--bs-gray-dark);
|
||||
background-color: transparent;
|
||||
border-width: 2px;
|
||||
border-color: var(--bs-gray-dark);
|
||||
}
|
||||
.btn-outline-primary:hover {
|
||||
background-color: var(--evcc-dark-green);
|
||||
.btn-outline-primary:hover,
|
||||
.btn-outline-primary:active {
|
||||
color: var(--evcc-dark-green);
|
||||
background-color: transparent;
|
||||
border-width: 2px;
|
||||
border-color: var(--evcc-dark-green);
|
||||
}
|
||||
.text-evcc {
|
||||
|
|
@ -52,6 +116,18 @@ a {
|
|||
.text-grid {
|
||||
color: var(--evcc-grid);
|
||||
}
|
||||
.text-dark {
|
||||
color: var(--bs-gray-dark);
|
||||
}
|
||||
.text-gray {
|
||||
color: var(--bs-gray-medium);
|
||||
}
|
||||
.text-gray-medium {
|
||||
color: var(--bs-gray-medium);
|
||||
}
|
||||
.bg-dark {
|
||||
background-color: var(--bs-gray-dark) !important;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 400px) and (max-width: 574px) {
|
||||
.d-xs-none {
|
||||
|
|
@ -62,6 +138,33 @@ a {
|
|||
}
|
||||
}
|
||||
|
||||
.modal-backdrop.show {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-weight: bold;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
border-radius: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding-top: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
|
@ -69,3 +172,13 @@ a {
|
|||
.v-popper__inner {
|
||||
margin: 0 12px !important;
|
||||
}
|
||||
|
||||
.small,
|
||||
small {
|
||||
font-size: var(--bs-body-font-size);
|
||||
color: var(--bs-gray-medium) !important;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
opacity: 1;
|
||||
}
|
||||
|
|
|
|||
BIN
assets/font/Montserrat-Bold.woff2
Normal file
BIN
assets/font/Montserrat-Medium.woff2
Normal file
|
|
@ -1,11 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square70x70logo src="ico/mstile-70x70.png" />
|
||||
<square150x150logo src="ico/mstile-150x150.png" />
|
||||
<square310x310logo src="ico/mstile-310x310.png" />
|
||||
<TileColor>#18191a</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
||||
|
|
@ -3,22 +3,21 @@
|
|||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="description" content="EV Charge Controller" />
|
||||
<meta name="author" content="andig" />
|
||||
<meta name="description" content="evcc - Sonne tanken ☀️🚘" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="ico/apple-touch-icon.png?[[.Version]]" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="ico/favicon-32x32.png?[[.Version]]" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="ico/favicon-16x16.png?[[.Version]]" />
|
||||
<link rel="manifest" href="ico/site.webmanifest" />
|
||||
<link rel="mask-icon" href="ico/safari-pinned-tab.svg?[[.Version]]" color="#18191a" />
|
||||
<link rel="shortcut icon" href="ico/favicon.ico?[[.Version]]" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="evcc" />
|
||||
<meta name="application-name" content="evcc" />
|
||||
<meta name="msapplication-TileColor" content="#18191a" />
|
||||
<meta name="msapplication-config" content="ico/browserconfig.xml" />
|
||||
<meta name="theme-color" content="#18191a" />
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="meta/apple-touch-icon.png?[[.Version]]" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="meta/favicon-32x32.png?[[.Version]]" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="meta/favicon-16x16.png?[[.Version]]" />
|
||||
<link rel="manifest" href="meta/site.webmanifest?[[.Version]]" />
|
||||
<link rel="mask-icon" href="meta/safari-pinned-tab.svg?[[.Version]]" color="#28293e" />
|
||||
<link rel="shortcut icon" href="meta/favicon.ico?[[.Version]]" />
|
||||
<meta name="msapplication-TileColor" content="#28293e" />
|
||||
<meta name="msapplication-config" content="meta/browserconfig.xml?[[.Version]]" />
|
||||
<meta name="theme-color" content="#28293e" />
|
||||
|
||||
<title>evcc</title>
|
||||
</head>
|
||||
|
|
@ -32,5 +31,6 @@
|
|||
</script>
|
||||
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,22 +1,21 @@
|
|||
import "bootstrap/dist/css/bootstrap.min.css";
|
||||
import "bootstrap";
|
||||
import smoothscroll from "smoothscroll-polyfill";
|
||||
import "../css/app.css";
|
||||
import Vue from "vue";
|
||||
import VueMeta from "vue-meta";
|
||||
import { createApp, h } from "vue";
|
||||
import { createMetaManager, plugin as metaPlugin } from "vue-meta";
|
||||
import api from "./api";
|
||||
import App from "./views/App";
|
||||
import App from "./views/App.vue";
|
||||
import router from "./router";
|
||||
import i18n from "./i18n";
|
||||
import "./tooltip";
|
||||
import store from "./store";
|
||||
import featureflags from "./featureflags";
|
||||
|
||||
Vue.use(VueMeta);
|
||||
smoothscroll.polyfill();
|
||||
|
||||
window.app = new Vue({
|
||||
el: "#app",
|
||||
router,
|
||||
i18n,
|
||||
data: { store, notifications: [] },
|
||||
const app = createApp({
|
||||
data() {
|
||||
return { notifications: [] };
|
||||
},
|
||||
methods: {
|
||||
raise: function (msg) {
|
||||
console[msg.type](msg);
|
||||
|
|
@ -48,11 +47,18 @@ window.app = new Vue({
|
|||
this.raise(msg);
|
||||
},
|
||||
},
|
||||
render: function (h) {
|
||||
return h(App, { props: { notifications: this.notifications } });
|
||||
render: function () {
|
||||
return h(App, { notifications: this.notifications });
|
||||
},
|
||||
});
|
||||
|
||||
app.use(i18n);
|
||||
app.use(router);
|
||||
app.use(createMetaManager());
|
||||
app.use(metaPlugin);
|
||||
app.use(featureflags);
|
||||
window.app = app.mount("#app");
|
||||
|
||||
window.setInterval(function () {
|
||||
api.get("health").catch(function () {
|
||||
window.app.error({ message: "Server unavailable" });
|
||||
|
|
|
|||
|
|
@ -1,58 +1,27 @@
|
|||
<template>
|
||||
<div :class="{ 'power--in': charge, 'power--out': discharge }">
|
||||
<fa-icon class="battery" :icon="batteryIcon"></fa-icon
|
||||
><fa-icon class="arrow" icon="angle-double-right"></fa-icon>
|
||||
</div>
|
||||
<component :is="`shopicon-regular-${icon}`"></component>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import "../../icons";
|
||||
import "@h2d2/shopicons/es/regular/batteryfull";
|
||||
import "@h2d2/shopicons/es/regular/batterythreequarters";
|
||||
import "@h2d2/shopicons/es/regular/batteryhalf";
|
||||
import "@h2d2/shopicons/es/regular/batteryquarter";
|
||||
import "@h2d2/shopicons/es/regular/batteryempty";
|
||||
|
||||
export default {
|
||||
name: "BatteryIcon",
|
||||
props: {
|
||||
discharge: { type: Boolean },
|
||||
charge: { type: Boolean },
|
||||
soc: { type: Number, default: 0 },
|
||||
},
|
||||
computed: {
|
||||
batteryIcon: function () {
|
||||
if (this.soc > 80) return "battery-full";
|
||||
if (this.soc > 60) return "battery-three-quarters";
|
||||
if (this.soc > 40) return "battery-half";
|
||||
if (this.soc > 20) return "battery-quarter";
|
||||
return "battery-empty";
|
||||
icon: function () {
|
||||
if (this.soc > 80) return "batteryfull";
|
||||
if (this.soc > 60) return "batterythreequarters";
|
||||
if (this.soc > 40) return "batteryhalf";
|
||||
if (this.soc > 20) return "batteryquarter";
|
||||
return "batteryempty";
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
.battery {
|
||||
transform: translateX(0.35rem) rotate(-90deg);
|
||||
transition-property: transform;
|
||||
transition-duration: 250ms;
|
||||
transition-timing-function: ease;
|
||||
}
|
||||
.power--in .battery {
|
||||
transform: translateX(0.7rem) rotate(-90deg);
|
||||
}
|
||||
.power--out .battery {
|
||||
transform: translateX(0) rotate(-90deg);
|
||||
}
|
||||
.arrow {
|
||||
margin-left: -0.2rem;
|
||||
opacity: 0;
|
||||
transform: translateX(-0.5rem);
|
||||
transition-property: opacity, transform;
|
||||
transition-duration: 250ms;
|
||||
transition-timing-function: ease;
|
||||
}
|
||||
.power--in .arrow {
|
||||
opacity: 1;
|
||||
transform: translateX(-1rem) scaleX(1);
|
||||
}
|
||||
.power--out .arrow {
|
||||
opacity: 1;
|
||||
transform: translateX(0rem) scaleX(1);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,228 +0,0 @@
|
|||
/* globals describe, it, expect */
|
||||
import { shallowMount } from "@vue/test-utils";
|
||||
import Energyflow from "./Energyflow.vue";
|
||||
|
||||
const mocks = {
|
||||
$t: (x) => x,
|
||||
$tc: (x, n) => `${n} ${x}`,
|
||||
$n: function (value, options) {
|
||||
const n = new Intl.NumberFormat("en-EN", options);
|
||||
return n.format(value);
|
||||
},
|
||||
};
|
||||
|
||||
describe("Energyflow.vue", () => {
|
||||
const defaultProps = {
|
||||
gridConfigured: true,
|
||||
gridPower: 0,
|
||||
pvConfigured: true,
|
||||
pvPower: 0,
|
||||
homePower: 0,
|
||||
batteryConfigured: false,
|
||||
batteryPower: 0,
|
||||
batterySoC: 0,
|
||||
};
|
||||
|
||||
it("using pv and grid power", async () => {
|
||||
const wrapper = shallowMount(Energyflow, {
|
||||
mocks,
|
||||
propsData: {
|
||||
...defaultProps,
|
||||
gridPower: 1000,
|
||||
pvPower: 4000,
|
||||
homePower: 1300,
|
||||
loadpointsPower: 3700,
|
||||
},
|
||||
});
|
||||
await wrapper.find(".energyflow").trigger("click");
|
||||
|
||||
expect(wrapper.find("[data-test-grid-import]").text()).toMatch("1.0 kW");
|
||||
expect(wrapper.find("[data-test-self-consumption]").text()).toMatch("4.0 kW");
|
||||
expect(wrapper.find("[data-test-pv-export]").text()).toMatch("0.0 kW");
|
||||
|
||||
expect(wrapper.find("[data-test-home-power]").text()).toMatch("1.3 kW");
|
||||
expect(wrapper.find("[data-test-loadpoints]").text()).toMatch("3.7 kW");
|
||||
expect(wrapper.find("[data-test-pv-production]").text()).toMatch("4.0 kW");
|
||||
expect(wrapper.find("[data-test-battery]").exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("exporting all pv power, no usage", async () => {
|
||||
const wrapper = shallowMount(Energyflow, {
|
||||
mocks,
|
||||
propsData: { ...defaultProps, gridPower: -4000, pvPower: 5000, loadpointsPower: 1000 },
|
||||
});
|
||||
|
||||
await wrapper.find(".energyflow").trigger("click");
|
||||
|
||||
expect(wrapper.find("[data-test-grid-import]").text()).toMatch("0.0 kW");
|
||||
expect(wrapper.find("[data-test-self-consumption]").text()).toMatch("1.0 kW");
|
||||
expect(wrapper.find("[data-test-pv-export]").text()).toMatch("4.0 kW");
|
||||
|
||||
expect(wrapper.find("[data-test-home-power]").text()).toMatch("0.0 kW");
|
||||
expect(wrapper.find("[data-test-loadpoints]").text()).toMatch("1.0 kW");
|
||||
expect(wrapper.find("[data-test-pv-production]").text()).toMatch("5.0 kW");
|
||||
expect(wrapper.find("[data-test-battery]").exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("more grid export than pv, grid value wins (invalid state)", async () => {
|
||||
const wrapper = shallowMount(Energyflow, {
|
||||
mocks,
|
||||
propsData: { ...defaultProps, gridPower: -4000, pvPower: 3000 },
|
||||
});
|
||||
|
||||
await wrapper.find(".energyflow").trigger("click");
|
||||
|
||||
expect(wrapper.find("[data-test-grid-import]").text()).toMatch("0.0 kW");
|
||||
expect(wrapper.find("[data-test-self-consumption]").text()).toMatch("0.0 kW");
|
||||
expect(wrapper.find("[data-test-pv-export]").text()).toMatch("4.0 kW");
|
||||
|
||||
expect(wrapper.find("[data-test-home-power]").text()).toMatch("0.0 kW");
|
||||
expect(wrapper.find("[data-test-loadpoints]").text()).toMatch("0.0 kW");
|
||||
expect(wrapper.find("[data-test-pv-production]").text()).toMatch("3.0 kW");
|
||||
expect(wrapper.find("[data-test-battery]").exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("more loadpoints power than grid import, loadpoints unchanged, house consumption min 0 (invalid state)", async () => {
|
||||
const wrapper = shallowMount(Energyflow, {
|
||||
mocks,
|
||||
propsData: { ...defaultProps, gridPower: 6000, pvPower: 0, loadpointsPower: 7000 },
|
||||
});
|
||||
|
||||
await wrapper.find(".energyflow").trigger("click");
|
||||
|
||||
expect(wrapper.find("[data-test-grid-import]").text()).toMatch("6.0 kW");
|
||||
expect(wrapper.find("[data-test-self-consumption]").text()).toMatch("0.0 kW");
|
||||
expect(wrapper.find("[data-test-pv-export]").text()).toMatch("0.0 kW");
|
||||
|
||||
expect(wrapper.find("[data-test-home-power]").text()).toMatch("0.0 kW");
|
||||
expect(wrapper.find("[data-test-loadpoints]").text()).toMatch("7.0 kW");
|
||||
expect(wrapper.find("[data-test-pv-production]").text()).toMatch("0.0 kW");
|
||||
expect(wrapper.find("[data-test-battery]").exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("only grid usage, no pv, idleBattery", async () => {
|
||||
const wrapper = shallowMount(Energyflow, {
|
||||
mocks,
|
||||
propsData: {
|
||||
...defaultProps,
|
||||
gridPower: 360,
|
||||
pvPower: 0,
|
||||
batteryConfigured: true,
|
||||
batteryPower: 0,
|
||||
homePower: 360,
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.find(".energyflow").trigger("click");
|
||||
|
||||
expect(wrapper.find("[data-test-grid-import]").text()).toMatch("360 W");
|
||||
expect(wrapper.find("[data-test-self-consumption]").text()).toMatch("0 W");
|
||||
expect(wrapper.find("[data-test-pv-export]").text()).toMatch("0 W");
|
||||
|
||||
expect(wrapper.find("[data-test-home-power]").text()).toMatch("360 W");
|
||||
expect(wrapper.find("[data-test-pv-production]").text()).toMatch("0 W");
|
||||
expect(wrapper.find("[data-test-battery]").text()).toMatch("main.energyflow.battery");
|
||||
});
|
||||
|
||||
it("grid and battery usage, no pv", async () => {
|
||||
const wrapper = shallowMount(Energyflow, {
|
||||
mocks,
|
||||
propsData: {
|
||||
...defaultProps,
|
||||
gridPower: 300,
|
||||
batteryConfigured: true,
|
||||
batteryPower: 234,
|
||||
batterySoC: 77,
|
||||
pvPower: 0,
|
||||
homePower: 534,
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.find(".energyflow").trigger("click");
|
||||
|
||||
expect(wrapper.find("[data-test-grid-import]").text()).toMatch("300 W");
|
||||
expect(wrapper.find("[data-test-self-consumption]").text()).toMatch("234 W");
|
||||
expect(wrapper.find("[data-test-pv-export]").text()).toMatch("0 W");
|
||||
|
||||
expect(wrapper.find("[data-test-home-power]").text()).toMatch("534 W");
|
||||
expect(wrapper.find("[data-test-pv-production]").text()).toMatch("0 W");
|
||||
expect(wrapper.find("[data-test-battery]").text()).toMatch("234 W");
|
||||
expect(wrapper.find("[data-test-battery]").text()).toMatch("77%");
|
||||
expect(wrapper.find("[data-test-battery]").text()).toMatch("main.energyflow.batteryDischarge");
|
||||
});
|
||||
|
||||
it("battery charge, pv export", async () => {
|
||||
const wrapper = shallowMount(Energyflow, {
|
||||
mocks,
|
||||
propsData: {
|
||||
...defaultProps,
|
||||
gridPower: -2500,
|
||||
batteryConfigured: true,
|
||||
batteryPower: -1700,
|
||||
pvPower: 9000,
|
||||
homePower: 4800,
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.find(".energyflow").trigger("click");
|
||||
|
||||
expect(wrapper.find("[data-test-grid-import]").text()).toMatch("0.0 kW");
|
||||
expect(wrapper.find("[data-test-self-consumption]").text()).toMatch("6.5 kW");
|
||||
expect(wrapper.find("[data-test-pv-export]").text()).toMatch("2.5 kW");
|
||||
|
||||
expect(wrapper.find("[data-test-home-power]").text()).toMatch("4.8 kW");
|
||||
expect(wrapper.find("[data-test-pv-production]").text()).toMatch("9.0 kW");
|
||||
expect(wrapper.find("[data-test-battery]").text()).toMatch("1.7 kW");
|
||||
expect(wrapper.find("[data-test-battery]").text()).toMatch("main.energyflow.batteryCharge");
|
||||
});
|
||||
|
||||
it("thresholds", async () => {
|
||||
const wrapper = shallowMount(Energyflow, {
|
||||
mocks,
|
||||
propsData: {
|
||||
...defaultProps,
|
||||
gridPower: 5555,
|
||||
batteryConfigured: true,
|
||||
batteryPower: 1234,
|
||||
pvPower: 378,
|
||||
homePower: 7200,
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.find(".energyflow").trigger("click");
|
||||
|
||||
expect(wrapper.find("[data-test-grid-import]").text()).toMatch("5.6 kW");
|
||||
expect(wrapper.find("[data-test-self-consumption]").text()).toMatch("1.6 kW");
|
||||
expect(wrapper.find("[data-test-pv-export]").text()).toMatch("0.0 kW");
|
||||
|
||||
expect(wrapper.find("[data-test-home-power]").text()).toMatch("7.2 kW");
|
||||
expect(wrapper.find("[data-test-pv-production]").text()).toMatch("0.4 kW");
|
||||
expect(wrapper.find("[data-test-battery]").text()).toMatch("1.2 kW");
|
||||
expect(wrapper.find("[data-test-battery]").text()).toMatch("main.energyflow.batteryDischarge");
|
||||
});
|
||||
|
||||
it("battery charge, grid import", async () => {
|
||||
const wrapper = shallowMount(Energyflow, {
|
||||
mocks,
|
||||
propsData: {
|
||||
...defaultProps,
|
||||
gridPower: 1500,
|
||||
batteryConfigured: true,
|
||||
batteryPower: -1000,
|
||||
homePower: 500,
|
||||
pvPower: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.find(".energyflow").trigger("click");
|
||||
|
||||
expect(wrapper.find("[data-test-grid-import]").text()).toMatch("1.5 kW");
|
||||
expect(wrapper.find("[data-test-self-consumption]").text()).toMatch("0.0 kW");
|
||||
expect(wrapper.find("[data-test-pv-export]").text()).toMatch("0.0 kW");
|
||||
|
||||
expect(wrapper.find("[data-test-home-power]").text()).toMatch("0.5 kW");
|
||||
expect(wrapper.find("[data-test-pv-production]").text()).toMatch("0.0 kW");
|
||||
expect(wrapper.find("[data-test-battery]").text()).toMatch("1.0 kW");
|
||||
expect(wrapper.find("[data-test-battery]").text()).toMatch("main.energyflow.batteryCharge");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import Energyflow from "./";
|
||||
import i18n from "../../i18n";
|
||||
|
||||
export default {
|
||||
title: "Main/Energyflow",
|
||||
|
|
@ -13,13 +12,19 @@ export default {
|
|||
batteryPower: { control: { type: "range", min: -4000, max: 4000, step: 100 } },
|
||||
batterySoC: { control: { type: "range", min: 0, max: 100, step: 1 } },
|
||||
},
|
||||
parameters: {
|
||||
backgrounds: {
|
||||
default: "light",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const Template = (args, { argTypes }) => ({
|
||||
i18n,
|
||||
props: Object.keys(argTypes),
|
||||
const Template = (args) => ({
|
||||
setup() {
|
||||
return { args };
|
||||
},
|
||||
components: { Energyflow },
|
||||
template: '<Energyflow v-bind="$props"></Energyflow>',
|
||||
template: '<Energyflow v-bind="args"></Energyflow>',
|
||||
});
|
||||
|
||||
export const GridAndPV = Template.bind({});
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="row align-items-start align-items-md-center mt-4 energyflow"
|
||||
class="energyflow cursor-pointer position-relative"
|
||||
:class="{ 'energyflow--open': detailsOpen }"
|
||||
@click="toggleDetails"
|
||||
>
|
||||
<div class="row">
|
||||
<Visualization
|
||||
class="col-12 offset-md-1 col-md-6 offset-lg-1 col-lg-8 offset-xl-1 col-xl-6 offset-xxl-1 col-xl-8 order-md-2"
|
||||
:showDetails="showDetails"
|
||||
class="col-12 mb-3 mb-md-4"
|
||||
:gridImport="gridImport"
|
||||
:selfConsumption="selfConsumption"
|
||||
:loadpoints="loadpointsPower"
|
||||
|
|
@ -18,103 +18,117 @@
|
|||
:batterySoC="batterySoC"
|
||||
:valuesInKw="valuesInKw"
|
||||
/>
|
||||
</div>
|
||||
<div class="indicator position-absolute bottom-0 start-50">
|
||||
<shopicon-regular-arrowdown></shopicon-regular-arrowdown>
|
||||
</div>
|
||||
<div class="details" :style="{ height: detailsHeight }">
|
||||
<div ref="detailsInner" class="details-inner row">
|
||||
<div class="col-12 d-flex justify-content-between pt-2 mb-4">
|
||||
<div class="d-flex flex-nowrap align-items-center">
|
||||
<span class="color-self me-2"
|
||||
><shopicon-filled-square></shopicon-filled-square
|
||||
></span>
|
||||
<span>{{ $t("main.energyflow.selfConsumption") }}</span>
|
||||
</div>
|
||||
<div v-if="gridImport > 0" class="d-flex flex-nowrap align-items-center">
|
||||
<span>{{ $t("main.energyflow.gridImport") }}</span>
|
||||
<span class="color-grid ms-2"
|
||||
><shopicon-filled-square></shopicon-filled-square
|
||||
></span>
|
||||
</div>
|
||||
<div v-else class="d-flex flex-nowrap align-items-center">
|
||||
<span>{{ $t("main.energyflow.pvExport") }}</span>
|
||||
<span class="color-export ms-2"
|
||||
><shopicon-filled-square></shopicon-filled-square
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="col-12 col-sm-6 col-md-5 col-lg-3 col-xl-3 order-md-1 mt-2 mt-md-0"
|
||||
:class="`${showDetails ? 'd-block' : `d-none d-md-block`}`"
|
||||
class="col-12 col-md-6 pe-md-5 pb-4 d-flex flex-column justify-content-between"
|
||||
>
|
||||
<div class="d-flex justify-content-between" data-test-pv-production>
|
||||
<span class="details-icon text-muted"><fa-icon icon="sun"></fa-icon></span>
|
||||
<span class="text-nowrap flex-grow-1">{{
|
||||
$t("main.energyflow.pvProduction")
|
||||
}}</span>
|
||||
<span class="text-end text-nowrap ps-1">{{ kw(pvProduction) }}</span>
|
||||
<div class="d-flex justify-content-between align-items-end mb-4">
|
||||
<h3 class="m-0">In</h3>
|
||||
<span class="fw-bold text-gray-medium">{{ kw(inPower) }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between" data-test-home-power>
|
||||
<span class="details-icon text-muted"><fa-icon icon="home"></fa-icon></span>
|
||||
<span class="text-nowrap flex-grow-1">{{
|
||||
$t("main.energyflow.homePower")
|
||||
}}</span>
|
||||
<span class="text-end text-nowrap ps-1">{{ kw(homePower) }}</span>
|
||||
<div>
|
||||
<EnergyflowEntry
|
||||
:name="$t('main.energyflow.pvProduction')"
|
||||
icon="sun"
|
||||
:power="pvProduction"
|
||||
:valuesInKw="valuesInKw"
|
||||
/>
|
||||
<EnergyflowEntry
|
||||
v-if="batteryConfigured"
|
||||
:name="$t('main.energyflow.batteryDischarge')"
|
||||
icon="battery"
|
||||
:soc="batterySoC"
|
||||
:power="batteryDischarge"
|
||||
:valuesInKw="valuesInKw"
|
||||
/>
|
||||
<EnergyflowEntry
|
||||
:name="$t('main.energyflow.gridImport')"
|
||||
icon="powersupply"
|
||||
:power="gridImport"
|
||||
:valuesInKw="valuesInKw"
|
||||
/>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between" data-test-loadpoints>
|
||||
<span class="details-icon text-muted"><fa-icon icon="car"></fa-icon></span>
|
||||
<span class="text-nowrap flex-grow-1">{{
|
||||
$tc("main.energyflow.loadpoints", activeLoadpointsCount, {
|
||||
</div>
|
||||
<div
|
||||
class="col-12 col-md-6 ps-md-5 pb-4 d-flex flex-column justify-content-between"
|
||||
>
|
||||
<div class="d-flex justify-content-between align-items-end mb-4">
|
||||
<h3 class="m-0">Out</h3>
|
||||
<span class="fw-bold text-gray-medium">{{ kw(outPower) }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<EnergyflowEntry
|
||||
:name="$t('main.energyflow.homePower')"
|
||||
icon="home"
|
||||
:power="homePower"
|
||||
:valuesInKw="valuesInKw"
|
||||
/>
|
||||
<EnergyflowEntry
|
||||
:name="
|
||||
$tc('main.energyflow.loadpoints', activeLoadpointsCount, {
|
||||
count: activeLoadpointsCount,
|
||||
})
|
||||
}}</span>
|
||||
<span class="text-end text-nowrap ps-1">{{ kw(loadpointsPower) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="batteryConfigured"
|
||||
class="d-flex justify-content-between"
|
||||
data-test-battery
|
||||
>
|
||||
<span class="details-icon text-muted">
|
||||
<BatteryIcon
|
||||
:soc="batterySoC"
|
||||
:charge="batteryCharge > 0"
|
||||
:discharge="batteryDischarge > 0"
|
||||
"
|
||||
icon="car3"
|
||||
:power="loadpointsPower"
|
||||
:valuesInKw="valuesInKw"
|
||||
/>
|
||||
<EnergyflowEntry
|
||||
v-if="batteryConfigured"
|
||||
:name="$t('main.energyflow.batteryCharge')"
|
||||
icon="battery"
|
||||
:soc="batterySoC"
|
||||
:power="batteryCharge"
|
||||
:valuesInKw="valuesInKw"
|
||||
/>
|
||||
<EnergyflowEntry
|
||||
:name="$t('main.energyflow.pvExport')"
|
||||
icon="powersupply"
|
||||
:power="pvExport"
|
||||
:valuesInKw="valuesInKw"
|
||||
/>
|
||||
</span>
|
||||
<span class="text-nowrap flex-grow-1 text-truncate">
|
||||
<span v-if="batteryCharge">{{ $t("main.energyflow.batteryCharge") }}</span>
|
||||
<span v-else-if="batteryDischarge">{{
|
||||
$t("main.energyflow.batteryDischarge")
|
||||
}}</span>
|
||||
<span v-else>{{ $t("main.energyflow.battery") }}</span>
|
||||
</span>
|
||||
<span class="text-end text-nowrap ps-1">
|
||||
{{ batterySoC }}% /
|
||||
{{ kw(Math.abs(batteryPower)) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="showDetails"
|
||||
class="col-12 col-sm-6 offset-md-6 col-md-6 offset-lg-4 col-lg-8 d-block d-md-flex order-md-3 justify-content-between mt-2"
|
||||
>
|
||||
<div class="text-nowrap d-flex d-md-block" data-test-grid-import>
|
||||
<span class="color-grid details-icon"><fa-icon icon="square"></fa-icon></span>
|
||||
<span class="text-nowrap flex-grow-1">{{
|
||||
$t("main.energyflow.gridImport")
|
||||
}}</span>
|
||||
<span class="text-end text-nowrap d-md-none">
|
||||
{{ kw(gridImport) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-nowrap d-flex d-md-block" data-test-self-consumption>
|
||||
<span class="color-self details-icon"><fa-icon icon="square"></fa-icon></span>
|
||||
<span class="text-nowrap flex-grow-1">{{
|
||||
$t("main.energyflow.selfConsumption")
|
||||
}}</span>
|
||||
<span class="text-end text-nowrap d-md-none">
|
||||
{{ kw(selfConsumption) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-nowrap d-flex d-md-block" data-test-pv-export>
|
||||
<span class="color-export details-icon"><fa-icon icon="square"></fa-icon></span>
|
||||
<span class="text-nowrap flex-grow-1">{{
|
||||
$t("main.energyflow.pvExport")
|
||||
}}</span>
|
||||
<span class="text-end text-nowrap d-md-none">
|
||||
{{ kw(pvExport) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import "../../icons";
|
||||
import formatter from "../../mixins/formatter";
|
||||
import "@h2d2/shopicons/es/filled/square";
|
||||
import "@h2d2/shopicons/es/regular/arrowdown";
|
||||
import Visualization from "./Visualization.vue";
|
||||
import BatteryIcon from "./BatteryIcon.vue";
|
||||
import EnergyflowEntry from "./EnergyflowEntry.vue";
|
||||
import formatter from "../../mixins/formatter";
|
||||
|
||||
export default {
|
||||
name: "Energyflow",
|
||||
components: { Visualization, BatteryIcon },
|
||||
components: { Visualization, EnergyflowEntry },
|
||||
mixins: [formatter],
|
||||
props: {
|
||||
gridConfigured: Boolean,
|
||||
|
|
@ -128,8 +142,8 @@ export default {
|
|||
batteryPower: { type: Number, default: 0 },
|
||||
batterySoC: { type: Number, default: 0 },
|
||||
},
|
||||
data: function () {
|
||||
return { showDetails: false };
|
||||
data: () => {
|
||||
return { detailsOpen: false, detailsCompleteHeight: null };
|
||||
},
|
||||
computed: {
|
||||
gridImport: function () {
|
||||
|
|
@ -143,10 +157,10 @@ export default {
|
|||
return Math.abs(this.batteryPower) < batteryPowerThreshold ? 0 : this.batteryPower;
|
||||
},
|
||||
batteryDischarge: function () {
|
||||
return Math.max(0, this.batteryPowerAdjusted);
|
||||
return Math.abs(Math.max(0, this.batteryPowerAdjusted));
|
||||
},
|
||||
batteryCharge: function () {
|
||||
return Math.min(0, this.batteryPowerAdjusted) * -1;
|
||||
return Math.abs(Math.min(0, this.batteryPowerAdjusted) * -1);
|
||||
},
|
||||
selfConsumption: function () {
|
||||
const ownPower = this.batteryDischarge + this.pvProduction;
|
||||
|
|
@ -159,20 +173,60 @@ export default {
|
|||
valuesInKw: function () {
|
||||
return this.gridImport + this.selfConsumption + this.pvExport > 1000;
|
||||
},
|
||||
inPower: function () {
|
||||
return this.gridImport + this.pvProduction + this.batteryDischarge;
|
||||
},
|
||||
outPower: function () {
|
||||
return this.homePower + this.loadpointsPower + this.pvExport + this.batteryCharge;
|
||||
},
|
||||
detailsHeight: function () {
|
||||
return this.detailsOpen ? this.detailsCompleteHeight + "px" : 0;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener("resize", this.updateHeight);
|
||||
},
|
||||
unmounted() {
|
||||
window.removeEventListener("resize", this.updateHeight);
|
||||
},
|
||||
methods: {
|
||||
kw: function (watt) {
|
||||
return this.fmtKw(watt, this.valuesInKw);
|
||||
},
|
||||
toggleDetails() {
|
||||
this.showDetails = !this.showDetails;
|
||||
toggleDetails: function () {
|
||||
this.updateHeight();
|
||||
this.detailsOpen = !this.detailsOpen;
|
||||
},
|
||||
updateHeight: function () {
|
||||
this.detailsCompleteHeight = this.$refs.detailsInner.offsetHeight;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
.energyflow {
|
||||
cursor: pointer;
|
||||
background: var(--bs-white);
|
||||
}
|
||||
.indicator {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) scaleY(1);
|
||||
transition: opacity, transform;
|
||||
transition-duration: var(--evcc-transition-slow);
|
||||
}
|
||||
.energyflow--open .indicator {
|
||||
transform: translateX(-50%) scaleY(-1);
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.energyflow:hover .indicator {
|
||||
opacity: 0.25;
|
||||
}
|
||||
}
|
||||
.details {
|
||||
height: 0;
|
||||
overflow: visible;
|
||||
transition: height;
|
||||
transition-duration: var(--evcc-transition-medium);
|
||||
transition-timing-function: cubic-bezier(0.5, 0.5, 0.5, 1.15);
|
||||
}
|
||||
.color-grid {
|
||||
color: var(--evcc-grid);
|
||||
|
|
@ -183,11 +237,4 @@ export default {
|
|||
.color-export {
|
||||
color: var(--evcc-export);
|
||||
}
|
||||
.details-icon {
|
||||
text-align: center;
|
||||
width: 30px;
|
||||
margin-right: 0.25rem;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
55
assets/js/components/Energyflow/EnergyflowEntry.vue
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<template>
|
||||
<div class="d-flex justify-content-between mb-2 entry" :class="{ 'text-gray-medium': !active }">
|
||||
<span class="d-flex flex-nowrap">
|
||||
<BatteryIcon v-if="isBattery" :soc="soc" />
|
||||
<component :is="`shopicon-regular-${icon}`" v-else></component>
|
||||
</span>
|
||||
<span class="text-nowrap flex-grow-1 ms-3">{{ name }}</span>
|
||||
<span class="text-end text-nowrap ps-1 fw-bold"
|
||||
><span v-if="hasSoC">{{ soc }}% / </span>{{ kw(power) }}</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import "@h2d2/shopicons/es/regular/powersupply";
|
||||
import "@h2d2/shopicons/es/regular/sun";
|
||||
import "@h2d2/shopicons/es/regular/home";
|
||||
import "@h2d2/shopicons/es/regular/car3";
|
||||
import BatteryIcon from "./BatteryIcon.vue";
|
||||
import formatter from "../../mixins/formatter";
|
||||
|
||||
export default {
|
||||
name: "EnergyflowEntry",
|
||||
components: { BatteryIcon },
|
||||
mixins: [formatter],
|
||||
props: {
|
||||
name: { type: String },
|
||||
icon: { type: String },
|
||||
power: { type: Number },
|
||||
soc: { type: Number },
|
||||
valuesInKw: { type: Boolean },
|
||||
},
|
||||
computed: {
|
||||
active: function () {
|
||||
return this.power > 10;
|
||||
},
|
||||
isBattery: function () {
|
||||
return this.icon === "battery";
|
||||
},
|
||||
hasSoC: function () {
|
||||
return this.isBattery && !isNaN(this.soc);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
kw: function (watt) {
|
||||
return this.fmtKw(watt, this.valuesInKw);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
.entry {
|
||||
transition: color var(--evcc-transition-medium) linear;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
<template>
|
||||
<div :class="{ 'power-import': this.import, 'power-export': this.export }">
|
||||
<svg
|
||||
viewBox="0 0 512 512"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xml:space="preserve"
|
||||
class="grid svg-inline--fa fa-home fa-w-18"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M217.529 46.629H83.817c-8.83 0-16 7.169-16 16v32c0 .196.004.391.011.586v51.22c0 6.209 5.041 11.25 11.25 11.25h22.5c6.209 0 11.25-5.041 11.25-11.25v-35.806h104.701v84.697H83.577c-8.83 0-16 7.169-16 16v32c0 .196.004.391.011.586v51.22c0 6.209 5.041 11.25 11.25 11.25h22.5c6.209 0 11.25-5.041 11.25-11.25v-35.806h104.941v49.925L176.47 506.932l160 .033-39.179-197.538v-50.101h100.402v35.756c0 6.257 5.08 11.337 11.337 11.337h22.674c6.257 0 11.337-5.08 11.337-11.337v-83.756c0-8.831-7.169-16-16-16h-129.75v-84.697h100.642v35.756c0 6.257 5.08 11.337 11.337 11.337h22.674c6.257 0 11.337-5.08 11.337-11.337V62.629c0-8.831-7.169-16-16-16h-129.99V26.162c-.043-12.777-40.058-18.846-40.159-18.633 0 0-39.66 6.092-39.603 18.633v20.467Z"
|
||||
/></svg
|
||||
><fa-icon class="arrow" icon="angle-double-right"></fa-icon>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "GridIcon",
|
||||
props: {
|
||||
import: { type: Boolean },
|
||||
export: { type: Boolean },
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.grid {
|
||||
transform: translateX(0.35rem);
|
||||
transition-property: transform;
|
||||
transition-duration: 250ms;
|
||||
transition-timing-function: ease;
|
||||
}
|
||||
.power-export .grid {
|
||||
transform: translateX(0.9rem);
|
||||
}
|
||||
.power-import .grid {
|
||||
transform: translateX(-0.1rem);
|
||||
}
|
||||
.arrow {
|
||||
margin-left: -0.2rem;
|
||||
opacity: 0;
|
||||
transform: translateX(-0.5rem);
|
||||
transition-property: opacity, transform;
|
||||
transition-duration: 250ms;
|
||||
transition-timing-function: ease;
|
||||
}
|
||||
.power-export .arrow {
|
||||
opacity: 1;
|
||||
transform: translateX(-0.9rem) scaleX(1);
|
||||
}
|
||||
.power-import .arrow {
|
||||
opacity: 1;
|
||||
transform: translateX(0.1em) scaleX(1);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -35,8 +35,7 @@ export default {
|
|||
.label-bar {
|
||||
width: 0;
|
||||
margin: 0;
|
||||
height: 1.7rem;
|
||||
padding: 0.6rem 0;
|
||||
padding: 10px 0;
|
||||
opacity: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
|
@ -44,51 +43,50 @@ export default {
|
|||
opacity: 0;
|
||||
}
|
||||
.label-bar-scale {
|
||||
border: 1px solid var(--bs-gray);
|
||||
height: 0.5rem;
|
||||
border: 1px solid var(--bs-gray-medium);
|
||||
height: 14px;
|
||||
background: none;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
border-radius: 0;
|
||||
transition: border-radius 500ms linear;
|
||||
transition: border-radius var(--evcc-transition-medium) linear;
|
||||
}
|
||||
.label-bar--top .label-bar-scale {
|
||||
border-top-left-radius: 10px;
|
||||
border-top-right-radius: 10px;
|
||||
border-bottom: none;
|
||||
}
|
||||
.label-bar--bottom .label-bar-scale {
|
||||
border-bottom-left-radius: 10px;
|
||||
border-bottom-right-radius: 10px;
|
||||
border-top: none;
|
||||
}
|
||||
.label-bar--top.label-bar--first .label-bar-scale {
|
||||
border-top-left-radius: 4px;
|
||||
}
|
||||
.label-bar--top.label-bar--last .label-bar-scale {
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
.label-bar--bottom.label-bar--first .label-bar-scale {
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
.label-bar--bottom.label-bar--last .label-bar-scale {
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
.label-bar:not(.label-bar--first) .label-bar-scale {
|
||||
border-left: none;
|
||||
}
|
||||
.label-bar-icon {
|
||||
background-color: white;
|
||||
color: var(--bs-gray);
|
||||
padding: 0 0.3rem;
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
color: var(--bs-gray-dark);
|
||||
border-radius: 0;
|
||||
border: 0.25rem solid white;
|
||||
transition-property: background-color, transform, border-radius, border;
|
||||
/* will be overwritten by parent component to avoid initial transition */
|
||||
transition-duration: 0s;
|
||||
transition-delay: 0s;
|
||||
transition-timing-function: linear;
|
||||
}
|
||||
.label-bar--top .label-bar-icon {
|
||||
margin-top: -6px;
|
||||
margin-top: -12px;
|
||||
}
|
||||
.label-bar--bottom .label-bar-icon {
|
||||
margin-top: 6px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.label-bar--hide-icon .label-bar-icon {
|
||||
opacity: 0;
|
||||
background-color: var(--bs-gray-dark);
|
||||
transform: scale(0.1666666);
|
||||
border-radius: 100%;
|
||||
border-width: 1.5rem;
|
||||
transition-delay: 400ms, 0s;
|
||||
}
|
||||
.label-bar--hidden {
|
||||
opacity: 0;
|
||||
|
|
|
|||
|
|
@ -1,17 +1,18 @@
|
|||
<template>
|
||||
<div class="visualization" :class="{ 'visualization--ready': visualizationReady }">
|
||||
<div class="label-scale">
|
||||
<div class="d-flex justify-content-start">
|
||||
<div class="label-scale d-flex">
|
||||
<div class="d-flex justify-content-start flex-grow-1">
|
||||
<LabelBar v-bind="labelBarProps('top', 'pvProduction')">
|
||||
<fa-icon icon="sun"></fa-icon>
|
||||
<shopicon-regular-sun></shopicon-regular-sun>
|
||||
</LabelBar>
|
||||
<LabelBar v-bind="labelBarProps('top', 'batteryDischarge')">
|
||||
<BatteryIcon :soc="batterySoC" discharge />
|
||||
<BatteryIcon :soc="batterySoC" />
|
||||
</LabelBar>
|
||||
<LabelBar v-bind="labelBarProps('top', 'gridImport')">
|
||||
<GridIcon import />
|
||||
<shopicon-regular-powersupply></shopicon-regular-powersupply>
|
||||
</LabelBar>
|
||||
</div>
|
||||
<div class="label-scale-name">In</div>
|
||||
</div>
|
||||
<div ref="site_progress" class="site-progress">
|
||||
<div
|
||||
|
|
@ -51,38 +52,39 @@
|
|||
<span>{{ $t("main.energyflow.noEnergy") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="label-scale">
|
||||
<div class="d-flex justify-content-start">
|
||||
<div class="label-scale d-flex">
|
||||
<div class="d-flex justify-content-start flex-grow-1">
|
||||
<LabelBar v-bind="labelBarProps('bottom', 'homePower')">
|
||||
<fa-icon icon="home"></fa-icon>
|
||||
<shopicon-regular-home></shopicon-regular-home>
|
||||
</LabelBar>
|
||||
<LabelBar v-bind="labelBarProps('bottom', 'loadpoints')">
|
||||
<fa-icon icon="car"></fa-icon>
|
||||
<shopicon-regular-car3></shopicon-regular-car3>
|
||||
</LabelBar>
|
||||
<LabelBar v-bind="labelBarProps('bottom', 'batteryCharge')">
|
||||
<BatteryIcon :soc="batterySoC" charge />
|
||||
<BatteryIcon :soc="batterySoC" />
|
||||
</LabelBar>
|
||||
<LabelBar v-bind="labelBarProps('bottom', 'gridExport')">
|
||||
<GridIcon export />
|
||||
<shopicon-regular-powersupply></shopicon-regular-powersupply>
|
||||
</LabelBar>
|
||||
</div>
|
||||
<div class="label-scale-name">Out</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import "../../icons";
|
||||
import formatter from "../../mixins/formatter";
|
||||
import BatteryIcon from "./BatteryIcon.vue";
|
||||
import GridIcon from "./GridIcon.vue";
|
||||
import LabelBar from "./LabelBar.vue";
|
||||
import "@h2d2/shopicons/es/regular/car3";
|
||||
import "@h2d2/shopicons/es/regular/sun";
|
||||
import "@h2d2/shopicons/es/regular/home";
|
||||
|
||||
export default {
|
||||
name: "Visualization",
|
||||
components: { BatteryIcon, LabelBar, GridIcon },
|
||||
components: { BatteryIcon, LabelBar },
|
||||
mixins: [formatter],
|
||||
props: {
|
||||
showDetails: Boolean,
|
||||
gridImport: { type: Number, default: 0 },
|
||||
selfConsumption: { type: Number, default: 0 },
|
||||
pvExport: { type: Number, default: 0 },
|
||||
|
|
@ -99,7 +101,7 @@ export default {
|
|||
},
|
||||
computed: {
|
||||
gridExport: function () {
|
||||
return this.pvExport;
|
||||
return this.applyThreshold(this.pvExport);
|
||||
},
|
||||
totalRaw: function () {
|
||||
return this.gridImport + this.selfConsumption + this.pvExport;
|
||||
|
|
@ -118,9 +120,6 @@ export default {
|
|||
},
|
||||
},
|
||||
watch: {
|
||||
showDetails: function () {
|
||||
this.$nextTick(() => this.updateElementWidth());
|
||||
},
|
||||
totalAdjusted: function () {
|
||||
if (!this.visualizationReady && this.totalAdjusted > 0)
|
||||
setTimeout(() => {
|
||||
|
|
@ -134,7 +133,7 @@ export default {
|
|||
this.updateElementWidth();
|
||||
});
|
||||
},
|
||||
beforeDestroy() {
|
||||
beforeUnmount() {
|
||||
window.removeEventListener("resize", this.updateElementWidth);
|
||||
},
|
||||
methods: {
|
||||
|
|
@ -171,38 +170,13 @@ export default {
|
|||
updateElementWidth() {
|
||||
this.width = this.$refs.site_progress.getBoundingClientRect().width;
|
||||
},
|
||||
isLabelFirst(position, name) {
|
||||
return this.isLabel(position, name, false);
|
||||
},
|
||||
isLabelLast(position, name) {
|
||||
return this.isLabel(position, name, true);
|
||||
},
|
||||
isLabel(position, name, last) {
|
||||
const labels = {
|
||||
top: ["pvProduction", "batteryDischarge", "gridImport"],
|
||||
bottom: ["homePower", "loadpoints", "batteryCharge", "gridExport"],
|
||||
};
|
||||
const entries = [...labels[position]];
|
||||
if (last) {
|
||||
entries.reverse();
|
||||
}
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
if (this[entry] > 0) {
|
||||
return entry === name;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
labelBarProps(position, name) {
|
||||
const value = this[name];
|
||||
const minWidth = name.startsWith("battery") || name.startsWith("grid") ? 44 : 32;
|
||||
const minWidth = 40;
|
||||
return {
|
||||
value,
|
||||
hideIcon: this.hideLabelIcon(value, minWidth),
|
||||
style: { width: this.widthTotal(value) },
|
||||
first: this.isLabelFirst(position, name),
|
||||
last: this.isLabelLast(position, name),
|
||||
[position]: true,
|
||||
};
|
||||
},
|
||||
|
|
@ -211,12 +185,21 @@ export default {
|
|||
</script>
|
||||
<style scoped>
|
||||
.site-progress {
|
||||
--height: 38px;
|
||||
--height: 2.5rem;
|
||||
height: var(--height);
|
||||
margin: 0.25rem 0;
|
||||
border-radius: 5px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
margin-right: 1.2rem;
|
||||
}
|
||||
.label-scale-name {
|
||||
color: var(--bs-gray-medium);
|
||||
flex-basis: 1.2rem;
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
writing-mode: tb-rl;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
}
|
||||
.site-progress-bar {
|
||||
display: flex;
|
||||
|
|
@ -228,7 +211,7 @@ export default {
|
|||
}
|
||||
.visualization--ready .site-progress-bar {
|
||||
transition-property: width;
|
||||
transition-duration: 500ms;
|
||||
transition-duration: var(--evcc-transition-medium);
|
||||
transition-timing-function: linear;
|
||||
}
|
||||
.grid-import {
|
||||
|
|
@ -249,12 +232,12 @@ export default {
|
|||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
.visualization--ready >>> .label-bar {
|
||||
.visualization--ready ::v-deep(.label-bar) {
|
||||
transition-property: width, opacity;
|
||||
transition-duration: 500ms, 250ms;
|
||||
transition-duration: var(--evcc-transition-medium), var(--evcc-transition-fast);
|
||||
transition-timing-function: linear, ease;
|
||||
}
|
||||
.visualization--ready >>> .label-bar-icon {
|
||||
transition: opacity 250ms ease-in;
|
||||
.visualization--ready ::v-deep(.label-bar-icon) {
|
||||
transition-duration: var(--evcc-transition-very-fast), 500ms;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import Footer from "./Footer.vue";
|
||||
import i18n from "../i18n";
|
||||
|
||||
export default {
|
||||
title: "Main/Footer",
|
||||
|
|
@ -7,11 +6,12 @@ export default {
|
|||
argTypes: {},
|
||||
};
|
||||
|
||||
const Template = (args, { argTypes }) => ({
|
||||
i18n,
|
||||
props: Object.keys(argTypes),
|
||||
const Template = (args) => ({
|
||||
setup() {
|
||||
return { args };
|
||||
},
|
||||
components: { Footer },
|
||||
template: '<Footer v-bind="$props"></Footer>',
|
||||
template: '<Footer v-bind="args"></Footer>',
|
||||
});
|
||||
|
||||
export const KeinUpdate = Template.bind({});
|
||||
|
|
@ -84,18 +84,3 @@ Upgrade.args = {
|
|||
selfConsumptionPercent: 0,
|
||||
},
|
||||
};
|
||||
|
||||
export const Savings = Template.bind({});
|
||||
Savings.args = {
|
||||
version: {
|
||||
installed: "0.36",
|
||||
available: "0.40",
|
||||
hasUpdater: true,
|
||||
},
|
||||
savings: {
|
||||
since: 82800,
|
||||
totalCharged: 15231,
|
||||
selfConsumptionCharged: 12231,
|
||||
selfConsumptionPercent: 80.3,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,16 @@
|
|||
<template>
|
||||
<footer class="container">
|
||||
<div class="py-3 py-md-5 mt-3 mt-md-5 border-top">
|
||||
<footer class="footer">
|
||||
<div class="container py-3">
|
||||
<div class="d-flex justify-content-between">
|
||||
<Version v-bind="version" />
|
||||
<!--
|
||||
<button
|
||||
class="btn btn-link p-0 flex-shrink-0"
|
||||
:class="{ 'text-evcc': $hiddenFeatures, 'text-muted': !$hiddenFeatures }"
|
||||
@click="toggleHiddenFeatures"
|
||||
>
|
||||
<shopicon-filled-testtube size="s"></shopicon-filled-testtube>
|
||||
</button>-->
|
||||
<!-- Please don't mess with the sponsor status. You risk loosing your mojo. -->
|
||||
<Savings v-bind="savings" :sponsor="sponsor" />
|
||||
</div>
|
||||
|
|
@ -11,8 +19,10 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import Version from "./Version";
|
||||
import Savings from "./Savings";
|
||||
import "@h2d2/shopicons/es/filled/testtube";
|
||||
|
||||
import Version from "./Version.vue";
|
||||
import Savings from "./Savings.vue";
|
||||
|
||||
export default {
|
||||
name: "Footer",
|
||||
|
|
@ -22,5 +32,22 @@ export default {
|
|||
sponsor: String,
|
||||
savings: Object,
|
||||
},
|
||||
methods: {
|
||||
toggleHiddenFeatures() {
|
||||
const dialog = `🧪 Experimentelle UI-Elemente ${
|
||||
this.$hiddenFeatures ? "deaktivieren" : "aktivieren"
|
||||
}`;
|
||||
if (window.confirm(dialog)) {
|
||||
window.localStorage["hidden_features"] = !this.$hiddenFeatures;
|
||||
window.location.reload();
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.footer {
|
||||
color: var(--bs-white);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
41
assets/js/components/LabelAndValue.vue
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<template>
|
||||
<div class="root">
|
||||
<div class="mb-2 label">{{ label }}</div>
|
||||
<slot>
|
||||
<h3 class="value m-0">
|
||||
{{ value }}
|
||||
<span v-if="extraValue" class="extraValue d-block d-sm-inline text-nowrap">
|
||||
{{ extraValue }}
|
||||
</span>
|
||||
</h3>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "LabelAndValue",
|
||||
props: {
|
||||
label: String,
|
||||
value: String,
|
||||
extraValue: String,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
.root {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.label {
|
||||
text-transform: uppercase;
|
||||
color: var(--bs-gray-medium);
|
||||
font-size: 14px;
|
||||
}
|
||||
.value {
|
||||
font-size: 18px;
|
||||
}
|
||||
.extraValue {
|
||||
color: var(--bs-gray-medium);
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import Loadpoint from "./Loadpoint.vue";
|
||||
import i18n from "../i18n";
|
||||
|
||||
export default {
|
||||
title: "Main/Loadpoint",
|
||||
|
|
@ -11,11 +10,12 @@ export default {
|
|||
},
|
||||
};
|
||||
|
||||
const Template = (args, { argTypes }) => ({
|
||||
i18n,
|
||||
props: Object.keys(argTypes),
|
||||
const Template = (args) => ({
|
||||
setup() {
|
||||
return { args };
|
||||
},
|
||||
components: { Loadpoint },
|
||||
template: '<Loadpoint v-bind="$props"></Loadpoint>',
|
||||
template: '<Loadpoint v-bind="args"></Loadpoint>',
|
||||
});
|
||||
|
||||
export const Base = Template.bind({});
|
||||
|
|
@ -29,9 +29,29 @@ Base.args = {
|
|||
vehicleTitle: "Mein Auto",
|
||||
enabled: true,
|
||||
connected: true,
|
||||
mode: "pv",
|
||||
charging: true,
|
||||
vehicleSoC: 66,
|
||||
targetSoC: 90,
|
||||
chargeCurrent: 7,
|
||||
minCurrent: 6,
|
||||
maxCurrent: 16,
|
||||
activePhases: 2,
|
||||
};
|
||||
|
||||
export const Idle = Template.bind({});
|
||||
Idle.args = {
|
||||
id: 0,
|
||||
chargePower: 0,
|
||||
vehiclePresent: false,
|
||||
enabled: false,
|
||||
connected: false,
|
||||
mode: "off",
|
||||
charging: false,
|
||||
chargeCurrent: 0,
|
||||
minCurrent: 6,
|
||||
maxCurrent: 16,
|
||||
activePhases: 0,
|
||||
};
|
||||
|
||||
export const Disabled = Template.bind({});
|
||||
|
|
@ -42,9 +62,16 @@ Disabled.args = {
|
|||
remoteDisabledSource: "Sunny Home Manager",
|
||||
vehiclePresent: true,
|
||||
vehicleTitle: "Mein Auto",
|
||||
chargedEnergy: 31211,
|
||||
enabled: true,
|
||||
mode: "now",
|
||||
connected: true,
|
||||
charging: false,
|
||||
chargePower: 8112,
|
||||
vehicleSoC: 66,
|
||||
targetSoC: 100,
|
||||
chargeCurrent: 7,
|
||||
minCurrent: 6,
|
||||
maxCurrent: 16,
|
||||
activePhases: 3,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,40 +1,92 @@
|
|||
<template>
|
||||
<div>
|
||||
<p class="h3 mb-4 d-sm-block" :class="{ 'd-none': single }">
|
||||
<div class="loadpoint bg-white p-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h3 class="mb-3 me-2 text-truncate">
|
||||
{{ title || $t("main.loadpoint.fallbackName") }}
|
||||
</p>
|
||||
<div v-if="remoteDisabled == 'soft'" class="alert alert-warning mt-4 mb-2" role="alert">
|
||||
{{ $t("main.loadpoint.remoteDisabledSoft", { source: remoteDisabledSource }) }}
|
||||
</h3>
|
||||
<div class="mb-3 d-flex align-items-center">
|
||||
<Mode :mode="mode" @updated="setTargetMode" />
|
||||
<button v-if="$hiddenFeatures" class="btn btn-link text-gray p-0 flex-shrink-0">
|
||||
<shopicon-filled-options size="s"></shopicon-filled-options>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="remoteDisabled == 'hard'" class="alert alert-danger mt-4 mb-2" role="alert">
|
||||
{{ $t("main.loadpoint.remoteDisabledHard", { source: remoteDisabledSource }) }}
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<Mode class="col-12 col-md-6 col-lg-4 mb-4" :mode="mode" @updated="setTargetMode" />
|
||||
<div
|
||||
v-if="remoteDisabled"
|
||||
class="alert alert-warning my-4 py-2"
|
||||
:class="`${remoteDisabled === 'hard' ? 'alert-danger' : 'alert-warning'}`"
|
||||
role="alert"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
remoteDisabled === "hard"
|
||||
? "main.loadpoint.remoteDisabledHard"
|
||||
: "main.loadpoint.remoteDisabledSoft",
|
||||
{ source: remoteDisabledSource }
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div class="details d-flex align-items-start mb-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<div>
|
||||
<LabelAndValue
|
||||
:label="$t('main.loadpoint.power')"
|
||||
:value="fmtKw(chargePower)"
|
||||
class="mb-2"
|
||||
/>
|
||||
<Phases
|
||||
v-bind="phasesProps"
|
||||
class="opacity-transiton"
|
||||
:class="`opacity-${charging ? '100' : '0'}`"
|
||||
/>
|
||||
</div>
|
||||
<shopicon-regular-lightning
|
||||
class="text-evcc opacity-transiton"
|
||||
:class="`opacity-${charging ? '100' : '0'}`"
|
||||
size="m"
|
||||
></shopicon-regular-lightning>
|
||||
</div>
|
||||
<LabelAndValue :label="$t('main.loadpoint.charged')" :value="fmtKWh(chargedEnergy)" />
|
||||
<LabelAndValue
|
||||
v-if="chargeRemainingDurationInterpolated"
|
||||
:label="$t('main.loadpoint.remaining')"
|
||||
:value="`
|
||||
${fmtShortDuration(chargeRemainingDurationInterpolated)}
|
||||
${fmtShortDurationUnit(chargeRemainingDurationInterpolated, true)}`"
|
||||
/>
|
||||
<LabelAndValue
|
||||
v-else
|
||||
:label="$t('main.loadpoint.duration')"
|
||||
:value="`
|
||||
${fmtShortDuration(chargeDurationInterpolated)}
|
||||
${fmtShortDurationUnit(chargeDurationInterpolated)}`"
|
||||
/>
|
||||
</div>
|
||||
<Vehicle
|
||||
class="col-12 col-md-6 col-lg-8 mb-4"
|
||||
v-bind="vehicle"
|
||||
@target-soc-updated="setTargetSoC"
|
||||
@target-time-updated="setTargetTime"
|
||||
@target-time-removed="removeTargetTime"
|
||||
/>
|
||||
</div>
|
||||
<LoadpointDetails v-bind="details" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import "@h2d2/shopicons/es/regular/lightning";
|
||||
import "@h2d2/shopicons/es/filled/options";
|
||||
import api from "../api";
|
||||
import Mode from "./Mode";
|
||||
import Vehicle from "./Vehicle";
|
||||
import LoadpointDetails from "./LoadpointDetails";
|
||||
import Mode from "./Mode.vue";
|
||||
import Vehicle from "./Vehicle.vue";
|
||||
import Phases from "./Phases.vue";
|
||||
import LabelAndValue from "./LabelAndValue.vue";
|
||||
import formatter from "../mixins/formatter";
|
||||
import collector from "../mixins/collector";
|
||||
|
||||
export default {
|
||||
name: "Loadpoint",
|
||||
components: { LoadpointDetails, Mode, Vehicle },
|
||||
components: { Mode, Vehicle, Phases, LabelAndValue },
|
||||
mixins: [formatter, collector],
|
||||
props: {
|
||||
id: Number,
|
||||
|
|
@ -60,6 +112,7 @@ export default {
|
|||
minSoC: Number,
|
||||
targetTime: String,
|
||||
targetTimeActive: Boolean,
|
||||
targetTimeProjectedStart: String,
|
||||
vehicleProviderLoggedIn: Boolean,
|
||||
vehicleProviderLoginPath: String,
|
||||
vehicleProviderLogoutPath: String,
|
||||
|
|
@ -87,15 +140,58 @@ export default {
|
|||
pvRemaining: Number,
|
||||
pvAction: String,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tickerHandler: null,
|
||||
phaseRemainingInterpolated: this.phaseRemaining,
|
||||
pvRemainingInterpolated: this.pvRemaining,
|
||||
chargeDurationInterpolated: this.chargeDuration,
|
||||
chargeRemainingDurationInterpolated: this.chargeRemainingDuration,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
details: function () {
|
||||
return this.collectProps(LoadpointDetails);
|
||||
phasesProps: function () {
|
||||
return this.collectProps(Phases);
|
||||
},
|
||||
vehicle: function () {
|
||||
return this.collectProps(Vehicle);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
phaseRemaining() {
|
||||
this.phaseRemainingInterpolated = this.phaseRemaining;
|
||||
},
|
||||
pvRemaining() {
|
||||
this.pvRemainingInterpolated = this.pvRemaining;
|
||||
},
|
||||
chargeDuration() {
|
||||
this.chargeDurationInterpolated = this.chargeDuration;
|
||||
},
|
||||
chargeRemainingDuration() {
|
||||
this.chargeRemainingDurationInterpolated = this.chargeRemainingDuration;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.tickerHandler = setInterval(this.tick, 1000);
|
||||
},
|
||||
unmounted() {
|
||||
clearInterval(this.tickerHandler);
|
||||
},
|
||||
methods: {
|
||||
tick() {
|
||||
if (this.phaseRemainingInterpolated > 0) {
|
||||
this.phaseRemainingInterpolated--;
|
||||
}
|
||||
if (this.pvRemainingInterpolated > 0) {
|
||||
this.pvRemainingInterpolated--;
|
||||
}
|
||||
if (this.chargeDurationInterpolated > 0 && this.charging) {
|
||||
this.chargeDurationInterpolated++;
|
||||
}
|
||||
if (this.chargeRemainingDurationInterpolated > 0 && this.charging) {
|
||||
this.chargeRemainingDurationInterpolated--;
|
||||
}
|
||||
},
|
||||
apiPath: function (func) {
|
||||
return "loadpoints/" + this.id + "/" + func;
|
||||
},
|
||||
|
|
@ -115,3 +211,24 @@ export default {
|
|||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loadpoint {
|
||||
border-radius: 2rem;
|
||||
color: var(--bs-gray-dark);
|
||||
}
|
||||
|
||||
.details > div {
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
}
|
||||
.details > div:nth-child(2) {
|
||||
text-align: center;
|
||||
}
|
||||
.details > div:nth-child(3) {
|
||||
text-align: right;
|
||||
}
|
||||
.opacity-transiton {
|
||||
transition: opacity var(--evcc-transition-slow) ease-in;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import LoadpointDetails from "./LoadpointDetails.vue";
|
||||
import i18n from "../i18n";
|
||||
import "../tooltip";
|
||||
|
||||
export default {
|
||||
title: "Main/LoadpointDetails",
|
||||
|
|
@ -1,201 +0,0 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="row">
|
||||
<div class="col-6 col-sm-3 col-lg-2 mt-3 offset-lg-4">
|
||||
<div class="mb-2 value">
|
||||
{{ $t("main.loadpointDetails.power") }}
|
||||
<div
|
||||
v-if="chargePower && activePhases"
|
||||
v-tooltip="{ content: phaseTooltip }"
|
||||
class="badge rounded-pill bg-secondary text-light cursor-pointer d-inline-flex align-items-center"
|
||||
tabindex="0"
|
||||
>
|
||||
<div>{{ activePhases }}P</div>
|
||||
<WaitingDots
|
||||
v-if="phaseTimerVisible"
|
||||
:direction="phaseAction === 'scale1p' ? 'down' : 'up'"
|
||||
/>
|
||||
</div>
|
||||
<fa-icon
|
||||
v-if="climater == 'heating'"
|
||||
class="text-primary ms-1"
|
||||
icon="temperature-low"
|
||||
></fa-icon>
|
||||
<fa-icon
|
||||
v-if="climater == 'cooling'"
|
||||
class="text-primary ms-1"
|
||||
icon="temperature-high"
|
||||
></fa-icon>
|
||||
<fa-icon
|
||||
v-if="climater == 'on'"
|
||||
class="text-primary ms-1"
|
||||
icon="thermometer-half"
|
||||
></fa-icon>
|
||||
</div>
|
||||
<h3 class="value">
|
||||
{{ fmt(chargePower) }}
|
||||
<small class="text-muted">{{ fmtUnit(chargePower) }}W</small
|
||||
><small
|
||||
v-if="pvTimerVisible"
|
||||
v-tooltip="{
|
||||
content: $t(`main.loadpointDetails.tooltip.pv.${pvAction}`, {
|
||||
remaining: fmtShortDuration(pvRemainingInterpolated, true),
|
||||
}),
|
||||
}"
|
||||
class="text-muted cursor-pointer d-inline-block align-bottom"
|
||||
style="margin-bottom: 0.1em"
|
||||
tabindex="0"
|
||||
>
|
||||
<WaitingDots :direction="pvAction === 'disable' ? 'down' : 'up'" />
|
||||
</small>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-sm-3 col-lg-2 mt-3">
|
||||
<div class="mb-2 value">{{ $t("main.loadpointDetails.charged") }}</div>
|
||||
<h3 class="value">
|
||||
{{ fmt(chargedEnergy) }}
|
||||
<small class="text-muted">{{ fmtUnit(chargedEnergy) }}Wh</small>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div v-if="vehicleRange && vehicleRange >= 0" class="col-6 col-sm-3 col-lg-2 mt-3">
|
||||
<div class="mb-2 value">{{ $t("main.loadpointDetails.vehicleRange") }}</div>
|
||||
<h3 class="value">
|
||||
{{ Math.round(vehicleRange) }}
|
||||
<small class="text-muted">km</small>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div v-else class="col-6 col-sm-3 col-lg-2 mt-3">
|
||||
<div class="mb-2 value">{{ $t("main.loadpointDetails.duration") }}</div>
|
||||
<h3 class="value">
|
||||
{{ fmtShortDuration(chargeDurationInterpolated) }}
|
||||
<small class="text-muted">{{
|
||||
fmtShortDurationUnit(chargeDurationInterpolated)
|
||||
}}</small>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div v-if="vehiclePresent" class="col-6 col-sm-3 col-lg-2 mt-3">
|
||||
<div class="mb-2 value">{{ $t("main.loadpointDetails.remaining") }}</div>
|
||||
<h3 class="value">
|
||||
{{ fmtShortDuration(chargeRemainingDurationInterpolated) }}
|
||||
<small class="text-muted">{{
|
||||
fmtShortDurationUnit(chargeRemainingDurationInterpolated, true)
|
||||
}}</small>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import WaitingDots from "./WaitingDots";
|
||||
import formatter from "../mixins/formatter";
|
||||
|
||||
export default {
|
||||
name: "LoadpointDetails",
|
||||
components: { WaitingDots },
|
||||
mixins: [formatter],
|
||||
props: {
|
||||
chargedEnergy: Number,
|
||||
chargeDuration: Number,
|
||||
chargeRemainingDuration: Number,
|
||||
chargePower: Number,
|
||||
climater: String,
|
||||
vehiclePresent: Boolean,
|
||||
vehicleRange: Number,
|
||||
activePhases: Number,
|
||||
phaseRemaining: Number,
|
||||
phaseAction: String,
|
||||
pvRemaining: Number,
|
||||
pvAction: String,
|
||||
charging: Boolean,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tickerHandler: null,
|
||||
phaseRemainingInterpolated: this.phaseRemaining,
|
||||
pvRemainingInterpolated: this.pvRemaining,
|
||||
chargeDurationInterpolated: this.chargeDuration,
|
||||
chargeRemainingDurationInterpolated: this.chargeRemainingDuration,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
phaseTooltip() {
|
||||
if (["scale1p", "scale3p"].includes(this.phaseAction)) {
|
||||
return this.$t(`main.loadpointDetails.tooltip.phases.${this.phaseAction}`, {
|
||||
remaining: this.fmtShortDuration(this.phaseRemainingInterpolated, true),
|
||||
});
|
||||
}
|
||||
return this.$t(`main.loadpointDetails.tooltip.phases.charge${this.activePhases}p`);
|
||||
},
|
||||
phaseTimerActive() {
|
||||
return (
|
||||
this.phaseRemainingInterpolated > 0 &&
|
||||
["scale1p", "scale3p"].includes(this.phaseAction)
|
||||
);
|
||||
},
|
||||
pvTimerActive() {
|
||||
return (
|
||||
this.pvRemainingInterpolated > 0 && ["enable", "disable"].includes(this.pvAction)
|
||||
);
|
||||
},
|
||||
phaseTimerVisible() {
|
||||
if (this.phaseTimerActive && !this.pvTimerActive) {
|
||||
return true;
|
||||
}
|
||||
if (this.phaseTimerActive && this.pvTimerActive) {
|
||||
return this.phaseRemainingInterpolated < this.pvRemainingInterpolated; // only show next timer
|
||||
}
|
||||
return false;
|
||||
},
|
||||
pvTimerVisible() {
|
||||
if (this.pvTimerActive && !this.phaseTimerActive) {
|
||||
return true;
|
||||
}
|
||||
if (this.pvTimerActive && this.phaseTimerActive) {
|
||||
return this.pvRemainingInterpolated < this.phaseRemainingInterpolated; // only show next timer
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
phaseRemaining() {
|
||||
this.phaseRemainingInterpolated = this.phaseRemaining;
|
||||
},
|
||||
pvRemaining() {
|
||||
this.pvRemainingInterpolated = this.pvRemaining;
|
||||
},
|
||||
chargeDuration() {
|
||||
this.chargeDurationInterpolated = this.chargeDuration;
|
||||
},
|
||||
chargeRemainingDuration() {
|
||||
this.chargeRemainingDurationInterpolated = this.chargeRemainingDuration;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.tickerHandler = setInterval(this.tick, 1000);
|
||||
},
|
||||
destroyed() {
|
||||
clearInterval(this.tickerHandler);
|
||||
},
|
||||
methods: {
|
||||
tick() {
|
||||
if (this.phaseRemainingInterpolated > 0) {
|
||||
this.phaseRemainingInterpolated--;
|
||||
}
|
||||
if (this.pvRemainingInterpolated > 0) {
|
||||
this.pvRemainingInterpolated--;
|
||||
}
|
||||
if (this.chargeDurationInterpolated > 0 && this.charging) {
|
||||
this.chargeDurationInterpolated++;
|
||||
}
|
||||
if (this.chargeRemainingDurationInterpolated > 0 && this.charging) {
|
||||
this.chargeRemainingDurationInterpolated--;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
172
assets/js/components/Loadpoints.vue
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
<template>
|
||||
<div
|
||||
class="container container--loadpoint px-0 mb-3 mb-md-4 d-flex flex-column justify-content-center"
|
||||
>
|
||||
<div ref="carousel" class="carousel d-lg-flex flex-wrap">
|
||||
<div
|
||||
v-for="(loadpoint, index) in loadpoints"
|
||||
:key="index"
|
||||
class="flex-grow-1 mb-3 m-lg-0 p-lg-0"
|
||||
>
|
||||
<Loadpoint
|
||||
v-bind="loadpoint"
|
||||
:id="index"
|
||||
class="h-100"
|
||||
:class="{ 'loadpoint-unselected': !selected(index) }"
|
||||
@click="scrollTo(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loadpoints.length > 1" class="d-flex d-lg-none justify-content-center">
|
||||
<button
|
||||
v-for="(loadpoint, index) in loadpoints"
|
||||
:key="index"
|
||||
class="btn btn-sm btn-link p-0 mx-1 indicator d-flex justify-content-center align-items-center text-white"
|
||||
:class="{ 'indicator--selected': selected(index) }"
|
||||
@click="scrollTo(index)"
|
||||
>
|
||||
<shopicon-filled-circle class="indicator-icon"></shopicon-filled-circle>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import "@h2d2/shopicons/es/filled/circle";
|
||||
|
||||
import Loadpoint from "./Loadpoint.vue";
|
||||
import collector from "../mixins/collector";
|
||||
|
||||
export default {
|
||||
name: "Site",
|
||||
components: { Loadpoint },
|
||||
mixins: [collector],
|
||||
props: {
|
||||
loadpoints: Array,
|
||||
},
|
||||
data() {
|
||||
return { selectedIndex: 0, snapTimeout: null };
|
||||
},
|
||||
mounted() {
|
||||
this.$refs.carousel.addEventListener("scroll", this.handleCarouselScroll, false);
|
||||
},
|
||||
unmounted() {
|
||||
this.$refs.carousel.removeEventListener("scroll", this.handleCarouselScroll);
|
||||
},
|
||||
methods: {
|
||||
handleCarouselScroll() {
|
||||
const { scrollLeft } = this.$refs.carousel;
|
||||
const { offsetWidth } = this.$refs.carousel.children[0];
|
||||
this.selectedIndex = Math.round((scrollLeft - 7.5) / offsetWidth);
|
||||
},
|
||||
selected(index) {
|
||||
return this.selectedIndex === index;
|
||||
},
|
||||
scrollTo(index) {
|
||||
if (this.selectedIndex === index) {
|
||||
return;
|
||||
}
|
||||
this.selectedIndex = index;
|
||||
const $carousel = this.$refs.carousel;
|
||||
const width = $carousel.children[0].offsetWidth;
|
||||
$carousel.style.scrollSnapType = "none";
|
||||
$carousel.scrollTo({ top: 0, left: 7.5 + width * index, behavior: "smooth" });
|
||||
|
||||
clearTimeout(this.snapTimeout);
|
||||
this.snapTimeout = setTimeout(() => {
|
||||
this.$refs.carousel.style.scrollSnapType = "x mandatory";
|
||||
}, 1000);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
.container--loadpoint {
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
.carousel {
|
||||
scroll-snap-type: x mandatory;
|
||||
overflow-x: scroll;
|
||||
display: flex;
|
||||
flex-wrap: nowrap !important;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE 10+ */
|
||||
}
|
||||
.carousel::-webkit-scrollbar {
|
||||
display: none; /* Blink, Webkit */
|
||||
}
|
||||
.carousel > * {
|
||||
scroll-snap-align: center;
|
||||
min-width: 100%;
|
||||
}
|
||||
.indicator {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
opacity: 0.3;
|
||||
transition: opacity var(--evcc-transition-fast) ease-in;
|
||||
}
|
||||
.indicator--selected {
|
||||
opacity: 1;
|
||||
}
|
||||
.indicator-icon {
|
||||
width: 18px;
|
||||
}
|
||||
.loadpoint {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
transition-property: opacity, transform;
|
||||
transition-duration: var(--evcc-transition-fast);
|
||||
transition-timing-function: ease-in;
|
||||
}
|
||||
.loadpoint-unselected {
|
||||
transform: scale(0.95);
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* show truncated tiles on breakpoind sm,md */
|
||||
@media (min-width: 576px) and (max-width: 991.98px) {
|
||||
.container--loadpoint {
|
||||
max-width: none;
|
||||
}
|
||||
.carousel > *:first-child {
|
||||
margin-left: calc((100vw - var(--slide-width)) / 2);
|
||||
}
|
||||
.carousel > *:last-child {
|
||||
margin-right: calc((100vw - var(--slide-width)) / 2);
|
||||
}
|
||||
/* fixes safari issue with end-side padding https://webplatform.news/issues/2019-08-07 */
|
||||
.carousel::after {
|
||||
content: "";
|
||||
padding-right: 0.02px;
|
||||
}
|
||||
.carousel > * {
|
||||
min-width: var(--slide-width);
|
||||
}
|
||||
}
|
||||
|
||||
/* breakpoind sm */
|
||||
@media (min-width: 576px) and (max-width: 767.98px) {
|
||||
.carousel {
|
||||
--slide-width: 540px;
|
||||
}
|
||||
}
|
||||
|
||||
/* breakpoind md */
|
||||
@media (min-width: 768px) and (max-width: 991.98px) {
|
||||
.carousel {
|
||||
--slide-width: 720px;
|
||||
}
|
||||
}
|
||||
|
||||
/* breakpoind lg */
|
||||
@media (min-width: 992px) {
|
||||
.carousel {
|
||||
display: grid !important;
|
||||
grid-gap: 2rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(450px, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,27 +1,20 @@
|
|||
import Mode from "./Mode.vue";
|
||||
import i18n from "../i18n";
|
||||
|
||||
export default {
|
||||
title: "Main/Mode",
|
||||
component: Mode,
|
||||
argTypes: {
|
||||
mode: { control: { type: "inline-radio", options: ["off", "now", "minpv", "pv"] } },
|
||||
mode: { control: { type: "inline-radio" }, options: ["off", "now", "minpv", "pv"] },
|
||||
},
|
||||
};
|
||||
|
||||
const Template = (args, { argTypes }) => ({
|
||||
i18n,
|
||||
props: Object.keys(argTypes),
|
||||
const Template = (args) => ({
|
||||
setup() {
|
||||
return { args };
|
||||
},
|
||||
components: { Mode },
|
||||
template: '<Mode v-bind="$props"></Mode>',
|
||||
template: '<Mode v-bind="args"></Mode>',
|
||||
});
|
||||
|
||||
export const Base = Template.bind({});
|
||||
Base.args = {};
|
||||
|
||||
export const CaptionAndPV = Template.bind({});
|
||||
CaptionAndPV.args = {
|
||||
caption: true,
|
||||
pvConfigured: true,
|
||||
mode: "pv",
|
||||
};
|
||||
Base.args = { mode: "pv" };
|
||||
|
|
|
|||
|
|
@ -1,42 +1,28 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="mb-3">{{ $t("main.mode.title") }}</div>
|
||||
<div class="btn-group w-100" role="group">
|
||||
<div v-if="$hiddenFeatures" class="mode-group border d-inline-flex" role="group">
|
||||
<button
|
||||
v-for="m in ['fast', 'cheap']"
|
||||
:key="m"
|
||||
type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
:class="{ active: mode == 'off' }"
|
||||
@click="setTargetMode('off')"
|
||||
class="btn"
|
||||
:class="{ active: isActive(m) }"
|
||||
@click="setTargetMode(m)"
|
||||
>
|
||||
{{ $t("main.mode.stop") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
:class="{ active: mode == 'now' }"
|
||||
@click="setTargetMode('now')"
|
||||
>
|
||||
{{ $t("main.mode.now") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
:class="{ active: mode == 'minpv' }"
|
||||
@click="setTargetMode('minpv')"
|
||||
>
|
||||
<span class="d-inline d-sm-none"> {{ $t("main.mode.minpvShort") }} </span>
|
||||
<span class="d-none d-sm-inline"> {{ $t("main.mode.minpvLong") }} </span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
:class="{ active: mode == 'pv' }"
|
||||
@click="setTargetMode('pv')"
|
||||
>
|
||||
<span class="d-inline d-sm-none"> {{ $t("main.mode.pvShort") }} </span>
|
||||
<span class="d-none d-sm-inline"> {{ $t("main.mode.pvLong") }} </span>
|
||||
{{ $t(`main.mode.${m}`) }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="mode-group border d-inline-flex" role="group">
|
||||
<button
|
||||
v-for="m in modes"
|
||||
:key="m"
|
||||
type="button"
|
||||
class="btn"
|
||||
:class="{ active: isActive(m) }"
|
||||
@click="setTargetMode(m)"
|
||||
>
|
||||
<span class="d-inline d-sm-none"> {{ $t(`main.mode.${m}Short`) }} </span>
|
||||
<span class="d-none d-sm-inline"> {{ $t(`main.mode.${m}Long`) }} </span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -46,18 +32,49 @@ export default {
|
|||
props: {
|
||||
mode: String,
|
||||
},
|
||||
emits: ["updated"],
|
||||
data() {
|
||||
return {
|
||||
modes: ["off", "now", "minpv", "pv"],
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
isActive: function (mode) {
|
||||
return this.mode === this.mapToOldModes(mode);
|
||||
},
|
||||
setTargetMode: function (mode) {
|
||||
this.$emit("updated", mode);
|
||||
this.$emit("updated", this.mapToOldModes(mode));
|
||||
},
|
||||
mapToOldModes: function (mode) {
|
||||
const mapping = { fast: "now", cheap: "pv" };
|
||||
return mapping[mode] || mode;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
<style scoped>
|
||||
.mode-group {
|
||||
border: 2px solid var(--bs-gray-medium);
|
||||
background-color: var(--bs-white);
|
||||
border-radius: 20px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
/* equal width buttons */
|
||||
flex-basis: 0;
|
||||
white-space: nowrap;
|
||||
border-radius: 18px;
|
||||
padding: 0.1em 0.8em;
|
||||
}
|
||||
.btn.active {
|
||||
background: var(--bs-gray-dark);
|
||||
color: var(--bs-white);
|
||||
}
|
||||
.btn-group {
|
||||
border-radius: 16px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import Notifications from "./Notifications.vue";
|
||||
import i18n from "../i18n";
|
||||
|
||||
export default {
|
||||
title: "Main/Notifications",
|
||||
|
|
@ -7,11 +6,12 @@ export default {
|
|||
argTypes: {},
|
||||
};
|
||||
|
||||
const Template = (args, { argTypes }) => ({
|
||||
i18n,
|
||||
props: Object.keys(argTypes),
|
||||
const Template = (args) => ({
|
||||
setup() {
|
||||
return { args };
|
||||
},
|
||||
components: { Notifications },
|
||||
template: '<Notifications v-bind="$props"></Notifications>',
|
||||
template: '<Notifications v-bind="args"></Notifications>',
|
||||
});
|
||||
|
||||
function timeAgo(hours = 0, minutes = 0, seconds = 0) {
|
||||
|
|
|
|||
|
|
@ -5,17 +5,20 @@
|
|||
href="#"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#notificationModal"
|
||||
class="btn btn-link text-decoration-none link-light text-nowrap"
|
||||
class="btn btn-sm btn-link text-decoration-none link-light text-nowrap"
|
||||
>
|
||||
<fa-icon :class="iconClass" icon="exclamation-triangle"></fa-icon>
|
||||
<shopicon-regular-exclamationtriangle
|
||||
:class="iconClass"
|
||||
></shopicon-regular-exclamationtriangle>
|
||||
</button>
|
||||
|
||||
<div
|
||||
id="notificationModal"
|
||||
class="modal fade"
|
||||
class="modal fade text-dark"
|
||||
tabindex="-1"
|
||||
role="dialog"
|
||||
aria-hidden="true"
|
||||
data-bs-backdrop="true"
|
||||
>
|
||||
<div
|
||||
class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable"
|
||||
|
|
@ -40,14 +43,13 @@
|
|||
{{ fmtTimeAgo(msg.time - new Date()) }}
|
||||
</small>
|
||||
<p class="d-flex align-items-baseline">
|
||||
<fa-icon
|
||||
<shopicon-regular-exclamationtriangle
|
||||
:class="{
|
||||
'text-danger': msg.type === 'error',
|
||||
'text-warning': msg.type === 'warn',
|
||||
}"
|
||||
class="flex-grow-0 d-block"
|
||||
icon="exclamation-triangle"
|
||||
></fa-icon>
|
||||
></shopicon-regular-exclamationtriangle>
|
||||
<span class="flex-grow-1 px-2 py-1 text-break">
|
||||
{{ msg.message }}
|
||||
</span>
|
||||
|
|
@ -75,7 +77,7 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import "../icons";
|
||||
import "@h2d2/shopicons/es/regular/exclamationtriangle";
|
||||
import formatter from "../mixins/formatter";
|
||||
|
||||
export default {
|
||||
|
|
@ -99,7 +101,7 @@ export default {
|
|||
this.$forceUpdate();
|
||||
}, 10 * 1000);
|
||||
},
|
||||
destroyed: function () {
|
||||
unmounted: function () {
|
||||
clearTimeout(this.interval);
|
||||
},
|
||||
methods: {
|
||||
|
|
|
|||
59
assets/js/components/Phases.stories.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import Phases from "./Phases.vue";
|
||||
|
||||
export default {
|
||||
title: "Main/Phases",
|
||||
component: Phases,
|
||||
argTypes: {},
|
||||
parameters: {
|
||||
backgrounds: {
|
||||
default: "light",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const Template = (args, { argTypes }) => ({
|
||||
props: Object.keys(argTypes),
|
||||
components: { Phases },
|
||||
template: '<Phases v-bind="$props"></Phases>',
|
||||
});
|
||||
|
||||
export const Base = Template.bind({});
|
||||
Base.args = {
|
||||
activePhases: 1,
|
||||
minCurrent: 6,
|
||||
maxCurrent: 32,
|
||||
chargeCurrent: 10,
|
||||
};
|
||||
|
||||
export const OnePhase = Template.bind({});
|
||||
OnePhase.args = {
|
||||
activePhases: 1,
|
||||
minCurrent: 6,
|
||||
maxCurrent: 16,
|
||||
chargeCurrent: 6,
|
||||
};
|
||||
|
||||
export const TwoPhase = Template.bind({});
|
||||
TwoPhase.args = {
|
||||
activePhases: 2,
|
||||
minCurrent: 6,
|
||||
maxCurrent: 16,
|
||||
chargeCurrent: 8,
|
||||
};
|
||||
|
||||
export const ThreePhases = Template.bind({});
|
||||
ThreePhases.args = {
|
||||
activePhases: 3,
|
||||
minCurrent: 6,
|
||||
maxCurrent: 16,
|
||||
chargeCurrent: 12,
|
||||
};
|
||||
|
||||
export const RealCurrents = Template.bind({});
|
||||
RealCurrents.args = {
|
||||
activePhases: 3,
|
||||
minCurrent: 6,
|
||||
maxCurrent: 32,
|
||||
chargeCurrent: 16.5,
|
||||
chargeCurrents: [15.2, 15.76, 14.5],
|
||||
};
|
||||
99
assets/js/components/Phases.vue
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
<template>
|
||||
<div class="phases d-flex justify-content-between">
|
||||
<div
|
||||
v-for="num in [1, 2, 3]"
|
||||
:key="num"
|
||||
class="phase me-1"
|
||||
:class="{ inactive: inactive(num) }"
|
||||
>
|
||||
<div class="target" :style="{ width: `${targetWidth()}%` }"></div>
|
||||
<div class="real" :style="{ width: `${realWidth(num)}%` }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "Phases",
|
||||
props: {
|
||||
chargeCurrent: { type: Number },
|
||||
chargeCurrents: { type: Object },
|
||||
activePhases: { type: Number },
|
||||
minCurrent: { type: Number },
|
||||
maxCurrent: { type: Number },
|
||||
phaseAction: { type: String },
|
||||
phaseRemaining: { type: Number },
|
||||
},
|
||||
computed: {
|
||||
phaseTimerVisible() {
|
||||
if (this.phaseTimerActive && !this.pvTimerActive) {
|
||||
return true;
|
||||
}
|
||||
if (this.phaseTimerActive && this.pvTimerActive) {
|
||||
return this.phaseRemainingInterpolated < this.pvRemainingInterpolated; // only show next timer
|
||||
}
|
||||
return false;
|
||||
},
|
||||
pvTimerVisible() {
|
||||
if (this.pvTimerActive && !this.phaseTimerActive) {
|
||||
return true;
|
||||
}
|
||||
if (this.pvTimerActive && this.phaseTimerActive) {
|
||||
return this.pvRemainingInterpolated < this.phaseRemainingInterpolated; // only show next timer
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
inactive(num) {
|
||||
return num > this.activePhases;
|
||||
},
|
||||
targetWidth() {
|
||||
let current = Math.min(Math.max(this.minCurrent, this.chargeCurrent), this.maxCurrent);
|
||||
return (100 / this.maxCurrent) * current;
|
||||
},
|
||||
realWidth(num) {
|
||||
if (this.chargeCurrents) {
|
||||
const current = this.chargeCurrents[num - 1] || 0;
|
||||
return (100 / this.maxCurrent) * current;
|
||||
}
|
||||
return this.targetWidth();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.phases {
|
||||
width: 68px;
|
||||
}
|
||||
.phase {
|
||||
background-color: var(--bs-gray-200);
|
||||
height: 4px;
|
||||
width: 20px;
|
||||
position: relative;
|
||||
border-radius: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.target,
|
||||
.real {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
transition-property: width, opacity;
|
||||
transition-duration: var(--evcc-transition-slow);
|
||||
transition-timing-function: ease-in;
|
||||
opacity: 1;
|
||||
}
|
||||
.target {
|
||||
background-color: var(--evcc-green);
|
||||
}
|
||||
.real {
|
||||
background-color: var(--evcc-dark-green);
|
||||
}
|
||||
.phase.inactive .target,
|
||||
.phase.inactive .real {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
29
assets/js/components/Savings.stories.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import Savings from "./Savings.vue";
|
||||
|
||||
export default {
|
||||
title: "Main/Footer/Savings",
|
||||
component: Savings,
|
||||
argTypes: {},
|
||||
};
|
||||
|
||||
const Template = (args) => ({
|
||||
setup() {
|
||||
return { args };
|
||||
},
|
||||
components: { Savings },
|
||||
template: '<div class="d-flex justify-content-end"><Savings v-bind="args"></Savings></div>',
|
||||
});
|
||||
|
||||
export const Default = Template.bind({});
|
||||
Default.args = {
|
||||
since: 82800,
|
||||
totalCharged: 15231,
|
||||
selfConsumptionCharged: 12231,
|
||||
selfConsumptionPercent: 80.3,
|
||||
};
|
||||
|
||||
export const NoCharge = Template.bind({});
|
||||
NoCharge.args = {
|
||||
since: 82800,
|
||||
totalCharged: 0,
|
||||
};
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<div>
|
||||
<button
|
||||
class="btn btn-link pe-0 text-decoration-none link-dark text-nowrap"
|
||||
class="btn btn-link pe-0 text-decoration-none text-white text-nowrap d-flex align-items-end"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#savingsModal"
|
||||
>
|
||||
|
|
@ -10,36 +10,38 @@
|
|||
}}</span
|
||||
><span class="d-none d-sm-inline">{{
|
||||
$t("footer.savings.footerLong", { percent })
|
||||
}}</span
|
||||
><fa-icon icon="sun" class="icon ms-2 text-evcc"></fa-icon>
|
||||
}}</span>
|
||||
<shopicon-regular-sun class="ms-2 text-evcc"></shopicon-regular-sun>
|
||||
</button>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
id="savingsModal"
|
||||
ref="modal"
|
||||
class="modal fade"
|
||||
class="modal fade text-dark"
|
||||
data-bs-backdrop="true"
|
||||
tabindex="-1"
|
||||
role="dialog"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable" role="document">
|
||||
<div
|
||||
class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable"
|
||||
role="document"
|
||||
>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<span class="d-block d-sm-none">
|
||||
<span class="text-nowrap">
|
||||
{{
|
||||
$t("footer.savings.modalTitleShort", {
|
||||
percent,
|
||||
$t("footer.savings.modalTitlePartOne", {
|
||||
total: fmtKw(totalCharged * 1000, true, false),
|
||||
})
|
||||
}}
|
||||
·
|
||||
</span>
|
||||
<span class="d-none d-sm-block">
|
||||
{{
|
||||
$t("footer.savings.modalTitleLong", {
|
||||
percent,
|
||||
total: fmtKw(totalCharged * 1000, true, false),
|
||||
})
|
||||
}}
|
||||
{{ " " }}
|
||||
<span class="text-nowrap">
|
||||
{{ $t("footer.savings.modalTitlePartTwo", { percent }) }}
|
||||
</span>
|
||||
</h5>
|
||||
<button
|
||||
|
|
@ -51,44 +53,62 @@
|
|||
</div>
|
||||
<div class="modal-body py-4">
|
||||
<div class="chart-container mb-3">
|
||||
<div class="chart-legend d-flex flex-wrap justify-content-between mb-1">
|
||||
<div class="text-nowrap">
|
||||
<fa-icon icon="square" class="text-evcc"></fa-icon>
|
||||
<div
|
||||
class="chart-legend d-flex flex-wrap justify-content-between mb-1"
|
||||
>
|
||||
<div
|
||||
class="text-nowrap"
|
||||
:class="{ 'text-gray-medium': noData }"
|
||||
>
|
||||
<shopicon-filled-square
|
||||
class="text-evcc d-inline-block"
|
||||
:class="{ 'text-gray-medium': noData }"
|
||||
></shopicon-filled-square>
|
||||
{{
|
||||
$t("footer.savings.modalChartSelf", {
|
||||
self: fmtKw(selfConsumptionCharged * 1000, true, false),
|
||||
self: fmtKw(
|
||||
selfConsumptionCharged * 1000,
|
||||
true,
|
||||
false
|
||||
),
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
<div class="text-nowrap">
|
||||
<fa-icon icon="square" class="text-grid"></fa-icon>
|
||||
<div
|
||||
class="text-nowrap"
|
||||
:class="{ 'text-gray-medium': noData }"
|
||||
>
|
||||
{{
|
||||
$t("footer.savings.modalChartGrid", {
|
||||
grid: fmtKw(gridCharged * 1000, true, false),
|
||||
})
|
||||
}}
|
||||
<shopicon-filled-square
|
||||
class="text-grid d-inline-block"
|
||||
:class="{ 'text-gray-medium': noData }"
|
||||
></shopicon-filled-square>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="chart d-flex justify-content-stretch mb-1 rounded overflow-hidden"
|
||||
>
|
||||
<div
|
||||
v-if="totalCharged > 0"
|
||||
class="chart-item chart-item--self d-flex justify-content-center text-white flex-shrink-1"
|
||||
v-if="!noData"
|
||||
class="chart-item chart-item--self d-flex justify-content-center align-items-center text-white flex-shrink-1"
|
||||
:style="{ width: `${percent}%` }"
|
||||
>
|
||||
<span class="text-truncate"> {{ percent }}% </span>
|
||||
</div>
|
||||
<div
|
||||
v-if="totalCharged > 0"
|
||||
class="chart-item chart-item--grid d-flex justify-content-center text-white flex-shrink-1"
|
||||
v-if="!noData"
|
||||
class="chart-item chart-item--grid d-flex justify-content-center align-items-center text-white flex-shrink-1"
|
||||
:style="{ width: `${100 - percent}%` }"
|
||||
>
|
||||
<span class="text-truncate"> {{ 100 - percent }}% </span>
|
||||
</div>
|
||||
<div
|
||||
v-if="totalCharged === 0"
|
||||
class="chart-item chart-item--no-data d-flex justify-content-center text-white w-100"
|
||||
v-if="noData"
|
||||
class="chart-item chart-item--no-data d-flex justify-content-center align-items-center text-white w-100"
|
||||
>
|
||||
<span>{{ $t("footer.savings.modalNoData") }}</span>
|
||||
</div>
|
||||
|
|
@ -106,7 +126,7 @@
|
|||
<a
|
||||
href="https://docs.evcc.io/docs/guides/setup/#ersparnisberechnung"
|
||||
target="_blank"
|
||||
class="text-muted"
|
||||
class="small"
|
||||
>
|
||||
{{ $t("footer.savings.modalExplaination") }}</a
|
||||
>:
|
||||
|
|
@ -134,30 +154,20 @@
|
|||
|
||||
<hr class="mb-4" />
|
||||
|
||||
<Sponsor :sponsor="sponsor" class="mb-4" />
|
||||
|
||||
<p class="small text-muted mb-0">
|
||||
<strong class="text-primary">
|
||||
<fa-icon icon="flask"></fa-icon>
|
||||
{{ $t("footer.savings.experimentalLabel") }}:
|
||||
</strong>
|
||||
{{ $t("footer.savings.experimentalText") }}
|
||||
<a
|
||||
href="https://github.com/evcc-io/evcc/discussions/2104"
|
||||
target="_blank"
|
||||
>GitHub Discussions</a
|
||||
>.
|
||||
</p>
|
||||
<Sponsor :sponsor="sponsor" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import formatter from "../mixins/formatter";
|
||||
import Sponsor from "./Sponsor.vue";
|
||||
import "@h2d2/shopicons/es/filled/square";
|
||||
import "@h2d2/shopicons/es/regular/sun";
|
||||
|
||||
export default {
|
||||
name: "Savings",
|
||||
|
|
@ -180,6 +190,9 @@ export default {
|
|||
percent() {
|
||||
return Math.round(this.selfConsumptionPercent) || 0;
|
||||
},
|
||||
noData() {
|
||||
return this.totalCharged === 0;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
secondsSinceStart() {
|
||||
|
|
@ -196,7 +209,7 @@ export default {
|
|||
margin-bottom: 0;
|
||||
}
|
||||
.modal.fade .modal-dialog {
|
||||
transition: transform 0.4s ease;
|
||||
transition: transform var(--evcc-transition-medium) ease;
|
||||
transform: translate(0, 150px);
|
||||
}
|
||||
.modal.show .modal-dialog {
|
||||
|
|
@ -206,12 +219,13 @@ export default {
|
|||
height: calc(100% - 0.5rem);
|
||||
}
|
||||
.modal-content {
|
||||
border-radius: 1rem 1rem 0 0;
|
||||
border-end-end-radius: 0;
|
||||
border-end-start-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.chart {
|
||||
height: 1.6rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.chart-item--self {
|
||||
|
|
@ -221,12 +235,12 @@ export default {
|
|||
background-color: var(--evcc-grid);
|
||||
}
|
||||
.chart-item--no-data {
|
||||
background-color: var(--bs-gray);
|
||||
background-color: var(--bs-gray-medium);
|
||||
}
|
||||
|
||||
.chart-item {
|
||||
transition-property: width;
|
||||
transition-duration: 500ms;
|
||||
transition-duration: var(--evcc-transition-medium);
|
||||
transition-timing-function: linear;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import Site from "./Site.vue";
|
||||
import i18n from "../i18n";
|
||||
|
||||
export default {
|
||||
title: "Main/Site",
|
||||
|
|
@ -7,11 +6,12 @@ export default {
|
|||
argTypes: {},
|
||||
};
|
||||
|
||||
const Template = (args, { argTypes }) => ({
|
||||
i18n,
|
||||
props: Object.keys(argTypes),
|
||||
const Template = (args) => ({
|
||||
setup() {
|
||||
return { args };
|
||||
},
|
||||
components: { Site },
|
||||
template: '<Site v-bind="$props"></Site>',
|
||||
template: '<Site v-bind="args"></Site>',
|
||||
});
|
||||
|
||||
export const Base = Template.bind({});
|
||||
|
|
|
|||
|
|
@ -1,37 +1,45 @@
|
|||
<template>
|
||||
<div class="flex-grow-1 d-flex flex-column">
|
||||
<h3 class="d-none d-md-block my-4">
|
||||
{{ siteTitle || "Home" }}
|
||||
</h3>
|
||||
<div class="d-flex flex-column site">
|
||||
<div class="container px-4 top-area">
|
||||
<div class="d-flex justify-content-between align-items-center my-3">
|
||||
<h1 class="d-block my-0">
|
||||
{{ siteTitle || "evcc" }}
|
||||
</h1>
|
||||
<div class="d-flex">
|
||||
<Notifications :notifications="notifications" class="me-2" />
|
||||
<TopNavigation v-bind="topNavigation" />
|
||||
</div>
|
||||
</div>
|
||||
<Energyflow v-bind="energyflow" />
|
||||
<hr class="w-100 my-4" />
|
||||
<div class="flex-grow-1 d-flex justify-content-around flex-column">
|
||||
<template v-for="(loadpoint, id) in loadpoints">
|
||||
<hr v-if="id > 0" :key="id + '_hr'" class="w-100 my-4" />
|
||||
<Loadpoint
|
||||
v-bind="loadpoint"
|
||||
:id="id"
|
||||
:key="id"
|
||||
:single="loadpoints.length === 1"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<div class="d-flex flex-column justify-content-between content-area pt-4">
|
||||
<Loadpoints class="mt-1 mt-sm-2 flex-grow-1" :loadpoints="loadpoints" />
|
||||
<Vehicles v-if="$hiddenFeatures" />
|
||||
<Footer v-bind="footer"></Footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Energyflow from "./Energyflow";
|
||||
import Loadpoint from "./Loadpoint";
|
||||
import "@h2d2/shopicons/es/regular/arrowup";
|
||||
import TopNavigation from "./TopNavigation.vue";
|
||||
import Notifications from "./Notifications.vue";
|
||||
import Energyflow from "./Energyflow/Energyflow.vue";
|
||||
import Loadpoints from "./Loadpoints.vue";
|
||||
import Vehicles from "./Vehicles.vue";
|
||||
import Footer from "./Footer.vue";
|
||||
import formatter from "../mixins/formatter";
|
||||
import collector from "../mixins/collector";
|
||||
|
||||
export default {
|
||||
name: "Site",
|
||||
components: { Loadpoint, Energyflow },
|
||||
components: { Loadpoints, Energyflow, Footer, Notifications, TopNavigation, Vehicles },
|
||||
mixins: [formatter, collector],
|
||||
props: {
|
||||
loadpoints: Array,
|
||||
|
||||
notifications: Array,
|
||||
|
||||
// details
|
||||
gridConfigured: Boolean,
|
||||
gridPower: Number,
|
||||
|
|
@ -44,6 +52,30 @@ export default {
|
|||
gridCurrents: Array,
|
||||
prioritySoC: Number,
|
||||
siteTitle: String,
|
||||
|
||||
auth: Object,
|
||||
|
||||
// footer
|
||||
currency: String,
|
||||
savingsAmount: Number,
|
||||
savingsEffectivePrice: Number,
|
||||
savingsGridCharged: Number,
|
||||
savingsSelfConsumptionCharged: Number,
|
||||
savingsSelfConsumptionPercent: Number,
|
||||
savingsSince: Number,
|
||||
savingsTotalCharged: Number,
|
||||
tariffFeedIn: Number,
|
||||
tariffGrid: Number,
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
availableVersion: null,
|
||||
releaseNotes: null,
|
||||
hasUpdater: null,
|
||||
uploadMessage: null,
|
||||
uploadProgress: null,
|
||||
sponsor: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
energyflow: function () {
|
||||
|
|
@ -58,6 +90,46 @@ export default {
|
|||
return sum;
|
||||
}, 0);
|
||||
},
|
||||
topNavigation: function () {
|
||||
const vehicleLogins = this.auth ? this.auth.vehicles : {};
|
||||
return { vehicleLogins };
|
||||
},
|
||||
footer: function () {
|
||||
return {
|
||||
version: {
|
||||
installed: window.evcc.version,
|
||||
available: this.availableVersion,
|
||||
releaseNotes: this.releaseNotes,
|
||||
hasUpdater: this.hasUpdater,
|
||||
uploadMessage: this.uploadMessage,
|
||||
uploadProgress: this.uploadProgress,
|
||||
},
|
||||
sponsor: this.sponsor,
|
||||
savings: {
|
||||
since: this.savingsSince,
|
||||
totalCharged: this.savingsTotalCharged,
|
||||
gridCharged: this.savingsGridCharged,
|
||||
selfConsumptionCharged: this.savingsSelfConsumptionCharged,
|
||||
amount: this.savingsAmount,
|
||||
effectivePrice: this.savingsEffectivePrice,
|
||||
selfConsumptionPercent: this.savingsSelfConsumptionPercent,
|
||||
gridPrice: this.tariffGrid,
|
||||
feedInPrice: this.tariffFeedIn,
|
||||
currency: this.currency,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
.site {
|
||||
min-height: 100vh;
|
||||
}
|
||||
.content-area {
|
||||
background-color: var(--bs-gray-dark);
|
||||
color: var(--bs-white);
|
||||
flex-grow: 1;
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
import Sponsor from "./Sponsor.vue";
|
||||
import i18n from "../i18n";
|
||||
|
||||
export default {
|
||||
title: "Main/Footer/Sponsor",
|
||||
component: Sponsor,
|
||||
argTypes: {},
|
||||
};
|
||||
|
||||
const Template = (args, { argTypes }) => ({
|
||||
i18n,
|
||||
props: Object.keys(argTypes),
|
||||
components: { Sponsor },
|
||||
template: '<Sponsor v-bind="$props"></Sponsor>',
|
||||
});
|
||||
|
||||
export const Standard = Template.bind({});
|
||||
Standard.args = {
|
||||
sponsor: undefined,
|
||||
};
|
||||
|
||||
export const IsSponsor = Template.bind({});
|
||||
IsSponsor.args = {
|
||||
sponsor: "naltatis",
|
||||
};
|
||||
|
|
@ -1,18 +1,18 @@
|
|||
<template>
|
||||
<div v-if="sponsor">
|
||||
<div v-if="sponsor || true">
|
||||
<p class="mb-3">
|
||||
{{ $t("footer.sponsor.thanks", { sponsor }) }}
|
||||
</p>
|
||||
<div class="d-flex justify-content-center flex-column">
|
||||
<div class="d-flex justify-content-center align-items-center flex-column">
|
||||
<button
|
||||
ref="confetti"
|
||||
class="btn btn-primary mb-2 confetti-button bg-evcc"
|
||||
class="btn btn btn-outline-primary mb-2 confetti-button bg-evcc w-75 rounded"
|
||||
@click="surprise"
|
||||
>
|
||||
<fa-icon :icon="['fas', 'heart']" class="icon me-1 solid"></fa-icon>
|
||||
<shopicon-regular-heart class="me-1 d-inline-block"></shopicon-regular-heart>
|
||||
{{ $t("footer.sponsor.confetti") }}
|
||||
</button>
|
||||
<a href="https://evcc.io/sticker" target="_blank" class="small text-muted text-center">
|
||||
<a href="https://evcc.io/sticker" target="_blank" class="small text-muted">
|
||||
{{ $t("footer.sponsor.sticker") }}
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -21,13 +21,13 @@
|
|||
<p class="mb-3">
|
||||
{{ $t("footer.sponsor.supportUs") }}
|
||||
</p>
|
||||
<div class="d-flex justify-content-center flex-column">
|
||||
<div class="d-flex justify-content-center align-items-center flex-column">
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://github.com/sponsors/andig"
|
||||
class="btn btn-outline-primary mb-2"
|
||||
class="btn btn-outline-primary mb-2 w-75"
|
||||
>
|
||||
<fa-icon :icon="['far', 'heart']" class="icon me-1"></fa-icon>
|
||||
<shopicon-regular-heart class="me-1 d-inline-block"></shopicon-regular-heart>
|
||||
{{ $t("footer.sponsor.becomeSponsor") }}
|
||||
</a>
|
||||
<div class="small text-muted text-center">
|
||||
|
|
@ -39,6 +39,8 @@
|
|||
|
||||
<script>
|
||||
import confetti from "canvas-confetti";
|
||||
import "@h2d2/shopicons/es/regular/heart";
|
||||
import "@h2d2/shopicons/es/filled/heart";
|
||||
|
||||
export default {
|
||||
name: "Sponsor",
|
||||
|
|
|
|||
|
|
@ -1,19 +1,30 @@
|
|||
<template>
|
||||
<div>
|
||||
<LabelAndValue class="flex-grow-1" :label="$t('main.targetCharge.title')">
|
||||
<button
|
||||
class="target-time-button btn btn-link btn-sm pe-0"
|
||||
:class="{
|
||||
invisible: !targetSoC,
|
||||
'text-dark': targetTimeActive,
|
||||
'text-secondary': !targetTimeActive,
|
||||
}"
|
||||
class="btn btn-link p-0 value text-end"
|
||||
:class="buttonIsEnabled ? 'text-white' : 'text-gray'"
|
||||
data-bs-toggle="modal"
|
||||
:data-bs-target="`#${modalId}`"
|
||||
>
|
||||
{{ targetTimeLabel() }}<fa-icon class="ms-1" icon="clock"></fa-icon>
|
||||
<strong v-if="targetChargeEnabled">{{ targetTimeLabel() }}</strong>
|
||||
<span v-else>{{ $t("main.targetCharge.setTargetTime") }}</span>
|
||||
</button>
|
||||
<div :id="modalId" class="modal fade" tabindex="-1" role="dialog" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable" role="document">
|
||||
</LabelAndValue>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
:id="modalId"
|
||||
class="modal fade text-dark"
|
||||
data-bs-backdrop="true"
|
||||
tabindex="-1"
|
||||
role="dialog"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
class="modal-dialog modal-dialog-centered modal-dialog-scrollable"
|
||||
role="document"
|
||||
>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
|
|
@ -30,11 +41,8 @@
|
|||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<label
|
||||
for="targetTimeLabel"
|
||||
class="mb-3"
|
||||
v-html="$t('main.targetCharge.description', { targetSoC })"
|
||||
>
|
||||
<label for="targetTimeLabel" class="mb-3">
|
||||
{{ $t("main.targetCharge.description", { targetSoC }) }}
|
||||
</label>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
<div
|
||||
|
|
@ -67,9 +75,8 @@
|
|||
<p v-if="!selectedTargetTimeValid" class="text-danger mb-0">
|
||||
{{ $t("main.targetCharge.targetIsInThePast") }}
|
||||
</p>
|
||||
<p class="small mt-3 text-muted">
|
||||
<strong class="text-primary">
|
||||
<fa-icon icon="flask"></fa-icon>
|
||||
<p class="small mt-3 text-muted mb-0">
|
||||
<strong class="text-evcc">
|
||||
{{ $t("main.targetCharge.experimentalLabel") }}:
|
||||
</strong>
|
||||
{{ $t("main.targetCharge.experimentalText") }}
|
||||
|
|
@ -102,10 +109,15 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import "@h2d2/shopicons/es/filled/plus";
|
||||
import "@h2d2/shopicons/es/filled/edit";
|
||||
import LabelAndValue from "./LabelAndValue.vue";
|
||||
|
||||
import formatter from "../mixins/formatter";
|
||||
|
||||
const DEFAULT_TARGET_TIME = "7:00";
|
||||
|
|
@ -113,13 +125,16 @@ const LAST_TARGET_TIME_KEY = "last_target_time";
|
|||
|
||||
export default {
|
||||
name: "TargetCharge",
|
||||
components: { LabelAndValue },
|
||||
mixins: [formatter],
|
||||
props: {
|
||||
id: Number,
|
||||
id: [String, Number],
|
||||
targetTime: String,
|
||||
targetTimeActive: Boolean,
|
||||
targetSoC: Number,
|
||||
disabled: Boolean,
|
||||
},
|
||||
emits: ["target-time-updated", "target-time-removed"],
|
||||
data: function () {
|
||||
return { selectedDay: null, selectedTime: null };
|
||||
},
|
||||
|
|
@ -127,6 +142,9 @@ export default {
|
|||
targetChargeEnabled: function () {
|
||||
return this.targetTime;
|
||||
},
|
||||
buttonIsEnabled: function () {
|
||||
return this.targetChargeEnabled && !this.disabled;
|
||||
},
|
||||
selectedTargetTimeValid: function () {
|
||||
const now = new Date();
|
||||
return now < this.selectedTargetTime;
|
||||
|
|
@ -139,6 +157,12 @@ export default {
|
|||
},
|
||||
},
|
||||
watch: {
|
||||
targetTimeLabel: function () {
|
||||
const targetDate = new Date(this.targetTime);
|
||||
return this.$t("main.targetCharge.activeLabel", {
|
||||
time: this.fmtAbsoluteDate(targetDate),
|
||||
});
|
||||
},
|
||||
targetTime() {
|
||||
this.initInputFields();
|
||||
},
|
||||
|
|
@ -223,3 +247,15 @@ export default {
|
|||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.value {
|
||||
font-size: 18px;
|
||||
line-height: 1.2;
|
||||
border: none;
|
||||
transition: color var(--evcc-transition-medium) linear;
|
||||
}
|
||||
.value:hover {
|
||||
color: var(--bs-color-white);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
53
assets/js/components/TopNavigation.stories.js
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import "bootstrap";
|
||||
import TopNavigation from "./TopNavigation.vue";
|
||||
|
||||
export default {
|
||||
title: "Main/TopNavigation",
|
||||
component: TopNavigation,
|
||||
parameters: {
|
||||
backgrounds: {
|
||||
default: "light",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const Template = (args) => ({
|
||||
setup() {
|
||||
return { args };
|
||||
},
|
||||
components: { TopNavigation },
|
||||
template: `<div class="d-flex justify-content-end" style="padding-bottom: 18rem">
|
||||
<TopNavigation v-bind="args"></TopNavigation>
|
||||
</div>`,
|
||||
});
|
||||
|
||||
export const Default = Template.bind({});
|
||||
Default.args = {};
|
||||
|
||||
export const VehicleLogins = Template.bind({});
|
||||
VehicleLogins.args = {
|
||||
vehicleLogins: {
|
||||
["Mercedes EQS"]: {
|
||||
authenticated: true,
|
||||
uri: "https://login-provider-a.test/",
|
||||
},
|
||||
["Nissan Leaf Pro"]: {
|
||||
authenticated: false,
|
||||
uri: "https://login-provider-b.test/",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const PendingVehicleLogins = Template.bind({});
|
||||
PendingVehicleLogins.args = {
|
||||
vehicleLogins: {
|
||||
["Mercedes EQS"]: {
|
||||
authenticated: true,
|
||||
uri: "https://login-provider-a.test/",
|
||||
},
|
||||
["Nissan Leaf Pro"]: {
|
||||
authenticated: true,
|
||||
uri: "https://login-provider-b.test/",
|
||||
},
|
||||
},
|
||||
};
|
||||
104
assets/js/components/TopNavigation.vue
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<template>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
data-bs-toggle="dropdown"
|
||||
data-bs-target="#navbarNavAltMarkup"
|
||||
aria-controls="navbarNavAltMarkup"
|
||||
aria-expanded="false"
|
||||
aria-label="Toggle navigation"
|
||||
class="btn btn-sm btn-outline-secondary position-relative"
|
||||
>
|
||||
<span
|
||||
v-if="logoutCount > 0"
|
||||
class="position-absolute top-0 start-100 translate-middle p-2 bg-danger border border-light rounded-circle"
|
||||
>
|
||||
<span class="visually-hidden">login available</span>
|
||||
</span>
|
||||
<shopicon-regular-menu></shopicon-regular-menu>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<a class="dropdown-item" href="https://docs.evcc.io/blog/" target="_blank">
|
||||
{{ $t("header.blog") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="https://docs.evcc.io/docs/Home/" target="_blank">
|
||||
{{ $t("header.docs") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="https://github.com/evcc-io/evcc" target="_blank">
|
||||
{{ $t("header.github") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="https://evcc.io/" target="_blank">
|
||||
{{ $t("header.about") }}
|
||||
</a>
|
||||
</li>
|
||||
<template v-if="providerLogins.length > 0">
|
||||
<li><hr class="dropdown-divider" /></li>
|
||||
<li>
|
||||
<h6 class="dropdown-header">{{ $t("header.login") }}</h6>
|
||||
</li>
|
||||
<li v-for="login in providerLogins" :key="login.title">
|
||||
<button
|
||||
type="button"
|
||||
class="dropdown-item"
|
||||
@click="handleProviderAuthorization(login)"
|
||||
>
|
||||
<span
|
||||
v-if="!login.loggedIn"
|
||||
class="d-inline-block p-1 rounded-circle bg-danger border border-light rounded-circle"
|
||||
></span>
|
||||
{{ login.title }}
|
||||
{{ $t(login.loggedIn ? "main.provider.logout" : "main.provider.login") }}
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import "@h2d2/shopicons/es/regular/menu";
|
||||
import baseAPI from "../baseapi";
|
||||
|
||||
export default {
|
||||
name: "TopNavigation",
|
||||
props: {
|
||||
vehicleLogins: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
return {};
|
||||
},
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
logoutCount() {
|
||||
return this.providerLogins.filter((login) => !login.loggedIn).length;
|
||||
},
|
||||
providerLogins() {
|
||||
return Object.entries(this.vehicleLogins).map(([k, v]) => ({
|
||||
title: k,
|
||||
loggedIn: v.authenticated,
|
||||
loginPath: v.uri + "/login",
|
||||
logoutPath: v.uri + "/logout",
|
||||
}));
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handleProviderAuthorization: async function (provider) {
|
||||
if (!provider.loggedIn) {
|
||||
baseAPI.post(provider.loginPath).then(function (response) {
|
||||
window.location.href = response.data.loginUri;
|
||||
});
|
||||
} else {
|
||||
baseAPI.post(provider.logoutPath);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
@ -1,16 +1,16 @@
|
|||
import Vehicle from "./Vehicle.vue";
|
||||
import i18n from "../i18n";
|
||||
|
||||
export default {
|
||||
title: "Main/Vehicle",
|
||||
component: Vehicle,
|
||||
};
|
||||
|
||||
const Template = (args, { argTypes }) => ({
|
||||
i18n,
|
||||
props: Object.keys(argTypes),
|
||||
const Template = (args) => ({
|
||||
setup() {
|
||||
return { args };
|
||||
},
|
||||
components: { Vehicle },
|
||||
template: '<Vehicle v-bind="$props"></Vehicle>',
|
||||
template: '<Vehicle v-bind="args"></Vehicle>',
|
||||
});
|
||||
|
||||
export const Base = Template.bind({});
|
||||
|
|
@ -20,6 +20,7 @@ Base.args = {
|
|||
connected: true,
|
||||
vehiclePresent: true,
|
||||
vehicleSoC: 42,
|
||||
vehicleRange: 231,
|
||||
targetSoC: 90,
|
||||
id: 0,
|
||||
};
|
||||
|
|
@ -58,7 +59,6 @@ Charging.args = {
|
|||
vehicleSoC: 66,
|
||||
targetSoC: 90,
|
||||
id: 0,
|
||||
targetTimeHourSuggestion: null,
|
||||
};
|
||||
|
||||
const hoursFromNow = function (hours) {
|
||||
|
|
@ -110,6 +110,38 @@ MinCharge.args = {
|
|||
id: 0,
|
||||
};
|
||||
|
||||
export const TimerPvEnable = Template.bind({});
|
||||
TimerPvEnable.args = {
|
||||
vehicleTitle: "Mein Auto",
|
||||
enabled: false,
|
||||
connected: true,
|
||||
vehiclePresent: true,
|
||||
vehicleSoC: 17,
|
||||
charging: false,
|
||||
targetSoC: 90,
|
||||
phaseAction: "inactive",
|
||||
phaseRemainingInterpolated: 0,
|
||||
pvAction: "enable",
|
||||
pvRemainingInterpolated: 32,
|
||||
id: 0,
|
||||
};
|
||||
|
||||
export const TimerPvDisable = Template.bind({});
|
||||
TimerPvDisable.args = {
|
||||
vehicleTitle: "Mein Auto",
|
||||
enabled: true,
|
||||
connected: true,
|
||||
vehiclePresent: true,
|
||||
vehicleSoC: 17,
|
||||
charging: true,
|
||||
targetSoC: 90,
|
||||
phaseAction: "inactive",
|
||||
phaseRemainingInterpolated: 0,
|
||||
pvAction: "disable",
|
||||
pvRemainingInterpolated: 155,
|
||||
id: 0,
|
||||
};
|
||||
|
||||
export const UnknownVehicleConnected = Template.bind({});
|
||||
UnknownVehicleConnected.args = {
|
||||
vehicleTitle: "Mein Auto",
|
||||
|
|
|
|||
|
|
@ -1,52 +1,118 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="mb-3">
|
||||
{{ vehicleTitle || $t("main.vehicle.fallbackName") }}
|
||||
</div>
|
||||
<VehicleSoc v-bind="vehicleSocProps" @target-soc-updated="targetSocUpdated" />
|
||||
<VehicleSubline
|
||||
v-bind="vehicleSubline"
|
||||
class="my-1"
|
||||
<div class="vehicle p-4 pb-3">
|
||||
<VehicleTitle v-bind="vehicleTitleProps" />
|
||||
<VehicleStatus v-if="!parked" v-bind="vehicleStatus" class="mb-2" />
|
||||
<VehicleSoc
|
||||
v-bind="vehicleSocProps"
|
||||
class="mt-2 mb-4"
|
||||
@target-soc-updated="targetSocUpdated"
|
||||
@target-soc-drag="targetSocDrag"
|
||||
/>
|
||||
<div v-if="vehiclePresent">
|
||||
<div class="details d-flex flex-wrap justify-content-between">
|
||||
<LabelAndValue
|
||||
class="flex-grow-1 text-start"
|
||||
:label="$t('main.vehicle.vehicleSoC')"
|
||||
:value="`${vehicleSoC || '--'} %`"
|
||||
:extraValue="vehicleRange ? `${vehicleRange} km` : null"
|
||||
/>
|
||||
<LabelAndValue
|
||||
class="flex-grow-1 text-center"
|
||||
:label="$t('main.vehicle.targetSoC')"
|
||||
:value="`${displayTargetSoC} %`"
|
||||
:extraValue="estimatedTargetRange"
|
||||
/>
|
||||
<TargetCharge
|
||||
class="flex-grow-1 text-end target-charge"
|
||||
v-bind="targetCharge"
|
||||
:disabled="targetChargeDisabled"
|
||||
@target-time-updated="setTargetTime"
|
||||
@target-time-removed="removeTargetTime"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="$hiddenFeatures" class="d-flex justify-content-start">
|
||||
<small>vor 5 Stunden</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import collector from "../mixins/collector";
|
||||
|
||||
import VehicleSoc from "./VehicleSoc";
|
||||
import VehicleSubline from "./VehicleSubline";
|
||||
import LabelAndValue from "./LabelAndValue.vue";
|
||||
import VehicleTitle from "./VehicleTitle.vue";
|
||||
import VehicleSoc from "./VehicleSoc.vue";
|
||||
import VehicleStatus from "./VehicleStatus.vue";
|
||||
import TargetCharge from "./TargetCharge.vue";
|
||||
|
||||
export default {
|
||||
name: "Vehicle",
|
||||
components: { VehicleSoc, VehicleSubline },
|
||||
components: { VehicleTitle, VehicleSoc, VehicleStatus, LabelAndValue, TargetCharge },
|
||||
mixins: [collector],
|
||||
props: {
|
||||
id: Number,
|
||||
id: [String, Number],
|
||||
connected: Boolean,
|
||||
vehiclePresent: Boolean,
|
||||
vehicleSoC: Number,
|
||||
enabled: Boolean,
|
||||
charging: Boolean,
|
||||
minSoC: Number,
|
||||
vehicleRange: Number,
|
||||
vehicleTitle: String,
|
||||
targetTimeActive: Boolean,
|
||||
targetTimeHourSuggestion: Number,
|
||||
targetTime: String,
|
||||
targetTimeProjectedStart: String,
|
||||
targetSoC: Number,
|
||||
mode: String,
|
||||
phaseAction: String,
|
||||
phaseRemainingInterpolated: Number,
|
||||
pvAction: String,
|
||||
pvRemainingInterpolated: Number,
|
||||
parked: Boolean,
|
||||
},
|
||||
emits: ["target-time-removed", "target-time-updated", "target-soc-updated"],
|
||||
data() {
|
||||
return {
|
||||
displayTargetSoC: this.targetSoC,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
vehicleSocProps: function () {
|
||||
return this.collectProps(VehicleSoc);
|
||||
},
|
||||
vehicleSubline: function () {
|
||||
return this.collectProps(VehicleSubline);
|
||||
vehicleStatus: function () {
|
||||
return this.collectProps(VehicleStatus);
|
||||
},
|
||||
vehicleTitleProps: function () {
|
||||
return this.collectProps(VehicleTitle);
|
||||
},
|
||||
targetCharge: function () {
|
||||
return this.collectProps(TargetCharge);
|
||||
},
|
||||
estimatedTargetRange: function () {
|
||||
if (this.vehicleSoC > 10 && this.vehicleRange && this.displayTargetSoC) {
|
||||
return (
|
||||
Math.round(this.displayTargetSoC * (this.vehicleRange / this.vehicleSoC)) +
|
||||
" km"
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
targetChargeDisabled: function () {
|
||||
return !this.connected || !["pv", "minpv"].includes(this.mode);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
targetSoC: function () {
|
||||
this.displayTargetSoC = this.targetSoC;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
targetSocDrag: function (targetSoC) {
|
||||
this.displayTargetSoC = targetSoC;
|
||||
},
|
||||
targetSocUpdated: function (targetSoC) {
|
||||
this.displayTargetSoC = targetSoC;
|
||||
this.$emit("target-soc-updated", targetSoC);
|
||||
},
|
||||
setTargetTime: function (targetTime) {
|
||||
|
|
@ -58,3 +124,18 @@ export default {
|
|||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vehicle {
|
||||
background-color: var(--bs-gray-dark);
|
||||
border-radius: 1rem;
|
||||
color: var(--bs-white);
|
||||
}
|
||||
.car-icon {
|
||||
width: 1.75rem;
|
||||
}
|
||||
.details > div {
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -2,39 +2,31 @@
|
|||
<div class="vehicle-soc">
|
||||
<div class="progress">
|
||||
<div
|
||||
v-if="connected || parked"
|
||||
class="progress-bar"
|
||||
role="progressbar"
|
||||
:class="{
|
||||
[progressColor]: true,
|
||||
'progress-bar-striped': charging,
|
||||
'progress-bar-animated': charging,
|
||||
[progressColor]: true,
|
||||
}"
|
||||
:style="{ width: `${vehicleSoCDisplayWidth}%` }"
|
||||
>
|
||||
{{ vehicleSoCDisplayValue }}
|
||||
</div>
|
||||
></div>
|
||||
<div
|
||||
v-if="remainingSoCWidth > 0 && enabled"
|
||||
class="progress-bar"
|
||||
v-if="remainingSoCWidth > 0 && enabled && connected"
|
||||
class="progress-bar bg-muted"
|
||||
role="progressbar"
|
||||
:class="{
|
||||
[progressColor]: true,
|
||||
'bg-muted': true,
|
||||
}"
|
||||
:class="progressColor"
|
||||
:style="{ width: `${remainingSoCWidth}%`, transition: 'none' }"
|
||||
></div>
|
||||
</div>
|
||||
<div
|
||||
class="target"
|
||||
:class="{ 'target--slider-hidden': allowSliderHiding && visibleTargetSoC === 100 }"
|
||||
>
|
||||
<div class="target">
|
||||
<div
|
||||
class="target-label d-flex align-items-center justify-content-center"
|
||||
:style="{ left: `${visibleTargetSoC}%` }"
|
||||
>
|
||||
{{ visibleTargetSoC }}%
|
||||
</div>
|
||||
></div>
|
||||
<input
|
||||
v-if="vehiclePresent && (connected || parked)"
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
|
|
@ -62,11 +54,12 @@ export default {
|
|||
charging: Boolean,
|
||||
minSoC: Number,
|
||||
targetSoC: Number,
|
||||
parked: Boolean,
|
||||
},
|
||||
emits: ["target-soc-drag", "target-soc-updated"],
|
||||
data: function () {
|
||||
return {
|
||||
selectedTargetSoC: null,
|
||||
allowSliderHiding: false,
|
||||
interactionStartScreenY: null,
|
||||
};
|
||||
},
|
||||
|
|
@ -77,31 +70,7 @@ export default {
|
|||
}
|
||||
return 100;
|
||||
},
|
||||
vehicleSoCDisplayValue: function () {
|
||||
// no soc or no soc value
|
||||
if (!this.vehiclePresent || !this.vehicleSoC || this.vehicleSoC < 0) {
|
||||
let chargeStatus = this.$t("main.vehicleSoC.disconnected");
|
||||
if (this.charging) {
|
||||
chargeStatus = this.$t("main.vehicleSoC.charging");
|
||||
} else if (this.enabled) {
|
||||
chargeStatus = this.$t("main.vehicleSoC.ready");
|
||||
} else if (this.connected) {
|
||||
chargeStatus = this.$t("main.vehicleSoC.connected");
|
||||
}
|
||||
return chargeStatus;
|
||||
}
|
||||
|
||||
// percent value if enough space
|
||||
let vehicleSoC = this.vehicleSoC;
|
||||
if (vehicleSoC >= 10) {
|
||||
vehicleSoC += "%";
|
||||
}
|
||||
return vehicleSoC;
|
||||
},
|
||||
progressColor: function () {
|
||||
if (!this.connected) {
|
||||
return "bg-light border";
|
||||
}
|
||||
if (this.minSoCActive) {
|
||||
return "bg-danger";
|
||||
}
|
||||
|
|
@ -131,11 +100,6 @@ export default {
|
|||
this.selectedTargetSoC = this.targetSoC;
|
||||
},
|
||||
},
|
||||
mounted: function () {
|
||||
setTimeout(() => {
|
||||
this.allowSliderHiding = true;
|
||||
}, 1000);
|
||||
},
|
||||
methods: {
|
||||
changeTargetSoCStart: function (e) {
|
||||
const screenY = e.screenY || e.changedTouches[0].screenY;
|
||||
|
|
@ -165,6 +129,8 @@ export default {
|
|||
return false;
|
||||
}
|
||||
this.selectedTargetSoC = e.target.value;
|
||||
|
||||
this.$emit("target-soc-drag", this.selectedTargetSoC);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
|
|
@ -172,10 +138,9 @@ export default {
|
|||
</script>
|
||||
<style scoped>
|
||||
.vehicle-soc {
|
||||
--height: 38px;
|
||||
--thumb-overlap: 3px;
|
||||
--thumb-width: 3px;
|
||||
--thumb-horizontal-padding: 15px;
|
||||
--height: 32px;
|
||||
--thumb-overlap: 6px;
|
||||
--thumb-width: 12px;
|
||||
--label-height: 26px;
|
||||
position: relative;
|
||||
height: var(--height);
|
||||
|
|
@ -199,16 +164,13 @@ export default {
|
|||
text-align: center;
|
||||
color: var(--bs-gray-dark);
|
||||
font-size: 1rem;
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s ease 1s;
|
||||
}
|
||||
.target-slider {
|
||||
-webkit-appearance: none;
|
||||
position: absolute;
|
||||
top: calc(var(--thumb-overlap) * -1);
|
||||
left: calc(var(--thumb-horizontal-padding) * -1);
|
||||
height: calc(100% + 2 * var(--thumb-overlap));
|
||||
width: calc(100% + 2 * var(--thumb-horizontal-padding));
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
.target-slider:focus {
|
||||
|
|
@ -231,50 +193,25 @@ export default {
|
|||
.target-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
position: relative;
|
||||
top: calc(var(--label-height) * -1);
|
||||
margin-left: var(--thumb-width) / 2;
|
||||
height: 100%;
|
||||
width: var(--thumb-width);
|
||||
padding: var(--label-height) var(--thumb-horizontal-padding) 0;
|
||||
box-sizing: content-box;
|
||||
background-clip: content-box;
|
||||
background-color: var(--bs-gray-dark);
|
||||
background-color: var(--evcc-dark-green);
|
||||
cursor: grab;
|
||||
border: none;
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s ease 1s;
|
||||
box-shadow: none;
|
||||
border-radius: var(--thumb-overlap);
|
||||
box-shadow: 0 0 6px var(--bs-gray-dark);
|
||||
}
|
||||
.target-slider::-moz-range-thumb {
|
||||
position: relative;
|
||||
top: calc(var(--label-height) * -1);
|
||||
height: 100%;
|
||||
width: var(--thumb-width);
|
||||
padding: 0 var(--thumb-horizontal-padding) 0;
|
||||
box-sizing: content-box;
|
||||
background-clip: content-box;
|
||||
background-color: var(--bs-gray-dark);
|
||||
background-color: var(--evcc-dark-green);
|
||||
cursor: grab;
|
||||
border: none;
|
||||
opacity: 1;
|
||||
transition: opacity 0.5s ease 1s;
|
||||
}
|
||||
/* auto-hide targetSoC marker at 100% */
|
||||
.target--slider-hidden .target-slider::-webkit-slider-thumb,
|
||||
.target--slider-hidden .target-label {
|
||||
opacity: 0;
|
||||
}
|
||||
.target--slider-hidden .target-slider::-moz-range-thumb,
|
||||
.target--slider-hidden .target-label {
|
||||
opacity: 0;
|
||||
}
|
||||
.target:hover .target-slider::-webkit-slider-thumb,
|
||||
.target:hover .target-label {
|
||||
opacity: 1;
|
||||
transition-delay: 0s;
|
||||
}
|
||||
.target:hover .target-slider::-moz-range-thumb,
|
||||
.target:hover .target-label {
|
||||
opacity: 1;
|
||||
transition-delay: 0s;
|
||||
border-radius: var(--thumb-overlap);
|
||||
box-shadow: 0 0 6px var(--bs-gray-dark);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
97
assets/js/components/VehicleStatus.vue
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
<template>
|
||||
<div class="d-block text-gray">{{ message }} </div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import collector from "../mixins/collector";
|
||||
import formatter from "../mixins/formatter";
|
||||
|
||||
export default {
|
||||
name: "VehicleStatus",
|
||||
mixins: [collector, formatter],
|
||||
props: {
|
||||
vehicleSoC: Number,
|
||||
minSoC: Number,
|
||||
enabled: Boolean,
|
||||
connected: Boolean,
|
||||
charging: Boolean,
|
||||
targetTime: String,
|
||||
targetTimeProjectedStart: String,
|
||||
phaseAction: String,
|
||||
phaseRemainingInterpolated: Number,
|
||||
pvAction: String,
|
||||
pvRemainingInterpolated: Number,
|
||||
},
|
||||
computed: {
|
||||
phaseTimerActive() {
|
||||
return (
|
||||
this.charging &&
|
||||
this.phaseRemainingInterpolated > 0 &&
|
||||
["scale1p", "scale3p"].includes(this.phaseAction)
|
||||
);
|
||||
},
|
||||
pvTimerActive() {
|
||||
return (
|
||||
this.pvRemainingInterpolated > 0 && ["enable", "disable"].includes(this.pvAction)
|
||||
);
|
||||
},
|
||||
message: function () {
|
||||
const t = (key, data) => {
|
||||
return this.$t(`main.vehicleStatus.${key}`, data);
|
||||
};
|
||||
|
||||
if (!this.connected) {
|
||||
return t("disconnected");
|
||||
}
|
||||
|
||||
// min charge active
|
||||
if (this.minSoC > 0 && this.vehicleSoC < this.minSoC) {
|
||||
return t("minCharge", { soc: this.minSoC });
|
||||
}
|
||||
|
||||
// target charage
|
||||
if (this.targetTime) {
|
||||
if (this.charging) {
|
||||
return t("targetChargeActive");
|
||||
}
|
||||
if (this.enabled) {
|
||||
return t("targetChargeWaitForVehicle");
|
||||
}
|
||||
if (this.targetTimeProjectedStart) {
|
||||
return t("targetChargePlanned", {
|
||||
time: this.fmtAbsoluteDate(new Date(this.targetTimeProjectedStart)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (this.pvTimerActive && !this.enabled && this.pvAction === "enable") {
|
||||
return t("pvEnable", {
|
||||
remaining: this.fmtShortDuration(this.pvRemainingInterpolated, true),
|
||||
});
|
||||
}
|
||||
|
||||
if (this.enabled && !this.charging) {
|
||||
return t("waitForVehicle");
|
||||
}
|
||||
|
||||
if (this.pvTimerActive && this.charging && this.pvAction === "disable") {
|
||||
return t("pvDisable", {
|
||||
remaining: this.fmtShortDuration(this.pvRemainingInterpolated, true),
|
||||
});
|
||||
}
|
||||
|
||||
if (this.phaseTimerActive) {
|
||||
return t(this.phaseAction, {
|
||||
remaining: this.fmtShortDuration(this.phaseRemainingInterpolated, true),
|
||||
});
|
||||
}
|
||||
|
||||
if (this.charging) {
|
||||
return t("charging");
|
||||
}
|
||||
|
||||
return t("connected");
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
<template>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<small class="text-secondary">
|
||||
<span v-if="minSoCActive">
|
||||
<fa-icon class="text-muted me-1" icon="exclamation-circle"></fa-icon>
|
||||
{{ $t("main.vehicleSubline.mincharge", { soc: minSoC }) }}
|
||||
</span>
|
||||
</small>
|
||||
<TargetCharge
|
||||
v-bind="targetCharge"
|
||||
@target-time-updated="setTargetTime"
|
||||
@target-time-removed="removeTargetTime"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import collector from "../mixins/collector";
|
||||
import TargetCharge from "./TargetCharge.vue";
|
||||
|
||||
export default {
|
||||
name: "VehicleSubline",
|
||||
components: { TargetCharge },
|
||||
mixins: [collector],
|
||||
props: {
|
||||
id: Number,
|
||||
vehicleSoC: Number,
|
||||
minSoC: Number,
|
||||
targetTime: String,
|
||||
targetTimeActive: Boolean,
|
||||
targetTimeHourSuggestion: Number,
|
||||
targetSoC: Number,
|
||||
},
|
||||
computed: {
|
||||
minSoCActive: function () {
|
||||
return this.minSoC > 0 && this.vehicleSoC < this.minSoC;
|
||||
},
|
||||
targetCharge: function () {
|
||||
return this.collectProps(TargetCharge);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
setTargetTime: function (targetTime) {
|
||||
this.$emit("target-time-updated", targetTime);
|
||||
},
|
||||
removeTargetTime: function () {
|
||||
this.$emit("target-time-removed");
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
44
assets/js/components/VehicleTitle.vue
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<template>
|
||||
<div class="d-flex justify-content-between mb-3 align-items-center">
|
||||
<h4 class="d-flex align-items-center m-0 flex-grow-1 overflow-hidden">
|
||||
<shopicon-regular-car3
|
||||
v-if="vehiclePresent || parked"
|
||||
class="me-2 flex-shrink-0 car-icon"
|
||||
></shopicon-regular-car3>
|
||||
<shopicon-regular-cablecharge
|
||||
v-else
|
||||
class="me-2 flex-shrink-0 car-icon"
|
||||
></shopicon-regular-cablecharge>
|
||||
<span class="flex-grow-1 text-truncate"> {{ name }} </span>
|
||||
</h4>
|
||||
<button v-if="$hiddenFeatures" class="btn btn-link text-white p-0 flex-shrink-0">
|
||||
<shopicon-filled-options size="s"></shopicon-filled-options>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import "@h2d2/shopicons/es/filled/options";
|
||||
import "@h2d2/shopicons/es/regular/car3";
|
||||
import "@h2d2/shopicons/es/regular/cablecharge";
|
||||
|
||||
import collector from "../mixins/collector";
|
||||
|
||||
export default {
|
||||
name: "VehicleTitle",
|
||||
mixins: [collector],
|
||||
props: {
|
||||
vehiclePresent: Boolean,
|
||||
vehicleTitle: String,
|
||||
parked: Boolean,
|
||||
},
|
||||
computed: {
|
||||
name() {
|
||||
if (this.vehiclePresent || this.parked) {
|
||||
return this.vehicleTitle || this.$t("main.vehicle.fallbackName");
|
||||
}
|
||||
return this.$t("main.vehicle.noVehicle");
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
104
assets/js/components/Vehicles.vue
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="container px-4 mb-3 mb-sm-4 d-flex justify-content-start align-items-center">
|
||||
<h2 class="m-0">{{ $t("main.vehicles") }}</h2>
|
||||
<button
|
||||
class="btn btn-link d-flex text-white p-2 ms-1 refresh"
|
||||
:class="{
|
||||
'refresh--in-progress': refreshing,
|
||||
}"
|
||||
:disabled="refreshing"
|
||||
@click="refresh"
|
||||
>
|
||||
<shopicon-regular-refresh class="refresh-icon"></shopicon-regular-refresh>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="container vehicles px-0 mb-5" :class="`vehicles-${vehicles.length}`">
|
||||
<Vehicle
|
||||
v-for="(vehicle, index) in vehicles"
|
||||
v-bind="vehicle"
|
||||
:id="`vehicle_${index}`"
|
||||
:key="index"
|
||||
class="vehicle"
|
||||
parked
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vehicle from "./Vehicle.vue";
|
||||
import collector from "../mixins/collector";
|
||||
import "@h2d2/shopicons/es/regular/refresh";
|
||||
|
||||
export default {
|
||||
name: "Vehicles",
|
||||
components: { Vehicle },
|
||||
mixins: [collector],
|
||||
|
||||
data() {
|
||||
return {
|
||||
refreshing: false,
|
||||
// TODO: mock data for development
|
||||
vehicles: [
|
||||
{
|
||||
vehiclePresent: true,
|
||||
vehicleTitle: "Ford Mustang Mach-E",
|
||||
vehicleSoC: 46,
|
||||
vehicleRange: 182,
|
||||
targetSoC: 100,
|
||||
},
|
||||
{
|
||||
vehiclePresent: true,
|
||||
vehicleTitle: "Renault Twingo Electric",
|
||||
vehicleSoC: 77,
|
||||
vehicleRange: 98,
|
||||
targetSoC: 90,
|
||||
},
|
||||
{
|
||||
vehiclePresent: true,
|
||||
vehicleTitle: "Blauer VW ID.4",
|
||||
vehicleSoC: 16,
|
||||
vehicleRange: 52,
|
||||
minSoC: 35,
|
||||
targetSoC: 60,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
refresh() {
|
||||
this.refreshing = true;
|
||||
// TODO: insert real implementation here
|
||||
window.setTimeout(() => {
|
||||
this.refreshing = false;
|
||||
}, 5000);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
.vehicles {
|
||||
display: grid;
|
||||
grid-gap: 2rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(310px, 1fr));
|
||||
}
|
||||
.vehicle {
|
||||
border: 4px solid white;
|
||||
}
|
||||
.refresh--in-progress {
|
||||
animation: rotation 1s infinite cubic-bezier(0.37, 0, 0.63, 1);
|
||||
}
|
||||
.refresh-icon {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
@keyframes rotation {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import Version from "./Version.vue";
|
||||
import i18n from "../i18n";
|
||||
|
||||
export default {
|
||||
title: "Main/Footer/Version",
|
||||
|
|
@ -7,11 +6,12 @@ export default {
|
|||
argTypes: {},
|
||||
};
|
||||
|
||||
const Template = (args, { argTypes }) => ({
|
||||
i18n,
|
||||
props: Object.keys(argTypes),
|
||||
const Template = (args) => ({
|
||||
setup() {
|
||||
return { args };
|
||||
},
|
||||
components: { Version },
|
||||
template: '<Version v-bind="$props"></Version>',
|
||||
template: '<Version v-bind="args"></Version>',
|
||||
});
|
||||
|
||||
const releaseNotes =
|
||||
|
|
|
|||
|
|
@ -4,23 +4,25 @@
|
|||
v-if="commit"
|
||||
:href="githubHashUrl"
|
||||
target="_blank"
|
||||
class="btn btn-link ps-0 text-decoration-none link-dark text-nowrap"
|
||||
class="btn btn-link ps-0 text-decoration-none text-white text-nowrap d-flex align-items-end"
|
||||
>
|
||||
<fa-icon icon="moon" class="me-2 text-muted"></fa-icon>v{{ installed }}
|
||||
<span class="text-muted">[{{ commit }}]</span>
|
||||
<shopicon-regular-moonstars class="me-2 text-gray-medium"></shopicon-regular-moonstars>
|
||||
v{{ installed }}
|
||||
<span class="ms-2 text-gray-medium">[{{ commit }}]</span>
|
||||
</a>
|
||||
<button
|
||||
v-else-if="newVersionAvailable"
|
||||
href="#"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#updateModal"
|
||||
class="btn btn-link ps-0 text-decoration-none link-dark text-nowrap"
|
||||
class="btn btn-link ps-0 text-decoration-none text-white text-nowrap d-flex align-items-end"
|
||||
>
|
||||
<fa-icon icon="gift" class="icon me-2"></fa-icon>v{{ installed }}
|
||||
<span class="d-none d-xs-inline d-sm-none text-muted text-decoration-underline">
|
||||
<shopicon-regular-gift class="me-2 text-evcc"></shopicon-regular-gift>
|
||||
v{{ installed }}
|
||||
<span class="ms-2 d-none d-xs-block d-sm-none text-decoration-underline">
|
||||
{{ $t("footer.version.availableShort") }}
|
||||
</span>
|
||||
<span class="d-none d-sm-inline text-muted text-decoration-underline">
|
||||
<span class="ms-2 d-none d-sm-block text-gray-medium text-decoration-underline">
|
||||
{{ $t("footer.version.availableLong") }}
|
||||
</span>
|
||||
</button>
|
||||
|
|
@ -28,17 +30,19 @@
|
|||
v-else
|
||||
:href="releaseNotesUrl(installed)"
|
||||
target="_blank"
|
||||
class="btn btn-link ps-0 text-decoration-none link-dark text-nowrap"
|
||||
class="btn btn-link text-white ps-0 text-decoration-none text-nowrap d-flex align-items-end"
|
||||
>
|
||||
<span class="d-inline d-xs-none d-sm-none">{{
|
||||
$t("footer.version.versionShort", { installed })
|
||||
}}</span>
|
||||
<span class="d-none d-xs-inline d-sm-inline">{{
|
||||
$t("footer.version.versionLong", { installed })
|
||||
}}</span>
|
||||
<Logo class="logo me-2" />
|
||||
v{{ installed }}
|
||||
</a>
|
||||
|
||||
<div id="updateModal" class="modal fade" tabindex="-1" role="dialog" aria-hidden="true">
|
||||
<div
|
||||
id="updateModal"
|
||||
class="modal fade text-dark"
|
||||
tabindex="-1"
|
||||
role="dialog"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
|
|
@ -119,10 +123,14 @@
|
|||
|
||||
<script>
|
||||
import api from "../api";
|
||||
import "../icons";
|
||||
import Logo from "./Logo.vue";
|
||||
|
||||
import "@h2d2/shopicons/es/regular/gift";
|
||||
import "@h2d2/shopicons/es/regular/moonstars";
|
||||
|
||||
export default {
|
||||
name: "Version",
|
||||
components: { Logo },
|
||||
props: {
|
||||
installed: String,
|
||||
available: String,
|
||||
|
|
@ -172,4 +180,8 @@ export default {
|
|||
.icon {
|
||||
color: var(--evcc-dark-green);
|
||||
}
|
||||
.logo {
|
||||
height: 1.1rem;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ export default {
|
|||
}
|
||||
}
|
||||
|
||||
.root >>> svg {
|
||||
.root ::v-deep(svg) {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
5
assets/js/featureflags.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export default {
|
||||
install: (app) => {
|
||||
app.config.globalProperties.$hiddenFeatures = window.localStorage["hidden_features"] === "true";
|
||||
},
|
||||
};
|
||||
|
|
@ -3,12 +3,11 @@ export default {
|
|||
docs: "Dokumentation",
|
||||
blog: "Blog",
|
||||
github: "GitHub",
|
||||
login: "Anmeldungen",
|
||||
login: "Fahrzeug Logins",
|
||||
about: "Über evcc",
|
||||
},
|
||||
footer: {
|
||||
version: {
|
||||
versionShort: "v{installed}",
|
||||
versionLong: "Version {installed}",
|
||||
availableShort: "Update",
|
||||
availableLong: "Update verfügbar",
|
||||
modalTitle: "Update verfügbar",
|
||||
|
|
@ -26,8 +25,8 @@ export default {
|
|||
savings: {
|
||||
footerShort: "{percent}% Sonne",
|
||||
footerLong: "{percent}% Sonnenenergie",
|
||||
modalTitleShort: "{total} kWh geladen · {percent}% Sonne",
|
||||
modalTitleLong: "{total} kWh geladen · {percent}% Sonnenenergie",
|
||||
modalTitlePartOne: "{total} kWh geladen",
|
||||
modalTitlePartTwo: "{percent}% Sonne",
|
||||
modalChartGrid: "Netz {grid} kWh",
|
||||
modalChartSelf: "Sonne {self} kWh",
|
||||
modalSavingsPrice: "Effektiver Energiepreis",
|
||||
|
|
@ -37,8 +36,6 @@ export default {
|
|||
modalExplainationFeedIn: "Einspeisung {feedInPrice}",
|
||||
modalServerStart: "Seit Serverstart {since}.",
|
||||
modalNoData: "noch nicht geladen",
|
||||
experimentalLabel: "experimentell",
|
||||
experimentalText: "Unplausible Werte? Fragen zur Ansicht? Meld dich gerne in den",
|
||||
},
|
||||
sponsor: {
|
||||
thanks: "Danke für deine Unterstützung, {sponsor}! Das hilft uns bei der Weiterentwicklung.",
|
||||
|
|
@ -54,7 +51,6 @@ export default {
|
|||
modalTitle: "Meldungen",
|
||||
dismissAll: "Meldungen entfernen",
|
||||
},
|
||||
|
||||
main: {
|
||||
energyflow: {
|
||||
noEnergy: "Kein Energiefluss",
|
||||
|
|
@ -69,21 +65,32 @@ export default {
|
|||
pvExport: "Einspeisung",
|
||||
},
|
||||
mode: {
|
||||
title: "Modus",
|
||||
stop: "Stop",
|
||||
now: "Sofort",
|
||||
offShort: "Aus",
|
||||
offLong: "Aus",
|
||||
nowShort: "Sofort",
|
||||
nowLong: "Sofort",
|
||||
minpvShort: "Min",
|
||||
minpvLong: "Min + PV",
|
||||
pvShort: "PV",
|
||||
pvLong: "Nur PV",
|
||||
pvLong: "PV",
|
||||
fast: "Schnell",
|
||||
cheap: "Günstig",
|
||||
},
|
||||
loadpoint: {
|
||||
fallbackName: "Ladepunkt",
|
||||
remoteDisabledSoft: "{source}: Adaptives PV-Laden deaktiviert",
|
||||
remoteDisabledHard: "{source}: Deaktiviert",
|
||||
power: "Leistung",
|
||||
charged: "Geladen",
|
||||
duration: "Dauer",
|
||||
remaining: "Restzeit",
|
||||
},
|
||||
vehicles: "Parkplatz",
|
||||
vehicle: {
|
||||
fallbackName: "Fahrzeug",
|
||||
vehicleSoC: "Ladestand",
|
||||
targetSoC: "Ladeziel",
|
||||
noVehicle: "Kein Fahrzeug",
|
||||
},
|
||||
vehicleSoC: {
|
||||
disconnected: "getrennt",
|
||||
|
|
@ -91,51 +98,42 @@ export default {
|
|||
ready: "bereit",
|
||||
connected: "verbunden",
|
||||
},
|
||||
vehicleSubline: {
|
||||
mincharge: "Mindestladung bis {soc}%",
|
||||
vehicleStatus: {
|
||||
minCharge: "Mindestladung bis {soc}%.",
|
||||
waitForVehicle: "Ladebereit. Warte auf Fahrzeug.",
|
||||
charging: "Ladevorgang aktiv.",
|
||||
targetChargePlanned: "Zielladen geplant. Ladung startet {time} Uhr.",
|
||||
targetChargeWaitForVehicle: "Zielladen bereit. Warte auf Fahrzeug.",
|
||||
targetChargeActive: "Zielladen aktiv.",
|
||||
connected: "Verbunden.",
|
||||
pvDisable: "Zu wenig Überschuss. Pausiere in {remaining}.",
|
||||
pvEnable: "Überschuss verfügbar. Starte in {remaining}.",
|
||||
scale1p: "Reduziere auf einphasig in {remaining}.",
|
||||
scale3p: "Erhöhe auf dreiphasig in {remaining}.",
|
||||
disconnected: "Nicht verbunden.",
|
||||
unknown: "",
|
||||
},
|
||||
provider: {
|
||||
login: "anmelden",
|
||||
logout: "abmelden",
|
||||
},
|
||||
targetCharge: {
|
||||
title: "Zielzeit",
|
||||
inactiveLabel: "Zielzeit",
|
||||
activeLabel: "bis {time} Uhr",
|
||||
activeLabel: "{time}",
|
||||
modalTitle: "Zielzeit festlegen",
|
||||
description: "Wann soll das Fahrzeug auf <strong>{targetSoC}%</strong> geladen sein?",
|
||||
setTargetTime: "keine",
|
||||
description: "Wann soll das Fahrzeug auf {targetSoC}% geladen sein?",
|
||||
today: "heute",
|
||||
tomorrow: "morgen",
|
||||
targetIsInThePast: "Zeitpunkt liegt in der Vergangenheit.",
|
||||
remove: "Keine Zielzeit",
|
||||
activate: "Zielzeit aktivieren",
|
||||
experimentalLabel: "experimentell",
|
||||
remove: "Entfernen",
|
||||
activate: "Aktivieren",
|
||||
experimentalLabel: "Experimentell",
|
||||
experimentalText: `
|
||||
Diese Funktion ist in einem frühen Stadium. Der Algorithmus ist noch
|
||||
nicht perfekt. Die Zielzeit wird aktuell nicht persistiert - das
|
||||
heißt sie geht beim Neustart verloren. Verlasse dich also noch nicht
|
||||
zu sehr auf diese Funktion. Wir freuen uns aber über deine
|
||||
Erfahrungen und Verbessungsvorschläge in den
|
||||
Dieses Feature funktioniert, ist aber noch nicht perfekt.
|
||||
Bitte melde unerwartetes Verhalten in unseren
|
||||
`,
|
||||
},
|
||||
loadpointDetails: {
|
||||
power: "Leistung",
|
||||
vehicleRange: "Reichweite",
|
||||
charged: "Geladen",
|
||||
duration: "Dauer",
|
||||
remaining: "Restzeit",
|
||||
tooltip: {
|
||||
phases: {
|
||||
scale1p: "Herunterschalten auf einphasig in {remaining}.",
|
||||
scale3p: "Hochschalten auf dreiphasig in {remaining}.",
|
||||
charge1p: "Lädt einphasig.",
|
||||
charge2p: "Lädt zweiphasig.",
|
||||
charge3p: "Lädt dreiphasig.",
|
||||
},
|
||||
pv: {
|
||||
enable: "Überschuss verfügbar. Ladung startet in {remaining}.",
|
||||
disable: "Zu wenig Überschuss. Ladung pausiert in {remaining}.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,12 +3,11 @@ export default {
|
|||
docs: "Documentation",
|
||||
blog: "Blog",
|
||||
github: "GitHub",
|
||||
login: "Logins",
|
||||
login: "Vehicle Logins",
|
||||
about: "About evcc",
|
||||
},
|
||||
footer: {
|
||||
version: {
|
||||
versionShort: "v{installed}",
|
||||
versionLong: "Version {installed}",
|
||||
availableShort: "update",
|
||||
availableLong: "update available",
|
||||
modalTitle: "Update available",
|
||||
|
|
@ -26,10 +25,10 @@ export default {
|
|||
savings: {
|
||||
footerShort: "{percent}% solar",
|
||||
footerLong: "{percent}% solar energy",
|
||||
modalTitleShort: "{total} kWh charged · {percent}% solar",
|
||||
modalTitleLong: "{total} kWh charged · {percent}% solar energy",
|
||||
modalChartGrid: "Grid energy {grid} kWh",
|
||||
modalChartSelf: "Solar energy {self} kWh",
|
||||
modalTitlePartOne: "{total} kWh charged",
|
||||
modalTitlePartTwo: "{percent}% solar",
|
||||
modalChartGrid: "Grid {grid} kWh",
|
||||
modalChartSelf: "Solar {self} kWh",
|
||||
modalSavingsPrice: "Effective energy price",
|
||||
modalSavingsTotal: "Savings compared to grid",
|
||||
modalExplaination: "Calculation",
|
||||
|
|
@ -37,8 +36,6 @@ export default {
|
|||
modalExplainationFeedIn: "feed-in rate {feedInPrice}",
|
||||
modalServerStart: "since server start {since}.",
|
||||
modalNoData: "nothing charged yet",
|
||||
experimentalLabel: "experimental",
|
||||
experimentalText: "Implausible values? Questions about this view? Feel free to join our ",
|
||||
},
|
||||
sponsor: {
|
||||
thanks: "Thanks for your support, {sponsor}! It helps us with the further development.",
|
||||
|
|
@ -58,8 +55,8 @@ export default {
|
|||
energyflow: {
|
||||
noEnergy: "No Energyflow",
|
||||
homePower: "Consumption",
|
||||
loadpoints: "Loadpoint | Loadpoint | {count} Loadpoints",
|
||||
pvProduction: "Production",
|
||||
loadpoints: "Loadpoint | Loadpoint | {count} Loadpoints",
|
||||
battery: "Battery",
|
||||
batteryCharge: "Battery charge",
|
||||
batteryDischarge: "Battery discharge",
|
||||
|
|
@ -68,21 +65,32 @@ export default {
|
|||
pvExport: "Grid export",
|
||||
},
|
||||
mode: {
|
||||
title: "Mode",
|
||||
stop: "Stop",
|
||||
now: "Now",
|
||||
offShort: "Stop",
|
||||
offLong: "Stop",
|
||||
nowShort: "Now",
|
||||
nowLong: "Now",
|
||||
minpvShort: "Min",
|
||||
minpvLong: "Min + PV",
|
||||
pvShort: "PV",
|
||||
pvLong: "PV only",
|
||||
pvLong: "PV",
|
||||
fast: "Fast",
|
||||
cheap: "Cheap",
|
||||
},
|
||||
loadpoint: {
|
||||
fallbackName: "Loadpoint",
|
||||
remoteDisabledSoft: "{source}: adaptive PV charging disabled",
|
||||
remoteDisabledHard: "{source}: disabled",
|
||||
power: "Power",
|
||||
charged: "Charged",
|
||||
duration: "Duration",
|
||||
remaining: "Remaining",
|
||||
},
|
||||
vehicles: "Parking",
|
||||
vehicle: {
|
||||
fallbackName: "Vehicle",
|
||||
vehicleSoC: "SoC",
|
||||
targetSoC: "Limit",
|
||||
noVehicle: "No Vehicle",
|
||||
},
|
||||
vehicleSoC: {
|
||||
disconnected: "disconnected",
|
||||
|
|
@ -90,51 +98,42 @@ export default {
|
|||
ready: "ready",
|
||||
connected: "connected",
|
||||
},
|
||||
vehicleSubline: {
|
||||
mincharge: "minimum charging to {soc}%",
|
||||
vehicleStatus: {
|
||||
minCharge: "minimum charging to {soc}%.",
|
||||
waitForVehicle: "Ready. Waiting for vehicle.",
|
||||
charging: "Charging.",
|
||||
targetChargePlanned: "Target charge planned. Starting {start}.",
|
||||
targetChargeWaitForVehicle: "Target charge ready. Wait for vehicle.",
|
||||
targetChargeActive: "Target charge active.",
|
||||
connected: "Connected.",
|
||||
pvDisable: "Not enough surplus. Pausing in {remaining}.",
|
||||
pvEnable: "Surplus available. Starging in {remaining}.",
|
||||
scale1p: "Reduce to single phase in {remaining}.",
|
||||
scale3p: "Increase to three phase in {remaining}.",
|
||||
disconnected: "Disconnected.",
|
||||
unknown: "",
|
||||
},
|
||||
provider: {
|
||||
login: "login",
|
||||
logout: "logout",
|
||||
},
|
||||
targetCharge: {
|
||||
title: "Target Time",
|
||||
inactiveLabel: "Target time",
|
||||
activeLabel: "until {time}",
|
||||
activeLabel: "{time}",
|
||||
modalTitle: "Set Target Time",
|
||||
description: "When should the vehicle be charged to <strong>{targetSoC}%</strong>?",
|
||||
setTargetTime: "none",
|
||||
description: "When should the vehicle be charged to {targetSoC}%?",
|
||||
today: "today",
|
||||
tomorrow: "tomorrow",
|
||||
targetIsInThePast: "The chosen time is in the past.",
|
||||
remove: "Remove",
|
||||
activate: "Activate",
|
||||
experimentalLabel: "experimental",
|
||||
experimentalLabel: "Experimental",
|
||||
experimentalText: `
|
||||
This function is at an early stage.
|
||||
The algorithm is not perfect yet.
|
||||
The target time is currently not persisted - this means, it will be lost when your server restarts.
|
||||
So do not rely too much on this function.
|
||||
However, we look forward to your experiences and suggestions for improvement in the
|
||||
This feature works but isn't perfect yet.
|
||||
Please report unexpected behaviour in our
|
||||
`,
|
||||
},
|
||||
loadpointDetails: {
|
||||
power: "Power",
|
||||
vehicleRange: "Range",
|
||||
charged: "Charged",
|
||||
duration: "Duration",
|
||||
remaining: "Remaining",
|
||||
tooltip: {
|
||||
phases: {
|
||||
scale1p: "Switching to single-phase in {remaining}.",
|
||||
scale3p: "Switching to three-phase in {remaining}.",
|
||||
charge1p: "Single-phase charging.",
|
||||
charge2p: "Two-phase charging.",
|
||||
charge3p: "Three-phase charging.",
|
||||
},
|
||||
pv: {
|
||||
enable: "Solar available. Resume charging in {remaining}.",
|
||||
disable: "Not enough solar. Pause charging in {remaining}.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,12 +1,9 @@
|
|||
import Vue from "vue";
|
||||
import VueI18n from "vue-i18n";
|
||||
import { createI18n } from "vue-i18n/index";
|
||||
import de from "./de";
|
||||
import en from "./en";
|
||||
import it from "./it";
|
||||
import lt from "./lt";
|
||||
|
||||
Vue.use(VueI18n);
|
||||
|
||||
const PREFERRED_LOCALE_KEY = "preferred_locale";
|
||||
|
||||
function getBrowserLocale() {
|
||||
|
|
@ -15,11 +12,10 @@ function getBrowserLocale() {
|
|||
if (!navigatorLocale) {
|
||||
return undefined;
|
||||
}
|
||||
const trimmedLocale = navigatorLocale.trim().split(/-|_/)[0];
|
||||
return trimmedLocale;
|
||||
return navigatorLocale.trim().split(/-|_/)[0];
|
||||
}
|
||||
|
||||
export default new VueI18n({
|
||||
export default createI18n({
|
||||
locale: window.localStorage[PREFERRED_LOCALE_KEY] || getBrowserLocale(),
|
||||
fallbackLocale: "en",
|
||||
messages: { de, en, it, lt },
|
||||
|
|
|
|||
|
|
@ -1,17 +1,16 @@
|
|||
export default {
|
||||
header: {
|
||||
docs: "Dokumentacija",
|
||||
docs: "Dokumentacija (Vokiečių k.)",
|
||||
blog: "Tinklaraštis",
|
||||
github: "GitHub",
|
||||
login: "Prisijungimas",
|
||||
login: " Automobilių prisijungimai",
|
||||
about: "Apie EVCC",
|
||||
},
|
||||
footer: {
|
||||
version: {
|
||||
versionShort: "v{installed}",
|
||||
versionLong: "Versija {installed}",
|
||||
availableShort: "Naujinimas",
|
||||
availableLong: "Prieinama naujesnė versija",
|
||||
modalTitle: "Prieinama naujesnė versija",
|
||||
availableLong: "Yra naujesnė versija",
|
||||
modalTitle: "Yra naujesnė versija",
|
||||
modalUpdateStarted: "Pasibaigus naujinimui EVCC startuos iš naujo..",
|
||||
modalInstalledVersion: "Dabartinė versija",
|
||||
modalNoReleaseNotes: "Naujinimo detalių nėra. Daugiau informacijos rasite čia:",
|
||||
|
|
@ -25,8 +24,8 @@ export default {
|
|||
savings: {
|
||||
footerShort: "{percent}% saulės",
|
||||
footerLong: "{percent}% saulės energija",
|
||||
modalTitleShort: "{total} kWh įkrauta · {percent}% saulės",
|
||||
modalTitleLong: "{total} kWh įkrauta · {percent}% saulės energija",
|
||||
modalTitlePartOne: "{total} kWh įkrauta",
|
||||
modalTitlePartTwo: "{percent}% saulės",
|
||||
modalChartGrid: "Energija iš tinklo {grid} kWh",
|
||||
modalChartSelf: "Saulės energija {self} kWh",
|
||||
modalSavingsPrice: "Faktinė energijos kaina",
|
||||
|
|
@ -36,18 +35,15 @@ export default {
|
|||
modalExplainationFeedIn: "Kompensavimas už patiektą į tinklą energiją {feedInPrice}",
|
||||
modalServerStart: "nuo EVCC starto {since}.",
|
||||
modalNoData: "įkrovimo duomenų dar nėra",
|
||||
experimentalLabel: "eksperimentinis",
|
||||
experimentalText:
|
||||
"Neteisingi duomenys? Turite klausimų apie šiuos skaičiavimus? Prisijunkite prie mūsų",
|
||||
},
|
||||
sponsor: {
|
||||
thanks: "Ačiū, kad mus remiate {sponsor}! Tuo prisidedate prie projekto vystymo.",
|
||||
thanks: "Ačiū, kad mus remiate {sponsor}! Taip prisidedate prie projekto vystymo.",
|
||||
confetti: "Ar pasiruošę priimti rėmėjo konfeti?",
|
||||
supportUs:
|
||||
"Norime, kad efektyvesnis elektromobilių įkrovimas taptų pasiekiamas kuo daugiau žmonių. Tapdami rėmėjais prisidedate prie nuolatinio projekto vystymo ir palaikymo.",
|
||||
sticker: "... ar evcc lipdukų?",
|
||||
confettiPromise: "Gausite skaitmeninių lipdukų ir konfeti ;)",
|
||||
becomeSponsor: "Taptkite GitHub rėmėju!",
|
||||
"Norime, kad efektyvesnis elektromobilių įkrovimas saulės energija taptų pasiekiamas kuo daugiau žmonių. Remdami prisidedate prie nuolatinio projekto vystymo ir techninio palaikymo.",
|
||||
sticker: "... ar EVCC lipdukų?",
|
||||
confettiPromise: "Gausite lipdukų ir skaitmeninių konfeti ;)",
|
||||
becomeSponsor: "Pradėkite GitHub rėmimą!",
|
||||
},
|
||||
},
|
||||
notifications: {
|
||||
|
|
@ -58,8 +54,8 @@ export default {
|
|||
energyflow: {
|
||||
noEnergy: "Energija neteka",
|
||||
homePower: "Namo suvartojimas",
|
||||
loadpoints: "Įkroviklis | Įkroviklis | {count} Įkrovikliai",
|
||||
pvProduction: "Gamyba",
|
||||
loadpoints: "Įkroviklis | Įkroviklis | {count} Įkrovikliai",
|
||||
battery: "Baterija",
|
||||
batteryCharge: "Baterijos įkrovimas",
|
||||
batteryDischarge: "Baterijos iškrovimas",
|
||||
|
|
@ -68,73 +64,75 @@ export default {
|
|||
pvExport: "Tinklo eksportas",
|
||||
},
|
||||
mode: {
|
||||
title: "Darbo režimas",
|
||||
stop: "Stop",
|
||||
now: "Dabar",
|
||||
offShort: "Stop",
|
||||
offLong: "Stop",
|
||||
nowShort: "Dabar",
|
||||
nowLong: "Dabar",
|
||||
minpvShort: "Min",
|
||||
minpvLong: "Min + PV",
|
||||
pvShort: "PV",
|
||||
pvLong: "Tik PV",
|
||||
pvLong: "PV",
|
||||
fast: "Greitai",
|
||||
cheap: "Pigiai",
|
||||
},
|
||||
loadpoint: {
|
||||
fallbackName: "Įkroviklis",
|
||||
remoteDisabledSoft: "{source}: adaptyvus PV įkrovimas išjungtas",
|
||||
remoteDisabledHard: "{source}: Išjungtas",
|
||||
remoteDisabledHard: "{source}: išjungtas",
|
||||
power: "Galia",
|
||||
charged: "Įkrauta",
|
||||
duration: "Trukmė",
|
||||
remaining: "Liko",
|
||||
},
|
||||
vehicles: "Autoparkas",
|
||||
vehicle: {
|
||||
fallbackName: "Automobilis",
|
||||
vehicleSoC: "Įkrova",
|
||||
targetSoC: "Limitas",
|
||||
noVehicle: "Nėra automobilio",
|
||||
},
|
||||
vehicleSoC: {
|
||||
disconnected: "neprijungtas",
|
||||
charging: "vyksta įkrovimas",
|
||||
ready: "leidžiama įkrauti",
|
||||
connected: "prijungtas",
|
||||
connected: "automobilis prijungtas",
|
||||
},
|
||||
vehicleSubline: {
|
||||
mincharge: "minimalus įkrovimas iki {soc}%",
|
||||
vehicleStatus: {
|
||||
minCharge: "minimalus įkrovimas iki {soc}%.",
|
||||
waitForVehicle: "Įkrovimas leidžiamas. Laukiama automobilio signalo.",
|
||||
charging: "Įkraunama.",
|
||||
targetChargePlanned: "Suplanuotas įkrovimas, prasidės {start}.",
|
||||
targetChargeWaitForVehicle: "Suplanuotas įkrovimas leidžiamas. Laukiama automobilio signalo.",
|
||||
targetChargeActive: "Suplanuotas įkrovimas aktyvuotas.",
|
||||
connected: "Prijungtas.",
|
||||
pvDisable: "Trūksta saulės, įkrovimo pauzė už {remaining}.",
|
||||
pvEnable: "Saulės užtenka, įkrovimas prasidės už {remaining}.",
|
||||
scale1p: "Sumažinti į vienfazį įkrovimą už {remaining}.",
|
||||
scale3p: "Padidinti į trifazį įkrovimą už {remaining}.",
|
||||
disconnected: "Neprijungtas.",
|
||||
unknown: "",
|
||||
},
|
||||
provider: {
|
||||
login: "prisijungti",
|
||||
logout: "atsijungti",
|
||||
},
|
||||
targetCharge: {
|
||||
inactiveLabel: "Suplanuota įkrovimo pabaiga",
|
||||
activeLabel: "įkrauti iki {time}",
|
||||
title: "Įkrauti iki",
|
||||
inactiveLabel: "Įkrauti iki",
|
||||
activeLabel: "{time}",
|
||||
modalTitle: "Nustatyti įkrovimo pabaigos laiką",
|
||||
description: "Kada automobilis turėtų būti įkrautas iki <strong>{targetSoC}%</strong>?",
|
||||
setTargetTime: "nenustatytas",
|
||||
description: "Kada automobilis turėtų būti įkrautas iki {targetSoC}%?",
|
||||
today: "šiandien",
|
||||
tomorrow: "rytoj",
|
||||
targetIsInThePast: "Pasirinktas laikas yra praeityje.",
|
||||
remove: "Panaikinti",
|
||||
activate: "Aktyvuoti",
|
||||
experimentalLabel: "eksperimentinis",
|
||||
experimentalLabel: "Eksperimentinis",
|
||||
experimentalText: `
|
||||
Ši funkcija yra ankstyvoje integravimo stadijoje.
|
||||
Algoritmas kol kas nėra tobulas.
|
||||
Nustatytas laikas neišsaugojamas - neišlieka po serverio restarto.
|
||||
Dėl šių priežasčių pernelyg nepasitikėkite šia eksperimentine funkcija.
|
||||
Visgi, norėtume gauti atsiliepimų apie jos veikimą bei pasiūlymų tobulinimui mūsų
|
||||
Ši funkcija veikia, bet dar nėra tobula.
|
||||
Apie netikėtą elgesį praneškite mūsų
|
||||
`,
|
||||
},
|
||||
loadpointDetails: {
|
||||
power: "Galia",
|
||||
vehicleRange: "Apytikris nuvažiuojamas atstumas",
|
||||
charged: "Įkrauta",
|
||||
duration: "Įkrovimas vyksta",
|
||||
remaining: "Pabaiga už",
|
||||
tooltip: {
|
||||
phases: {
|
||||
scale1p: "Perjungimas į vienfazį įkrovimą už {remaining}.",
|
||||
scale3p: "Perjungimas į trifazį įkrovimą už {remaining}.",
|
||||
charge1p: "Vienfazis įkrovimas.",
|
||||
charge2p: "Dvifazis įkrovimas.",
|
||||
charge3p: "Trifazis įkrovimas.",
|
||||
},
|
||||
pv: {
|
||||
enable: "Saulės energijos pakanka, įkrovimas tęsiamas už {remaining}.",
|
||||
disable: "Nepakanka saulės energijos, įkrovimo pauzė už {remaining}.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
import Vue from "vue";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { library } from "@fortawesome/fontawesome-svg-core";
|
||||
|
||||
import { faBatteryEmpty } from "@fortawesome/free-solid-svg-icons/faBatteryEmpty";
|
||||
import { faBatteryFull } from "@fortawesome/free-solid-svg-icons/faBatteryFull";
|
||||
import { faBatteryHalf } from "@fortawesome/free-solid-svg-icons/faBatteryHalf";
|
||||
import { faBatteryQuarter } from "@fortawesome/free-solid-svg-icons/faBatteryQuarter";
|
||||
import { faBatteryThreeQuarters } from "@fortawesome/free-solid-svg-icons/faBatteryThreeQuarters";
|
||||
import { faAngleUp } from "@fortawesome/free-solid-svg-icons/faAngleUp";
|
||||
import { faAngleDown } from "@fortawesome/free-solid-svg-icons/faAngleDown";
|
||||
import { faClock } from "@fortawesome/free-solid-svg-icons/faClock";
|
||||
import { faMoon } from "@fortawesome/free-solid-svg-icons/faMoon";
|
||||
import { faExclamationTriangle } from "@fortawesome/free-solid-svg-icons/faExclamationTriangle";
|
||||
import { faSun } from "@fortawesome/free-solid-svg-icons/faSun";
|
||||
import { faSun as farSun } from "@fortawesome/free-regular-svg-icons/faSun";
|
||||
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons/faInfoCircle";
|
||||
import { faFlask } from "@fortawesome/free-solid-svg-icons/faFlask";
|
||||
import { faTemperatureHigh } from "@fortawesome/free-solid-svg-icons/faTemperatureHigh";
|
||||
import { faTemperatureLow } from "@fortawesome/free-solid-svg-icons/faTemperatureLow";
|
||||
import { faThermometerHalf } from "@fortawesome/free-solid-svg-icons/faThermometerHalf";
|
||||
import { faHeart as farHeart } from "@fortawesome/free-regular-svg-icons/faHeart";
|
||||
import { faHeart as fasHeart } from "@fortawesome/free-solid-svg-icons/faHeart";
|
||||
import { faGift } from "@fortawesome/free-solid-svg-icons/faGift";
|
||||
import { faBox } from "@fortawesome/free-solid-svg-icons/faBox";
|
||||
import { faHome } from "@fortawesome/free-solid-svg-icons/faHome";
|
||||
import { faWrench } from "@fortawesome/free-solid-svg-icons/faWrench";
|
||||
import { faCar } from "@fortawesome/free-solid-svg-icons/faCar";
|
||||
import { faSquare } from "@fortawesome/free-solid-svg-icons/faSquare";
|
||||
import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons/faExclamationCircle";
|
||||
import { faAngleDoubleLeft } from "@fortawesome/free-solid-svg-icons/faAngleDoubleLeft";
|
||||
import { faAngleDoubleRight } from "@fortawesome/free-solid-svg-icons/faAngleDoubleRight";
|
||||
|
||||
library.add(
|
||||
faAngleDown,
|
||||
faAngleUp,
|
||||
faBatteryEmpty,
|
||||
faBatteryFull,
|
||||
faBatteryHalf,
|
||||
faBatteryQuarter,
|
||||
faBatteryThreeQuarters,
|
||||
faBox,
|
||||
faCar,
|
||||
faAngleDoubleLeft,
|
||||
faAngleDoubleRight,
|
||||
faClock,
|
||||
faMoon,
|
||||
faExclamationCircle,
|
||||
faExclamationTriangle,
|
||||
faGift,
|
||||
faHome,
|
||||
faWrench,
|
||||
farHeart,
|
||||
fasHeart,
|
||||
faSquare,
|
||||
faSun,
|
||||
farSun,
|
||||
faInfoCircle,
|
||||
faFlask,
|
||||
faTemperatureHigh,
|
||||
faTemperatureLow,
|
||||
faThermometerHalf
|
||||
);
|
||||
|
||||
// eslint-disable-next-line vue/component-definition-name-casing
|
||||
Vue.component("fa-icon", FontAwesomeIcon);
|
||||
|
|
@ -4,8 +4,10 @@ export default {
|
|||
collectProps: function (component) {
|
||||
let data = {};
|
||||
for (var p in component.props) {
|
||||
if (p in this) {
|
||||
data[p] = this[p];
|
||||
}
|
||||
}
|
||||
return data;
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -17,16 +17,21 @@ export default {
|
|||
val = Math.abs(val);
|
||||
return val >= this.fmtLimit ? this.round(val / 1e3, this.fmtDigits) : this.round(val, 0);
|
||||
},
|
||||
fmtKw: function (watt, kw = true, withUnit = true) {
|
||||
fmtKw: function (watt = 0, kw = true, withUnit = true) {
|
||||
const digits = kw ? 1 : 0;
|
||||
const value = kw ? watt / 1000 : watt;
|
||||
let unit = "";
|
||||
if (withUnit) {
|
||||
unit = kw ? " kW" : " W";
|
||||
}
|
||||
return (
|
||||
this.$n(value, { minimumFractionDigits: digits, maximumFractionDigits: digits }) + unit
|
||||
);
|
||||
return `${new Intl.NumberFormat(this.$i18n.locale, {
|
||||
style: "decimal",
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
}).format(value)}${unit}`;
|
||||
},
|
||||
fmtKWh: function (val) {
|
||||
return this.fmtKw(val) + "h";
|
||||
},
|
||||
fmtUnit: function (val) {
|
||||
return Math.abs(val) >= this.fmtLimit ? "k" : "";
|
||||
|
|
@ -88,15 +93,42 @@ export default {
|
|||
const mm = `${date.getMinutes()}`.padStart(2, "0");
|
||||
return `${HH}:${mm}`;
|
||||
},
|
||||
fmtAbsoluteDate: function (date) {
|
||||
isToday: function (date) {
|
||||
const today = new Date();
|
||||
return today.toDateString() === date.toDateString();
|
||||
},
|
||||
isTomorrow: function (date) {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
return tomorrow.toDateString() === date.toDateString();
|
||||
},
|
||||
weekdayPrefix: function (date) {
|
||||
const rtf = new Intl.RelativeTimeFormat(this.$i18n.locale, { numeric: "auto" });
|
||||
|
||||
if (this.isToday(date)) {
|
||||
return rtf.formatToParts(0, "day")[0].value;
|
||||
}
|
||||
if (this.isTomorrow(date)) {
|
||||
return rtf.formatToParts(1, "day")[0].value;
|
||||
}
|
||||
return new Intl.DateTimeFormat(this.$i18n.locale, {
|
||||
weekday: "short",
|
||||
}).format(date);
|
||||
},
|
||||
fmtAbsoluteDate: function (date) {
|
||||
const weekday = this.weekdayPrefix(date);
|
||||
const hour = new Intl.DateTimeFormat(this.$i18n.locale, {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
}).format(date);
|
||||
|
||||
return `${weekday} ${hour}`;
|
||||
},
|
||||
fmtMoney: function (amout = 0, currency = "EUR") {
|
||||
return this.$n(amout, { style: "currency", currency });
|
||||
return new Intl.NumberFormat(this.$i18n.locale, {
|
||||
style: "currency",
|
||||
currency,
|
||||
}).format(amout);
|
||||
},
|
||||
fmtPricePerKWh: function (amout = 0, currency = "EUR") {
|
||||
let unit = currency;
|
||||
|
|
@ -107,7 +139,10 @@ export default {
|
|||
unit = "ct";
|
||||
maximumFractionDigits = 1;
|
||||
}
|
||||
return `${this.$n(value, { style: "decimal", maximumFractionDigits })} ${unit}/kWh`;
|
||||
return `${new Intl.NumberFormat(this.$i18n.locale, {
|
||||
style: "decimal",
|
||||
maximumFractionDigits,
|
||||
}).format(value)} ${unit}/kWh`;
|
||||
},
|
||||
fmtTimeAgo: function (elapsed) {
|
||||
const units = {
|
||||
|
|
|
|||
|
|
@ -1,16 +1,8 @@
|
|||
import VueRouter from "vue-router";
|
||||
import Vue from "vue";
|
||||
import Main from "./views/Main";
|
||||
import Config from "./views/Config";
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
|
||||
Vue.use(VueRouter);
|
||||
import Main from "./views/Main.vue";
|
||||
|
||||
const routes = [
|
||||
{ path: "/", component: Main },
|
||||
{ path: "/config", component: Config },
|
||||
];
|
||||
|
||||
export default new VueRouter({
|
||||
routes,
|
||||
linkExactActiveClass: "active", // Bootstrap <nav>
|
||||
export default createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [{ path: "/", component: Main, props: true }],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import Vue from "vue";
|
||||
import { reactive } from "vue";
|
||||
|
||||
function setProperty(obj, props, value) {
|
||||
const prop = props.shift();
|
||||
if (!obj[prop]) {
|
||||
Vue.set(obj, prop, {});
|
||||
obj[prop] = {};
|
||||
}
|
||||
|
||||
if (!props.length) {
|
||||
|
|
@ -18,16 +18,18 @@ function setProperty(obj, props, value) {
|
|||
setProperty(obj[prop], props, value);
|
||||
}
|
||||
|
||||
const store = {
|
||||
state: {
|
||||
const state = reactive({
|
||||
loadpoints: [], // ensure array type
|
||||
},
|
||||
});
|
||||
|
||||
const store = {
|
||||
state,
|
||||
update: function (msg) {
|
||||
Object.keys(msg).forEach(function (k) {
|
||||
if (typeof window.app[k] === "function") {
|
||||
window.app[k]({ message: msg[k] });
|
||||
} else {
|
||||
setProperty(store.state, k.split("."), msg[k]);
|
||||
setProperty(state, k.split("."), msg[k]);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
import "v-tooltip/dist/v-tooltip.css";
|
||||
import Vue from "vue";
|
||||
import VTooltip from "v-tooltip";
|
||||
VTooltip.options.offset = [0, 10];
|
||||
VTooltip.options.themes.tooltip.triggers = ["click", "focus"];
|
||||
|
||||
Vue.use(VTooltip);
|
||||
|
|
@ -1,171 +1,20 @@
|
|||
<template>
|
||||
<div class="app d-flex flex-column justify-content-between overflow-hidden">
|
||||
<div class="flex-grow-1 d-flex flex-column justify-content-between">
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="https://evcc.io/" target="_blank">
|
||||
<Logo class="logo"></Logo>
|
||||
</a>
|
||||
<div class="d-flex flex-grow-1 justify-content-end">
|
||||
<Notifications :notifications="notifications" />
|
||||
<button
|
||||
class="navbar-toggler"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#navbarNavAltMarkup"
|
||||
aria-controls="navbarNavAltMarkup"
|
||||
aria-expanded="false"
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="navbarNavAltMarkup" class="collapse navbar-collapse flex-grow-0">
|
||||
<ul class="navbar-nav">
|
||||
<li v-if="providerLogins.length > 0" class="nav-item dropdown">
|
||||
<a
|
||||
class="nav-link dropdown-toggle"
|
||||
data-bs-toggle="dropdown"
|
||||
href="#"
|
||||
role="button"
|
||||
aria-expanded="false"
|
||||
>{{ $t("header.login") }}
|
||||
<span v-if="logoutCount > 0" class="badge bg-secondary">{{
|
||||
logoutCount
|
||||
}}</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li
|
||||
v-for="login in providerLogins"
|
||||
:key="login.title"
|
||||
class="dropdown-item"
|
||||
>
|
||||
<button
|
||||
class="dropdown-item"
|
||||
type="button"
|
||||
@click="handleProviderAuthorization(login)"
|
||||
>
|
||||
{{ login.title }}
|
||||
{{
|
||||
$t(
|
||||
login.loggedIn
|
||||
? "main.provider.logout"
|
||||
: "main.provider.login"
|
||||
)
|
||||
}}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a
|
||||
class="nav-link"
|
||||
href="https://docs.evcc.io/blog/"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $t("header.blog") }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a
|
||||
class="nav-link"
|
||||
href="https://docs.evcc.io/docs/Home/"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $t("header.docs") }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a
|
||||
class="nav-link"
|
||||
href="https://github.com/evcc-io/evcc"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $t("header.github") }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<router-view
|
||||
class="flex-grow-1 d-flex flex-column justify-content-stretch"
|
||||
></router-view>
|
||||
</div>
|
||||
<Footer :version="version" :sponsor="sponsor" :savings="savings"></Footer>
|
||||
<div class="app overflow-hidden">
|
||||
<metainfo>
|
||||
<template #title="{ content }">{{ content ? `${content} | evcc` : `evcc` }}</template>
|
||||
</metainfo>
|
||||
<router-view :notifications="notifications"></router-view>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import "../icons";
|
||||
import Logo from "../components/Logo";
|
||||
import Footer from "../components/Footer";
|
||||
import Notifications from "../components/Notifications";
|
||||
|
||||
import store from "../store";
|
||||
|
||||
import baseAPI from "../baseapi";
|
||||
|
||||
export default {
|
||||
name: "App",
|
||||
components: { Logo, Footer, Notifications },
|
||||
props: {
|
||||
notifications: Array,
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
compact: false,
|
||||
store: this.$root.$data.store,
|
||||
installedVersion: window.evcc.version,
|
||||
commit: window.evcc.commit,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
version: function () {
|
||||
return {
|
||||
installed: this.installedVersion,
|
||||
commit: this.commit,
|
||||
available: this.store.state.availableVersion,
|
||||
releaseNotes: this.store.state.releaseNotes,
|
||||
hasUpdater: this.store.state.hasUpdater,
|
||||
uploadMessage: this.store.state.uploadMessage,
|
||||
uploadProgress: this.store.state.uploadProgress,
|
||||
};
|
||||
},
|
||||
title: function () {
|
||||
return this.store.state.siteTitle;
|
||||
},
|
||||
sponsor: function () {
|
||||
return this.store.state.sponsor;
|
||||
},
|
||||
savings: function () {
|
||||
return {
|
||||
since: this.store.state.savingsSince,
|
||||
totalCharged: this.store.state.savingsTotalCharged,
|
||||
gridCharged: this.store.state.savingsGridCharged,
|
||||
selfConsumptionCharged: this.store.state.savingsSelfConsumptionCharged,
|
||||
amount: this.store.state.savingsAmount,
|
||||
effectivePrice: this.store.state.savingsEffectivePrice,
|
||||
selfConsumptionPercent: this.store.state.savingsSelfConsumptionPercent,
|
||||
gridPrice: this.store.state.tariffGrid,
|
||||
feedInPrice: this.store.state.tariffFeedIn,
|
||||
currency: this.store.state.currency,
|
||||
};
|
||||
},
|
||||
logoutCount() {
|
||||
return this.providerLogins.filter((login) => !login.loggedIn).length;
|
||||
},
|
||||
providerLogins() {
|
||||
return this.store.state.auth
|
||||
? Object.entries(this.store.state.auth.vehicles).map(([k, v]) => ({
|
||||
title: k,
|
||||
loggedIn: v.authenticated,
|
||||
loginPath: v.uri + "/login",
|
||||
logoutPath: v.uri + "/logout",
|
||||
}))
|
||||
: [];
|
||||
},
|
||||
},
|
||||
created: function () {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
this.compact = urlParams.get("compact");
|
||||
|
|
@ -211,32 +60,15 @@ export default {
|
|||
reload() {
|
||||
window.location.reload();
|
||||
},
|
||||
handleProviderAuthorization: async function (provider) {
|
||||
if (!provider.loggedIn) {
|
||||
baseAPI.post(provider.loginPath).then(function (response) {
|
||||
window.location.href = response.data.loginUri;
|
||||
});
|
||||
} else {
|
||||
baseAPI.post(provider.logoutPath);
|
||||
}
|
||||
},
|
||||
},
|
||||
metaInfo() {
|
||||
return {
|
||||
title: this.title ? `evcc | ${this.title}` : "evcc",
|
||||
};
|
||||
return { title: store.state.siteTitle || "" };
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
.logo {
|
||||
width: 85px;
|
||||
}
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
.title {
|
||||
position: relative;
|
||||
top: 0.1rem;
|
||||
background-color: white;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<div class="container">
|
||||
<Site v-if="configured" v-bind="state"></Site>
|
||||
<div v-else>
|
||||
<div>
|
||||
<Site v-if="configured" :notifications="notifications" v-bind="state"></Site>
|
||||
<div v-else class="container">
|
||||
<div class="row py-5">
|
||||
<div class="col12">
|
||||
<p class="h1 pt-5 pb-2 border-bottom">Willkommen bei evcc</p>
|
||||
|
|
@ -80,13 +80,17 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import Site from "../components/Site";
|
||||
import Site from "../components/Site.vue";
|
||||
import store from "../store";
|
||||
|
||||
export default {
|
||||
name: "Main",
|
||||
components: { Site },
|
||||
props: {
|
||||
notifications: Array,
|
||||
},
|
||||
data: function () {
|
||||
return this.$root.$data.store; // global state
|
||||
return store;
|
||||
},
|
||||
computed: {
|
||||
configured: function () {
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 4 KiB After Width: | Height: | Size: 4 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
11
assets/public/meta/browserconfig.xml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square70x70logo src="meta/mstile-70x70.png" />
|
||||
<square150x150logo src="meta/mstile-150x150.png" />
|
||||
<square310x310logo src="meta/mstile-310x310.png" />
|
||||
<TileColor>#18191a</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
||||
|
Before Width: | Height: | Size: 646 B After Width: | Height: | Size: 646 B |
|
Before Width: | Height: | Size: 797 B After Width: | Height: | Size: 797 B |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
200
cmd/demo.yaml
|
|
@ -9,16 +9,23 @@ javascript:
|
|||
- vm: shared
|
||||
script: |
|
||||
state = {
|
||||
residualpower: 300,
|
||||
batterypower: 0,
|
||||
pvpower: 6200,
|
||||
gridpower: 0,
|
||||
chargepower: 0,
|
||||
maxcurrent: 0,
|
||||
battery: 63, // car
|
||||
residualpower: 500,
|
||||
pvpower: 5000,
|
||||
batterypower: -750,
|
||||
batterySoC: 55,
|
||||
gridpower: -1000,
|
||||
loadpoints: [
|
||||
{ enabled: true, vehicleSoC: 62, maxcurrent: 6, phases: 1, chargepower: 0 },
|
||||
{ enabled: false, vehicleSoC: 22, maxcurrent: 0, phases: 3, chargepower: 0 }
|
||||
]
|
||||
};
|
||||
function log() { console.log("state:", JSON.stringify(state)); }
|
||||
function set() { console.log("set:", param+"="+val); }
|
||||
function get() {
|
||||
console.log("state:", JSON.stringify(state));
|
||||
}
|
||||
function set() {
|
||||
console.log(param+":", val);
|
||||
console.log("state:", JSON.stringify(state));
|
||||
}
|
||||
|
||||
meters:
|
||||
- name: grid
|
||||
|
|
@ -27,22 +34,9 @@ meters:
|
|||
source: js
|
||||
vm: shared
|
||||
script: |
|
||||
state.pvpower += 200*(Math.random()-0.5);
|
||||
state.chargepower = 0;
|
||||
if (state.enabled) {
|
||||
state.chargepower = state.maxcurrent * 3 * 230;
|
||||
state.batterypower = 0;
|
||||
}
|
||||
state.gridpower = -state.pvpower + state.chargepower + state.residualpower + state.batterypower;
|
||||
// if (state.gridpower < 0) {
|
||||
// state.batterypower = -state.gridpower;
|
||||
// if (state.batterypower > 1000) {
|
||||
// state.batterypower = 1000;
|
||||
// }
|
||||
// state.gridpower += state.batterypower;
|
||||
// } else {
|
||||
// state.batterypower = 0;
|
||||
// }
|
||||
state.pvpower = 8000+500*Math.random();
|
||||
state.batterypower = state.gridpower > 0 ? 1000 * Math.random() : 0;
|
||||
state.gridpower = state.loadpoints[0].chargepower + state.loadpoints[1].chargepower + state.residualpower - state.batterypower - state.pvpower;
|
||||
state.gridpower;
|
||||
|
||||
- name: pv
|
||||
|
|
@ -50,7 +44,7 @@ meters:
|
|||
power:
|
||||
source: js
|
||||
vm: shared
|
||||
script: state.pvpower
|
||||
script: state.pvpower;
|
||||
|
||||
- name: battery
|
||||
type: custom
|
||||
|
|
@ -61,56 +55,148 @@ meters:
|
|||
soc:
|
||||
source: js
|
||||
vm: shared
|
||||
script: "30"
|
||||
- name: charge
|
||||
script: |
|
||||
if (state.batterypower < 0) state.batterySoC++; else state.batterySoC--;
|
||||
if (state.batterySoC < 10) state.batterySoC = 90;
|
||||
if (state.batterySoC > 90) state.batterySoC = 10;
|
||||
state.batterySoC;
|
||||
- name: meter_charger_1
|
||||
type: custom
|
||||
power:
|
||||
source: js
|
||||
vm: shared
|
||||
script: state.chargepower;
|
||||
soc:
|
||||
script: state.loadpoints[0].chargepower;
|
||||
- name: meter_charger_2
|
||||
type: custom
|
||||
power:
|
||||
source: js
|
||||
vm: shared
|
||||
script: "30"
|
||||
|
||||
script: state.loadpoints[1].chargepower;
|
||||
- name: meter_charger_3
|
||||
type: custom
|
||||
power:
|
||||
source: js
|
||||
script: 0
|
||||
chargers:
|
||||
- name: demo
|
||||
- name: charger_1
|
||||
type: custom
|
||||
enable:
|
||||
source: js
|
||||
vm: shared
|
||||
script: state.enabled = val;
|
||||
script: |
|
||||
set();
|
||||
var lp = state.loadpoints[0];
|
||||
lp.enabled = val;
|
||||
if (lp.enabled) lp.chargepower = lp.maxcurrent * 230 * lp.phases; else lp.chargepower = 0;
|
||||
enabled:
|
||||
source: js
|
||||
vm: shared
|
||||
script: |
|
||||
state.enabled;
|
||||
state.loadpoints[0].enabled;
|
||||
status:
|
||||
source: js
|
||||
vm: shared
|
||||
script: |
|
||||
if (state.enabled) "C"; else "B";
|
||||
if (state.loadpoints[0].enabled) "C"; else "B";
|
||||
maxcurrent:
|
||||
source: js
|
||||
vm: shared
|
||||
script: state.maxcurrent = val;
|
||||
script: |
|
||||
set();
|
||||
var lp = state.loadpoints[0];
|
||||
lp.maxcurrent = val;
|
||||
if (lp.enabled) lp.chargepower = lp.maxcurrent * 230 * lp.phases; else lp.chargepower = 0;
|
||||
- name: charger_2
|
||||
type: custom
|
||||
enable:
|
||||
source: js
|
||||
vm: shared
|
||||
script: |
|
||||
set();
|
||||
var lp = state.loadpoints[1];
|
||||
lp.enabled = val;
|
||||
if (lp.enabled) lp.chargepower = lp.maxcurrent * 230 * lp.phases; else lp.chargepower = 0;
|
||||
enabled:
|
||||
source: js
|
||||
vm: shared
|
||||
script: |
|
||||
state.loadpoints[1].enabled;
|
||||
status:
|
||||
source: js
|
||||
vm: shared
|
||||
script: |
|
||||
if (state.loadpoints[1].enabled) "C"; else "B";
|
||||
maxcurrent:
|
||||
source: js
|
||||
vm: shared
|
||||
script: |
|
||||
set();
|
||||
var lp = state.loadpoints[1];
|
||||
lp.maxcurrent = val;
|
||||
if (lp.enabled) lp.chargepower = lp.maxcurrent * 230 * lp.phases; else lp.chargepower = 0;
|
||||
- name: charger_3
|
||||
type: custom
|
||||
enable:
|
||||
source: js
|
||||
vm: shared
|
||||
script:
|
||||
enabled:
|
||||
source: js
|
||||
vm: shared
|
||||
script: false
|
||||
status:
|
||||
source: js
|
||||
vm: shared
|
||||
script: "'A'"
|
||||
maxcurrent:
|
||||
source: js
|
||||
vm: shared
|
||||
script: 16
|
||||
|
||||
vehicles:
|
||||
- name: demo
|
||||
title: e-Golf
|
||||
- name: vehicle_1
|
||||
title: blauer e-Golf
|
||||
type: custom
|
||||
capacity: 50
|
||||
soc:
|
||||
source: js
|
||||
vm: shared
|
||||
script: |
|
||||
if (state.chargepower > 0) state.battery++; else state.battery--;
|
||||
if (state.battery < 15) state.battery = 15;
|
||||
if (state.battery > 100) state.battery = 100;
|
||||
state.battery;
|
||||
var lp = state.loadpoints[0];
|
||||
if (lp.chargepower > 0) lp.vehicleSoC++; else lp.vehicleSoC--;
|
||||
if (lp.vehicleSoC < 15) lp.vehicleSoC = 80;
|
||||
if (lp.vehicleSoC > 80) lp.vehicleSoC = 15;
|
||||
lp.vehicleSoC;
|
||||
range:
|
||||
source: js
|
||||
vm: shared
|
||||
script: |
|
||||
var lp = state.loadpoints[0]
|
||||
var range = (44 * lp.vehicleSoC) / 15;
|
||||
range
|
||||
capacity: 44
|
||||
- name: vehicle_2
|
||||
title: weißes Model 3
|
||||
type: custom
|
||||
soc:
|
||||
source: js
|
||||
vm: shared
|
||||
script: |
|
||||
var lp = state.loadpoints[1];
|
||||
if (lp.chargepower > 0) lp.vehicleSoC++; else lp.vehicleSoC--;
|
||||
if (lp.vehicleSoC < 15) lp.vehicleSoC = 75;
|
||||
if (lp.vehicleSoC > 75) lp.vehicleSoC = 15;
|
||||
lp.vehicleSoC;
|
||||
range:
|
||||
source: js
|
||||
vm: shared
|
||||
script: |
|
||||
var lp = state.loadpoints[1]
|
||||
var range = (80 * lp.vehicleSoC) / 17;
|
||||
range
|
||||
capacity: 80
|
||||
|
||||
site:
|
||||
title: Demo
|
||||
title: Zuhause
|
||||
meters:
|
||||
grid: grid
|
||||
pv: pv
|
||||
|
|
@ -118,6 +204,24 @@ site:
|
|||
|
||||
loadpoints:
|
||||
- title: Carport
|
||||
charger: demo
|
||||
meter: charge
|
||||
vehicle: demo
|
||||
charger: charger_1
|
||||
mode: pv
|
||||
phases: 1
|
||||
soc:
|
||||
target: 90
|
||||
meters:
|
||||
charge: meter_charger_1
|
||||
vehicle: vehicle_1
|
||||
- title: Garage
|
||||
charger: charger_2
|
||||
soc:
|
||||
target: 75
|
||||
mode: "off"
|
||||
meters:
|
||||
charge: meter_charger_2
|
||||
vehicle: vehicle_2
|
||||
# - title: Garage rechts
|
||||
# charger: charger_3
|
||||
# mode: "off"
|
||||
# meters:
|
||||
# charge: meter_charger_3
|
||||
|
|
|
|||
BIN
dist/assets/Montserrat-Bold.294653dc.woff2
vendored
Normal file
BIN
dist/assets/Montserrat-Medium.f8c1310c.woff2
vendored
Normal file
6
dist/assets/index.5a1f45bf.css
vendored
Normal file
93
dist/assets/index.a45f25da.js
vendored
Normal file
6
dist/css/chunk-vendors.38c609a9.css
vendored
1
dist/css/index.0a6b5a0e.css
vendored
11
dist/ico/browserconfig.xml
vendored
|
|
@ -1,11 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square70x70logo src="ico/mstile-70x70.png" />
|
||||
<square150x150logo src="ico/mstile-150x150.png" />
|
||||
<square310x310logo src="ico/mstile-310x310.png" />
|
||||
<TileColor>#18191a</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
||||
37
dist/index.html
vendored
|
|
@ -1,5 +1,38 @@
|
|||
<!DOCTYPE html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="description" content="EV Charge Controller"><meta name="author" content="andig"><meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-status-bar-style" content="black"><link rel="apple-touch-icon" sizes="180x180" href="ico/apple-touch-icon.png?[[.Version]]"><link rel="icon" type="image/png" sizes="32x32" href="ico/favicon-32x32.png?[[.Version]]"><link rel="icon" type="image/png" sizes="16x16" href="ico/favicon-16x16.png?[[.Version]]"><link rel="manifest" href="ico/site.webmanifest"><link rel="mask-icon" href="ico/safari-pinned-tab.svg?[[.Version]]" color="#18191a"><link rel="shortcut icon" href="ico/favicon.ico?[[.Version]]"><meta name="apple-mobile-web-app-title" content="evcc"><meta name="application-name" content="evcc"><meta name="msapplication-TileColor" content="#18191a"><meta name="msapplication-config" content="ico/browserconfig.xml"><meta name="theme-color" content="#18191a"><title>evcc</title><link href="css/chunk-vendors.38c609a9.css" rel="preload" as="style"><link href="css/index.0a6b5a0e.css" rel="preload" as="style"><link href="js/chunk-vendors.b6901fd4.js" rel="preload" as="script"><link href="js/index.f02d5732.js" rel="preload" as="script"><link href="css/chunk-vendors.38c609a9.css" rel="stylesheet"><link href="css/index.0a6b5a0e.css" rel="stylesheet"></head><body><script>window.evcc = {
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="description" content="evcc - Sonne tanken ☀️🚘" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="evcc" />
|
||||
<meta name="application-name" content="evcc" />
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="meta/apple-touch-icon.png?[[.Version]]" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="meta/favicon-32x32.png?[[.Version]]" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="meta/favicon-16x16.png?[[.Version]]" />
|
||||
<link rel="manifest" href="meta/site.webmanifest?[[.Version]]" />
|
||||
<link rel="mask-icon" href="meta/safari-pinned-tab.svg?[[.Version]]" color="#28293e" />
|
||||
<link rel="shortcut icon" href="meta/favicon.ico?[[.Version]]" />
|
||||
<meta name="msapplication-TileColor" content="#28293e" />
|
||||
<meta name="msapplication-config" content="meta/browserconfig.xml?[[.Version]]" />
|
||||
<meta name="theme-color" content="#28293e" />
|
||||
|
||||
<title>evcc</title>
|
||||
<script type="module" crossorigin src="./assets/index.a45f25da.js"></script>
|
||||
<link rel="stylesheet" href="./assets/index.5a1f45bf.css">
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
window.evcc = {
|
||||
version: "[[.Version]]",
|
||||
configured: "[[.Configured]]",
|
||||
commit: "[[.Commit]]",
|
||||
};</script><div id="app"></div><script src="js/chunk-vendors.b6901fd4.js"></script><script src="js/index.f02d5732.js"></script></body></html>
|
||||
};
|
||||
</script>
|
||||
|
||||
<div id="app"></div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
30
dist/js/chunk-vendors.b6901fd4.js
vendored
1
dist/js/chunk-vendors.b6901fd4.js.map
vendored
2
dist/js/index.f02d5732.js
vendored
1
dist/js/index.f02d5732.js.map
vendored
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 4 KiB After Width: | Height: | Size: 4 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |