This commit is contained in:
Tikhon Vodyanov 2025-08-02 13:24:06 +02:00
parent 118965e0c3
commit e3d416ff0e
30 changed files with 6864 additions and 0 deletions

46
frontend/README.md Normal file
View file

@ -0,0 +1,46 @@
# Project Genesis
A modern, responsive web application built with Vue.js. This project leverages a rich component library for a polished user interface and centralized state management for a robust and scalable architecture.
## Core Technologies
- **Framework:** [Vue.js](https://vuejs.org/)
- **UI Components:** [PrimeVue](https://primevue.org/)
- **State Management:** [Pinia](https://pinia.vuejs.org/)
- **API Communication:** [Axios](https://axios-http.com/) (Managed within Pinia stores for centralized API/SPI calls)
## Getting Started
Follow these instructions to get a copy of the project up and running on your local machine for development and testing purposes.
### Prerequisites
You will need [Node.js](https://nodejs.org/) and [npm](https://www.npmjs.com/) installed on your machine.
- Node.js (v18.x or higher recommended)
- npm (v9.x or higher recommended)
### Installation
1. Clone the repository to your local machine:
```sh
git clone <repository-url>
```
2. Navigate to the project directory:
```sh
cd <project-directory>
```
3. Install the dependencies:
```sh
npm install
```
## Usage
To start the local development server, run the following command:
```sh
npm run dev
```
The application will be available at `http://localhost:5173` (or the next available port). The development server supports hot-reloading, so any changes you make to the source code will be reflected in the browser automatically.

View file

@ -0,0 +1,280 @@
{
"environmental_characteristics": {
"atmosphere": [
{
"parameter": "air_temperature",
"name": "Air Temperature",
"unit": "Celsius",
"description": "The ambient temperature surrounding the plant. Slider from -50 to 50 degrees Celsius"
},
{
"parameter": "relative_humidity",
"name": "Relative Humidity",
"unit": "Percentage",
"description": "The amount of water vapor in the air, expressed as a percentage of the maximum amount the air could hold. Slider from 0 to 100"
},
{
"parameter": "co2_concentration",
"name": "Carbon Dioxide Concentration",
"unit": "ppm",
"description": "Concentration of CO2 in the atmosphere, essential for photosynthesis. Slider from 0 to 5000 ppm"
},
{
"parameter": "air_pressure",
"name": "Atmospheric Pressure",
"unit": "kPa",
"description": "Atmospheric pressure, which can influence transpiration rates. Slider from 50 to 150 kPa"
},
{
"parameter": "wind_speed",
"name": "Wind Speed",
"unit": "m/s",
"description": "Speed of air movement, which affects transpiration and physical stress. Slider from 0 to 20 m/s"
}
],
"soil": [
{
"parameter": "soil_temperature",
"name": "Soil Temperature",
"unit": "Celsius",
"description": "The temperature of the soil, affecting root health and nutrient uptake. Slider from -50 to 50 degrees Celsius"
},
{
"parameter": "soil_acidity",
"name": "Soil Acidity (pH)",
"unit": "pH",
"description": "The acidity or alkalinity of the soil, critically affecting nutrient availability. Slider from 0 to 14"
},
{
"parameter": "soil_moisture",
"name": "Soil Moisture",
"unit": "Percentage",
"description": "The amount of water held in the soil, available to the plant roots. Slider from 0 to 100"
},
{
"parameter": "nutrients",
"name": "Nutrients",
"description": "Essential minerals for plant growth, categorized into macro and micro. Multiselector with checkboxes",
"types": {
"macronutrients": ["Nitrogen (N)", "Phosphorus (P)", "Potassium (K)", "Calcium (Ca)", "Magnesium (Mg)", "Sulfur (S)"],
}
}
],
"light": [
{
"parameter": "light_intensity",
"name": "Light Intensity",
"unit": "PPFD (μmol/m²/s)",
"description": "The amount of light available for photosynthesis. Slider from 0 to 2000 μmol/m²/s",
},
{
"parameter": "photoperiod",
"name": "Photoperiod",
"unit": "Hours per day",
"description": "The duration of light the plant receives in a 24-hour period. Slider from 0 to 24 hours"
}
],
"water": [
{
"parameter": "watering_frequency",
"name": "Watering Frequency",
"unit": "Times per week",
"description": "How often the plant is watered. Slider from 0 to 14 times per week"
}
]
},
"plant_characteristics": {
"morphological": [
{
"name": "Plant type",
"description": "Basil, rosemary etc.",
"parameter": "plant_type",
"unit": "N/A"
},
{
"name": "Height",
"description": "Height of the plant in cm",
"parameter": "height",
"unit": "cm"
},
{
"name": "Leaf Color",
"description": "Color of the leaves, e.g., green, yellow, purple, brown.",
"parameter": "leaf_color",
"unit": "N/A"
},
{
"name": "Presence of Flowers",
"description": "boolean",
"parameter": "presence_of_flowers",
"unit": "N/A"
},
{
"name": "Fruit Development",
"description": "boolean",
"parameter": "fruit_development",
"unit": "N/A"
},
{
"name": "Root Health",
"description": "Color and structure of roots, e.g., white and firm, brown and mushy.",
"parameter": "root_health",
"unit": "N/A"
}
],
"physiological": [
{
"name": "Health Status",
"description": "['Thriving', 'Healthy', 'Stressed', 'Wilting', 'Diseased', 'Dying', 'Dead']",
"parameter": "health_status",
"unit": "N/A"
},
{
"name": "Growth Rate",
"description": "cm per week",
"parameter": "growth_rate",
"unit": "cm/week"
},
{
"name": "Photosynthesis Rate",
"description": "Efficiency of converting light to energy in percentage.",
"parameter": "photosynthesis_rate",
"unit": "Percentage"
},
{
"name": "Transpiration Rate",
"description": "Rate of water movement and evaporation from leaves in percentage.",
"parameter": "transpiration_rate",
"unit": "Percentage"
}
]
},
"effects_on_plant": [
{
"effect_name": "Heat Stress",
"cause": {
"parameter": "air_temperature",
"condition": "high",
"description": "Temperature is significantly above the plant's optimal range."
},
"impacts": [
{
"characteristic_affected": "Leaf Shape",
"symptom": "Leaves curl upwards and may develop brown, burnt spots on the edges."
},
{
"characteristic_affected": "Health Status",
"symptom": "Plant appears wilted even if the soil is moist due to high transpiration."
}
]
},
{
"effect_name": "Nitrogen Deficiency",
"cause": {
"parameter": "nutrients",
"condition": "low",
"description": "Insufficient Nitrogen (N) in the soil."
},
"impacts": [
{
"characteristic_affected": "Leaf Color",
"symptom": "General chlorosis (yellowing) of older, lower leaves first, as nitrogen is mobile within the plant."
},
{
"characteristic_affected": "Height",
"symptom": "Stunted growth and reduced overall plant size."
}
]
},
{
"effect_name": "Low Soil pH",
"cause": {
"parameter": "soil_acidity",
"condition": "low",
"description": "Soil is too acidic (pH < 5.5), locking up nutrients like phosphorus and magnesium."
},
"impacts": [
{
"characteristic_affected": "Leaf Color",
"symptom": "Can cause various nutrient deficiency symptoms, such as yellowing (magnesium) or purplish leaves (phosphorus)."
},
{
"characteristic_affected": "Root Health",
"symptom": "Can lead to aluminum toxicity, causing stunted, brown roots."
}
]
},
{
"effect_name": "Drought Stress",
"cause": {
"parameter": "soil_moisture",
"condition": "low",
"description": "Insufficient water available to the plant roots."
},
"impacts": [
{
"characteristic_affected": "Health Status",
"symptom": "Progressive wilting of the entire plant, starting from the leaves."
},
{
"characteristic_affected": "Leaf Shape",
"symptom": "Leaves become dry, brittle, and may fall off."
}
]
},
{
"effect_name": "Poor Light",
"cause": {
"parameter": "light_intensity",
"condition": "low",
"description": "Insufficient light for photosynthesis."
},
"impacts": [
{
"characteristic_affected": "Stem Thickness",
"symptom": "Plant becomes 'leggy' with thin, elongated stems as it stretches towards a light source."
},
{
"characteristic_affected": "Leaf Color",
"symptom": "Leaves may become pale green or yellow."
}
]
},
{
"effect_name": "CO2 Enrichment",
"cause": {
"parameter": "co2_concentration",
"condition": "high",
"description": "CO2 levels are elevated above ambient (e.g., > 1000 ppm)."
},
"impacts": [
{
"characteristic_affected": "Growth Rate",
"symptom": "Increased growth rate and biomass production, assuming other factors are not limiting."
}
]
},
{
"effect_name": "Osmotic Stress / Chemical Contamination",
"cause": {
"parameter": "water_quality",
"condition": "contaminated",
"description": "Watering with a harmful, non-standard liquid (e.g., sugary, acidic, or salty solution like Coca-Cola)."
},
"impacts": [
{
"characteristic_affected": "Root Health",
"symptom": "Roots are damaged by extreme pH and osmotic pressure, preventing water uptake and leading to decay."
},
{
"characteristic_affected": "Health Status",
"symptom": "Rapid and severe wilting, discoloration, and likely plant death within a short period."
},
{
"characteristic_affected": "Leaf Color",
"symptom": "Leaves may turn brown or black as cells die."
}
]
}
]
}

24
frontend/eslint.config.js Normal file
View file

@ -0,0 +1,24 @@
import { defineConfig, globalIgnores } from 'eslint/config'
import globals from 'globals'
import js from '@eslint/js'
import pluginVue from 'eslint-plugin-vue'
export default defineConfig([
{
name: 'app/files-to-lint',
files: ['**/*.{js,mjs,jsx,vue}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
{
languageOptions: {
globals: {
...globals.browser,
},
},
},
js.configs.recommended,
...pluginVue.configs['flat/essential'],
])

13
frontend/index.html Normal file
View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

8
frontend/jsconfig.json Normal file
View file

@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

4901
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

35
frontend/package.json Normal file
View file

@ -0,0 +1,35 @@
{
"name": "plant",
"version": "0.0.0",
"private": true,
"type": "module",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --fix"
},
"dependencies": {
"@primeuix/themes": "^1.2.3",
"axios": "^1.11.0",
"pinia": "^3.0.3",
"primeicons": "^7.0.0",
"primevue": "^4.3.7",
"scss": "^0.2.4",
"vue": "^3.5.18",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@eslint/js": "^9.31.0",
"@vitejs/plugin-vue": "^6.0.1",
"eslint": "^9.31.0",
"eslint-plugin-vue": "~10.3.0",
"globals": "^16.3.0",
"sass": "^1.89.2",
"vite": "^7.0.6",
"vite-plugin-vue-devtools": "^8.0.0"
}
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

49
frontend/src/App.vue Normal file
View file

@ -0,0 +1,49 @@
<script setup>
import PlantView from "@/views/PlantView.vue";
import ConditionsVIew from "@/views/ConditionsVIew.vue";
import { RouterView } from "vue-router";
import Button from "primevue/button";
import { useRoute } from 'vue-router';
import {computed} from "vue";
const route = useRoute();
const showHomeButton = computed(() => route.path !== '/' && route.path !== '/loading');
</script>
<template>
<div class="main-wrapper">
<div class="inner">
<Button v-if="showHomeButton" icon="pi pi-home" severity="secondary" aria-label="Home" @click="$router.push('/')"/>
<RouterView></RouterView>
</div>
</div>
</template>
<style scoped>
* {
font-family: Helvetica, sans-serif;
}
.main-wrapper {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 3rem;
}
.inner {
max-width: 45rem;
display: flex;
gap: 1rem;
flex-direction: column;
align-items: end;
}
</style>

View file

@ -0,0 +1,30 @@
<template>
<div class="description-wrapper">
<h3>What conditions are you going to put your plant in?</h3>
<Textarea v-model="plantStateStore.conditionDataDescription" placeholder="Are you adding fertilizer? Are you watering it more?"/>
</div>
</template>
<script setup>
import Textarea from 'primevue/textarea';
import { usePlantStateStore
} from "@/stores/plantState.js";
const plantStateStore = usePlantStateStore();
</script>
<style scoped>
textarea {
width: 100%;
height: 10rem;
padding: 1rem;
}
h3 {
margin-bottom: 1rem;
}
</style>

View file

@ -0,0 +1,286 @@
<template>
<main class="plant-form-container">
<h3>If you would like to specify specific environment information you can do so, if not, we have some common defaults</h3>
<form @submit.prevent="submitForm" class="plant-form">
<section class="environmental-section">
<Tabs value="0">
<TabList>
<Tab header="Atmosphere" value="0">
Atmosphere
</Tab>
<Tab header="Soil" value="1">
Soil
</Tab>
<Tab header="Light" value="2">
Light
</Tab>
<Tab header="Water" value="3">
Water
</Tab>
</TabList>
<TabPanels class="conditions-tabs" >
<TabPanel header="Atmosphere" value="0">
<Card class="condition-card">
<template #content>
<div class="field-grid">
<div class="field">
<label for="air_temperature">Air Temperature (°C)</label>
<Slider v-model="formData.environmental_characteristics.atmosphere.air_temperature"
:min="-50" :max="50"
:step="1"
class="w-full" />
<InputNumber v-model="formData.environmental_characteristics.atmosphere.air_temperature"
:min="-50" :max="50"
class="mt-2" />
<small>The ambient temperature surrounding the plant (-50 to 50°C)</small>
</div>
<div class="field">
<label for="relative_humidity">Relative Humidity (%)</label>
<Slider v-model="formData.environmental_characteristics.atmosphere.relative_humidity"
:min="0" :max="100"
class="w-full" />
<InputNumber v-model="formData.environmental_characteristics.atmosphere.relative_humidity"
:min="0" :max="100"
class="mt-2" />
<small>The amount of water vapor in the air (0-100%)</small>
</div>
<div class="field">
<label for="co2_concentration">Carbon Dioxide Concentration (ppm)</label>
<Slider v-model="formData.environmental_characteristics.atmosphere.co2_concentration"
:min="0" :max="5000"
:step="100"
class="w-full" />
<InputNumber v-model="formData.environmental_characteristics.atmosphere.co2_concentration"
:min="0" :max="5000"
class="mt-2" />
<small>Concentration of CO₂ in the atmosphere (0-5000 ppm)</small>
</div>
<div class="field">
<label for="air_pressure">Atmospheric Pressure (kPa)</label>
<Slider v-model="formData.environmental_characteristics.atmosphere.air_pressure"
:min="50" :max="150"
:step="1"
class="w-full" />
<InputNumber v-model="formData.environmental_characteristics.atmosphere.air_pressure"
:min="50" :max="150"
class="mt-2" />
<small>Atmospheric pressure (50-150 kPa)</small>
</div>
<div class="field">
<label for="wind_speed">Wind Speed (m/s)</label>
<Slider v-model="formData.environmental_characteristics.atmosphere.wind_speed"
:min="0" :max="20"
:step="0.1"
class="w-full" />
<InputNumber v-model="formData.environmental_characteristics.atmosphere.wind_speed"
:min="0" :max="20"
:step="0.1"
class="mt-2" />
<small>Speed of air movement (0-20 m/s)</small>
</div>
</div>
</template>
</Card>
</TabPanel>
<TabPanel header="Soil" value="1">
<Card class="condition-card">
<template #content>
<div class="field-grid">
<div class="field">
<label for="soil_temperature">Soil Temperature (°C)</label>
<Slider v-model="formData.environmental_characteristics.soil.soil_temperature"
:min="-50" :max="50"
class="w-full" />
<InputNumber v-model="formData.environmental_characteristics.soil.soil_temperature"
:min="-50" :max="50"
class="mt-2" />
<small>The temperature of the soil (-50 to 50°C)</small>
</div>
<div class="field">
<label for="soil_acidity">Soil Acidity (pH)</label>
<Slider v-model="formData.environmental_characteristics.soil.soil_acidity"
:min="0" :max="14"
:step="0.1"
class="w-full" />
<InputNumber v-model="formData.environmental_characteristics.soil.soil_acidity"
:min="0" :max="14"
:step="0.1"
class="mt-2" />
<small>The acidity or alkalinity of the soil (0-14 pH)</small>
</div>
<div class="field">
<label for="soil_moisture">Soil Moisture (%)</label>
<Slider v-model="formData.environmental_characteristics.soil.soil_moisture"
:min="0" :max="100"
class="w-full" />
<InputNumber v-model="formData.environmental_characteristics.soil.soil_moisture"
:min="0" :max="100"
class="mt-2" />
<small>The amount of water held in the soil (0-100%)</small>
</div>
<div class="field">
<label for="nutrients">Nutrients</label>
<MultiSelect v-model="formData.environmental_characteristics.soil.nutrients"
:options="nutrientOptions"
display="chip"
placeholder="Select Nutrients"
class="w-full" />
<small>Essential minerals for plant growth</small>
</div>
</div>
</template>
</Card>
</TabPanel>
<TabPanel header="Light" value="2">
<Card class="condition-card">
<template #content>
<div class="field-grid">
<div class="field">
<label for="light_intensity">Light Intensity (PPFD)</label>
<Slider v-model="formData.environmental_characteristics.light.light_intensity"
:min="0" :max="2000"
:step="50"
class="w-full" />
<InputNumber v-model="formData.environmental_characteristics.light.light_intensity"
:min="0" :max="2000"
class="mt-2" />
<small>Amount of light available for photosynthesis (0-2000 μmol//s)</small>
</div>
<div class="field">
<label for="photoperiod">Photoperiod (hours/day)</label>
<Slider v-model="formData.environmental_characteristics.light.photoperiod"
:min="0" :max="24"
class="w-full" />
<InputNumber v-model="formData.environmental_characteristics.light.photoperiod"
:min="0" :max="24"
class="mt-2" />
<small>Duration of light the plant receives (0-24 hours)</small>
</div>
</div>
</template>
</Card>
</TabPanel>
<TabPanel header="Water" value="3">
<Card class="condition-card">
<template #content>
<div class="field">
<label for="watering_frequency">Watering Frequency (times/week)</label>
<Slider v-model="formData.environmental_characteristics.water.watering_frequency"
:min="0" :max="14"
class="w-full" />
<InputNumber v-model="formData.environmental_characteristics.water.watering_frequency"
:min="0" :max="14"
class="mt-2" />
<small>How often the plant is watered (0-14 times/week)</small>
</div>
</template>
</Card>
</TabPanel>
</TabPanels>
</Tabs>
</section>
</form>
</main>
</template>
<script setup>
import { ref } from 'vue';
import { usePlantStateStore } from '@/stores/plantState';
import TabView from 'primevue/tabview';
import TabPanel from 'primevue/tabpanel';
import Card from 'primevue/card';
import Slider from 'primevue/slider';
import InputNumber from 'primevue/inputnumber';
import MultiSelect from 'primevue/multiselect';
import Button from 'primevue/button';
import Tab from "primevue/tab";
import TabList from "primevue/tablist";
import TabPanels from "primevue/tabpanels";
import Tabs from "primevue/tabs";
const plantStore = usePlantStateStore();
const nutrientOptions = [
"Nitrogen (N)",
"Phosphorus (P)",
"Potassium (K)",
"Calcium (Ca)",
"Magnesium (Mg)",
"Sulfur (S)"
];
const formData = plantStore.conditionData
const submitForm = () => {
plantStore.setConditionData(formData.value);
};
</script>
<style scoped>
.plant-form-container {
margin: 0 auto;
}
.plant-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.conditions-tabs {
margin-bottom: 1.5rem;
}
.condition-card {
margin-top: 1rem;
padding: 1rem;
}
.field-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
small {
color: var(--text-color-secondary);
font-size: 0.875rem;
}
.form-footer {
display: flex;
justify-content: flex-end;
margin-top: 1.5rem;
}
h3 {
margin-bottom: 1rem;
}
label {
margin-bottom: 3px;
}
:deep(.p-slider) {
margin-bottom: 5px;
}
</style>

View file

@ -0,0 +1,41 @@
<template>
<div class="image-wrapper">
<FileUpload name="demo[]" @select="handleUpload($event)" :multiple="false" :auto="true" accept="image/*" :maxFileSize="110000">
<template #empty>
<span>Drag and drop files to here to upload.</span>
</template>
</FileUpload>
</div>
</template>
<script setup>
import FileUpload from 'primevue/fileupload';
import { useToast } from "primevue/usetoast";
import { usePlantStateStore } from "@/stores/plantState.js";
const toast = useToast();
import router from "@/router";
const plantStateStore = usePlantStateStore();
async function handleUpload(event) {
console.log(event)
const file = event.files[0]
const reader = new FileReader()
reader.onload = async () => {
plantStateStore.setImage(reader.result)
}
reader.readAsDataURL(file)
toast.add({ severity: 'success', summary: 'What a lovely plant you have!', detail: 'File Uploaded', life: 3000 });
router.push('/help');
}
</script>
<style scoped>
:deep(.p-fileupload-upload-button) {
display: none !important;
}
</style>

View file

@ -0,0 +1,213 @@
<template>
<main class="plant-form-container">
<h3>Feel like being more precise? Insert additional information if you know it</h3>
<form @submit.prevent="submitForm" class="plant-form">
<section class="plant-section">
<Tabs value="0">
<TabList>
<Tab header="Morphological" value="0">
Morphological Characteristics
</Tab>
<Tab header="Physiological" value="1">
Physiological Characteristics
</Tab>
</TabList>
<TabPanels class="plant-tabs">
<TabPanel header="Morphological" value="0">
<Card class="plant-card">
<template #content>
<div class="field-grid">
<div class="field">
<label for="plant_type">Plant Type</label>
<InputText v-model="formData.plant_characteristics.morphological.plant_type"
id="plant_type"
class="w-full" />
<small>Basil, rosemary etc.</small>
</div>
<div class="field">
<label for="height">Height (cm)</label>
<InputNumber v-model="formData.plant_characteristics.morphological.height"
id="height"
:min="0"
class="w-full" />
<small>Height of the plant in cm</small>
</div>
<div class="field">
<label for="leaf_color">Leaf Color</label>
<InputText v-model="formData.plant_characteristics.morphological.leaf_color"
id="leaf_color"
class="w-full" />
<small>Color of the leaves (e.g., green, yellow, purple)</small>
</div>
<div class="field">
<label for="presence_of_flowers">Presence of Flowers</label>
<InputSwitch v-model="formData.plant_characteristics.morphological.presence_of_flowers"
id="presence_of_flowers" />
</div>
<div class="field">
<label for="fruit_development">Fruit Development</label>
<InputSwitch v-model="formData.plant_characteristics.morphological.fruit_development"
id="fruit_development" />
</div>
<div class="field">
<label for="root_health">Root Health</label>
<InputText v-model="formData.plant_characteristics.morphological.root_health"
id="root_health"
class="w-full" />
<small>Color and structure of roots (e.g., white and firm)</small>
</div>
</div>
</template>
</Card>
</TabPanel>
<TabPanel header="Physiological" value="1">
<Card class="plant-card">
<template #content>
<div class="field-grid">
<div class="field">
<label for="health_status">Health Status</label>
<Dropdown v-model="formData.plant_characteristics.physiological.health_status"
:options="healthOptions"
placeholder="Select Health Status"
class="w-full" />
</div>
<div class="field">
<label for="growth_rate">Growth Rate (cm/week)</label>
<InputNumber v-model="formData.plant_characteristics.physiological.growth_rate"
id="growth_rate"
:min="0"
:step="0.1"
class="w-full" />
</div>
<div class="field">
<label for="photosynthesis_rate">Photosynthesis Rate (%)</label>
<Slider v-model="formData.plant_characteristics.physiological.photosynthesis_rate"
:min="0"
:max="100"
class="w-full" />
<InputNumber v-model="formData.plant_characteristics.physiological.photosynthesis_rate"
:min="0"
:max="100"
class="mt-2" />
<small>Efficiency of converting light to energy</small>
</div>
<div class="field">
<label for="transpiration_rate">Transpiration Rate (%)</label>
<Slider v-model="formData.plant_characteristics.physiological.transpiration_rate"
:min="0"
:max="100"
class="w-full" />
<InputNumber v-model="formData.plant_characteristics.physiological.transpiration_rate"
:min="0"
:max="100"
class="mt-2" />
<small>Rate of water movement and evaporation from leaves</small>
</div>
</div>
</template>
</Card>
</TabPanel>
</TabPanels>
</Tabs>
</section>
</form>
</main>
</template>
<script setup>
import { ref } from 'vue';
import { usePlantStateStore } from '@/stores/plantState';
import TabPanel from 'primevue/tabpanel';
import Card from 'primevue/card';
import InputNumber from 'primevue/inputnumber';
import InputText from 'primevue/inputtext';
import InputSwitch from 'primevue/inputswitch';
import Dropdown from 'primevue/dropdown';
import Button from 'primevue/button';
import Slider from 'primevue/slider';
import TabList from 'primevue/tablist';
import Tab from 'primevue/tab';
import Tabs from 'primevue/tabs';
import TabPanels from 'primevue/tabpanels';
const plantStore = usePlantStateStore();
const healthOptions = [
"Thriving",
"Healthy",
"Stressed",
"Wilting",
"Diseased",
"Dying",
"Dead"
];
const formData = plantStore.plantData
const submitForm = () => {
plantStore.setPlantData(formData.value);
};
</script>
<style scoped>
.plant-form-container {
width: 100%;
margin: 0 auto;
}
.plant-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.plant-tabs {
margin-bottom: 1.5rem;
}
.plant-card {
margin-top: 1rem;
padding: 1rem;
}
.field-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
small {
color: var(--text-color-secondary);
font-size: 0.875rem;
}
.form-footer {
display: flex;
justify-content: flex-end;
margin-top: 1.5rem;
}
h3 {
margin-bottom: 2rem;
}
label {
margin-bottom: 3px;
}
</style>

View file

@ -0,0 +1,31 @@
<template>
<div class="description-wrapper">
<h3>Give us some more information, the more the better!</h3>
<Textarea v-model="plantStateStore.plantDataDescription" placeholder="Does your plant have any known sicknesses? Is it large? Does your pet nibble on it?"/>
</div>
</template>
<script setup>
import Textarea from 'primevue/textarea';
import {usePlantStateStore} from "@/stores/plantState.js";
const plantStateStore = usePlantStateStore();
</script>
<style scoped>
textarea {
width: 100%;
height: 10rem;
padding: 1rem;
}
.description-wrapper {
display: flex;
flex-direction: column;
gap: 1rem;
}
</style>

24
frontend/src/main.js Normal file
View file

@ -0,0 +1,24 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Aura from '@primeuix/themes/aura';
import ToastService from 'primevue/toastservice'
import '../style/styles.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ToastService)
app.use(PrimeVue, {
theme: {
preset: Aura
}
})
app.mount('#app')

View file

@ -0,0 +1,44 @@
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'Upload',
component: () => import('@/views/InitialUploadView.vue')
},
{
path: '/help',
name: 'How can we help you?',
component: () => import('@/views/HelpView.vue')
},
{
path: '/simulate',
name: 'Simulate',
component: () => import('@/views/PlantView.vue')
},
{
path: '/setEnvironment',
name: 'Set Environment',
component: () => import('@/views/EnvironmentView.vue')
},
{
path: '/loading',
name: 'Loading',
component: () => import('@/views/LoadingView.vue')
},
{
path: '/results',
name: 'Results',
component: () => import('@/views/SimulationResultView.vue')
},
{
path: '/cure',
name: 'Cure Plant',
component: () => import('@/views/PlantCureView.vue')
}
],
})
export default router

View file

@ -0,0 +1,12 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})

View file

@ -0,0 +1,171 @@
import { defineStore } from 'pinia';
import router from '@/router'; // Adjust the import based on your router setup
import axios from 'axios';
export const usePlantStateStore = defineStore('plantState', {
state: () => ({
imageResult: null,
textResult: null,
plantDataDescription: '',
conditionDataDescription: '',
imageBase64: null,
plantHealthDescription: '',
generatedImageBase64: null,
plantData: {
plant_characteristics: {
morphological: {
plant_type: '',
height: 0,
leaf_color: '',
presence_of_flowers: false,
fruit_development: false,
root_health: ''
},
physiological: {
health_status: '',
growth_rate: 0,
photosynthesis_rate: 0,
transpiration_rate: 0
}
}
},
conditionData: {
environmental_characteristics: {
atmosphere: {
air_temperature: 20,
relative_humidity: 50,
co2_concentration: 400,
air_pressure: 101.3,
wind_speed: 0
},
soil: {
soil_temperature: 20,
soil_acidity: 6.5,
soil_moisture: 50,
nutrients: []
},
light: {
light_intensity: 500,
photoperiod: 12
},
water: {
watering_frequency: 3
}
}
},
time_period: '',
cure: '',
text_response: '',
health: {
health_breakdown: {
Dead: 0.0105,
Dehydrated: 0.0421,
Diseased: 0.0562,
Healthy: 0.8912
},
health_status: "Healthy",
}
}),
actions: {
async healthCheck() {
// Convert base64 to Blob (File is a specialized Blob)
const blob = await fetch(this.imageBase64).then(res => res.blob());
// Create a File object from the Blob
const imageFile = new File([blob], 'image', { type: 'image/jpeg' });
// Create FormData and append the file
const formData = new FormData();
formData.append('image', imageFile);
const res = await axios.post('/api/analyze', formData, {
headers: {
'Content-Type': 'multipart/form-data',
'Access-Control-Allow-Origin': '*'
}
});
function makePercentage(number) {
// Ensure the number is between 0 and 1
if (number < 0 || number > 1) {
throw new Error("Number must be between 0 and 1");
}
// Convert to percentage and return as a string
return (number * 100).toFixed();
}
this.health.health_breakdown.Dead = makePercentage(res.data.health_breakdown.Dead);
this.health.health_breakdown.Dehydrated = makePercentage(res.data.health_breakdown.Dehydrated);
this.health.health_breakdown.Diseased = makePercentage(res.data.health_breakdown.Diseased);
this.health.health_breakdown.Healthy = makePercentage(res.data.health_breakdown.Healthy);
this.health.health_status = res.data.health_status;
this.plantData.plant_characteristics.morphological.plant_type = res.data.plant_species;
},
setPlantData(data) {
this.plantData = {
plant_characteristics: {
...this.plantData.plant_characteristics,
...data.plant_characteristics
}
};
},
setConditionData(data) {
this.conditionData = {
environmental_characteristics: {
...this.conditionData.environmental_characteristics,
...data.environmental_characteristics
}
};
},
async sendRequest() {
await router.push('/loading')
const data = {
plant_state_description: this.plantDataDescription,
plant_state_details: this.plantData,
environmental_changes_description: this.conditionDataDescription,
environmental_changes_details: this.conditionData,
time_period: this.time_period
}
const res = await axios.post('http://localhost:3000/analyze', JSON.stringify(data), {
headers: {
'Content-Type': 'application/json'
}
});
const imageRes = await axios.post('http://localhost:3000/generate', JSON.stringify([res.data.result.image_generator_prompt, this.imageBase64]), {
headers: {
'Content-Type': 'application/json'
}
});
console.log('Image generation prompt:', res.data.result.image_generator_prompt);
this.generatedImageBase64 = imageRes.data.result;
this.text_response = res.data.result.final_description_text;
await router.push('/results');
},
async askForCure() {
const res = await axios.post('http://localhost:3000/cure', JSON.stringify({
plant_state_description: this.plantDataDescription,
plant_state_details: this.plantData,
time_period: this.time_period
}), {
headers: {
'Content-Type': 'application/json'
}
});
this.cure = res.data.result;
},
setImage(base64) {
this.imageBase64 = base64
localStorage.setItem('uploadedImage', base64)
},
}
});

View file

@ -0,0 +1,37 @@
<script setup>
import conditionDetails from '@/components/conditionDetails.vue'
import conditionDescription from "@/components/conditionDescription.vue";
import Button from "primevue/button";
</script>
<template>
<div class="plant-wrapper">
<div class="loose">
<h2>Insert your conditions</h2>
<condition-description></condition-description>
<Button type="button" label="Upload" icon="pi pi-search" :loading="loading" @click="load" />
</div>
<div class="precise">
<condition-details></condition-details>
</div>
</div>
</template>
<style scoped>
.plant-wrapper {
display: flex;
flex-direction: row;
align-items: stretch;
justify-content: start;
gap: 2rem;
}
.loose {
flex: 1;
display: flex;
flex-direction: column;
gap: 1rem;
width: 30rem;
}
</style>

View file

@ -0,0 +1,13 @@
<script>
export default {
name: "CureResultsView"
}
</script>
<template>
</template>
<style scoped>
</style>

View file

@ -0,0 +1,76 @@
<template>
<div class="plant-wrapper">
<img :src="plantStateStore.imageBase64" alt="">
<div class="loose">
<h2>What's going to happen to your lovely plant?</h2>
<condition-description></condition-description>
<h3>How long is the condition going to last?</h3>
<InputText placeholder="E.g. 5hrs, 7 days, a month" v-model="plantStateStore.time_period"></InputText>
</div>
<div class="precise">
<condition-details></condition-details>
</div>
<div class="footer">
<Button icon="pi pi-check" label="Submit" aria-label="Home" @click="plantStateStore.sendRequest()"> </Button>
</div>
</div>
</template>
<script setup>
import ImageScan from '@/components/imageScan.vue'
import conditionDescription from "@/components/conditionDescription.vue";
import conditionDetails from '@/components/conditionDetails.vue';
import InputText from 'primevue/inputtext';
import Button from 'primevue/button';
import { usePlantStateStore} from "@/stores/plantState.js";
const plantStateStore = usePlantStateStore();
</script>
<style scoped>
@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Playwrite+HU:wght@100..400&family=Roboto+Flex:opsz,wght@8..144,100..1000&family=Rubik:ital,wght@0,300..900;1,300..900&display=swap');
h2 {
font-family: 'Playwrite HU', sans-serif;
font-weight: 600;
font-size: 1.5rem;
margin-bottom: 1rem;
}
.plant-wrapper {
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: start;
gap: 2rem;
overflow-y: scroll;
width: 100%;
}
.loose {
flex: 1;
display: flex;
flex-direction: column;
gap: 1rem;
}
.footer {
display: flex;
justify-content: flex-end;
}
img {
max-height: 5rem;
border-radius: 4px;
object-fit: cover;
}
h3 {
margin-top: 1rem;
}
</style>

View file

@ -0,0 +1,153 @@
<script setup>
import Button from 'primevue/button';
import { usePlantStateStore} from "@/stores/plantState.js";
import { watchEffect } from 'vue';
import Card from 'primevue/card';
import ProgressSpinner from 'primevue/progressspinner';
const plantStateStore = usePlantStateStore();
watchEffect(() => {
document.documentElement.style.setProperty('--background-image', 'url(' + plantStateStore.imageBase64 + ')');
});
import { onMounted, ref } from 'vue';
onMounted(() => {
loadHealthCheck();
});
const isLoading = ref(true);
function loadHealthCheck() {
plantStateStore.healthCheck().finally(() => {
isLoading.value = false;
});
}
</script>
<template>
<div class="row">
<div class="card">
<div class="content">
<h1>Here's how your plant is feeling today</h1>
</div>
</div>
<Card v-if="!isLoading">
<template #title>
<h3>Recognized plant: {{ plantStateStore.plantData.plant_characteristics.morphological.plant_type }}</h3>
</template>
<template #subtitle>
<h4>{{ plantStateStore.health.health_status }}</h4>
</template>
<template #content>
<ul>
<li>Healthy: {{ plantStateStore.health.health_breakdown.Healthy }}% </li>
<li>Dying: {{ plantStateStore.health.health_breakdown.Dead }}% </li>
<li>Diseased: {{ plantStateStore.health.health_breakdown.Diseased }}% </li>
<li>Dehydrated : {{ plantStateStore.health.health_breakdown.Dehydrated }}% </li>
</ul>
</template>
</Card>
<ProgressSpinner v-else></ProgressSpinner>
</div>
<div class="under">
<Button icon="pi pi-arrow-right" label="Continue" aria-label="Home" @click="$router.push('/simulate')"> </Button>
</div>
</template>
<style scoped>
@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Playwrite+HU:wght@100..400&family=Roboto+Flex:opsz,wght@8..144,100..1000&family=Rubik:ital,wght@0,300..900;1,300..900&display=swap');
.row {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
}
:deep(.p-card) {
width: 100%;
max-width: 35rem;
margin: 0 auto;
}
li {
margin-bottom: 0.7rem;
margin-left: 1rem;
font-size: 110%;
}
.card {
position: relative;
width: 100%;
max-width: 35rem;
height: 40rem;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.card::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: var(--background-image);
background-size: cover;
background-position: center;
}
.card::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(
to top,
rgba(0, 0, 0, 0.9) 0%,
rgba(0, 0, 0, 0.7) 30%,
rgba(0, 0, 0, 0.4) 70%,
rgba(0, 0, 0, 0.2) 100%
);
}
.content {
position: relative;
z-index: 2;
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 2rem;
color: white;
}
h1 {
font-family: 'Playwrite HU', sans-serif;
font-size: 2.5rem;
margin-bottom: 1rem;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
}
.under {
display: flex;
justify-content: stretch;
gap: 1rem;
margin-top: 2rem;
width: 100%;
}
:deep(.p-button) {
flex-grow: 1;
}
:deep(.p-button-label) {
font-weight: bold;
}
</style>

View file

@ -0,0 +1,39 @@
<script setup>
import ImageScan from "@/components/imageScan.vue";
import Card from "primevue/card";
</script>
<template>
<div class="upload-wrapper">
<Card>
<template #header>
<img src="../../style/moss.jpg" alt="">
</template>
<template #title>
<h1>Hello! Share your plant with us and we'll tell you all about it!</h1>
</template>
<template #content>
<image-scan></image-scan>
</template>
</Card>
</div>
</template>
<style scoped>
@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Playwrite+HU:wght@100..400&family=Roboto+Flex:opsz,wght@8..144,100..1000&family=Rubik:ital,wght@0,300..900;1,300..900&display=swap');
h1 {
font-family: 'Playwrite HU', sans-serif;
font-size: 2.5rem;
margin-bottom: 1rem;
}
img {
max-height: 5rem;
width: 45rem;
object-fit: cover;
border-radius: 5px 5px 0 0;
}
</style>

View file

@ -0,0 +1,12 @@
<script setup>
import ProgressSpinner from 'primevue/progressspinner';
</script>
<template>
<ProgressSpinner></ProgressSpinner>
</template>
<style scoped>
</style>

View file

@ -0,0 +1,98 @@
<template>
<div class="plant-wrapper">
<div class="loose">
<h1>Plant not doing too well?</h1>
<img :src="plantStateStore.imageBase64" alt="">
<h3>Here are some tips on how to make it feel better!</h3>
</div>
<div class="precise">
<ProgressBar v-if="isLoading" mode="indeterminate" style="width: 100%; height: 2rem; margin-top: 1rem;"></ProgressBar>
<Card v-else class="cure-card">
<template #content>
<ul>
<li class="list-item" v-for="(line, index) in textResponse" :key="index">{{ line }}</li>
</ul>
</template>
</Card>
</div>
</div>
</template>
<script setup>
import Button from 'primevue/button';
import { usePlantStateStore } from "@/stores/plantState.js";
import Textarea from "primevue/textarea";
const plantStateStore = usePlantStateStore();
import ProgressBar from "primevue/progressbar";
import {computed, ref} from 'vue';
import Card from "primevue/card";
const isLoading = ref(false);
import { onMounted } from 'vue';
onMounted(() => {
isLoading.value = true;
plantStateStore.askForCure().finally(() => {
isLoading.value = false;
});
});
const textResponse = computed(() =>
plantStateStore.cure.split('.').filter(line => line.trim() !== '').map(line => line.trim())
);
</script>
<style scoped>
@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Playwrite+HU:wght@100..400&family=Roboto+Flex:opsz,wght@8..144,100..1000&family=Rubik:ital,wght@0,300..900;1,300..900&display=swap');
h1 {
font-family: 'Playwrite HU', sans-serif;
font-weight: 600;
margin-bottom: 1rem;
}
.list-item {
margin-bottom: 1rem;
margin-left: 1.5rem;
font-size: 110%;
}
h3 {
margin-top: 1rem;
}
.plant-wrapper {
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: start;
gap: 2rem;
overflow-y: scroll;
width: 100%;
}
.loose {
flex: 1;
display: flex;
flex-direction: column;
align-content: center;
gap: 1.5rem;
}
.footer {
display: flex;
justify-content: flex-end;
}
img {
max-height: 40rem;
width: 100%;
border-radius: 4px;
object-fit: contain;
}
</style>

View file

@ -0,0 +1,70 @@
<template>
<div class="plant-wrapper">
<img :src="plantStateStore.imageBase64" alt="">
<div class="loose">
<h2>Talk to us about your plant</h2>
<text-description></text-description>
</div>
<div class="precise">
<plant-details></plant-details>
</div>
<div class="footer">
<Button label="Simulate your plant" icon="pi pi-chart-line" size="large" @click="$router.push('/setEnvironment')"/>
<Button label="Get care suggestions" icon="pi pi-heart" size="large" @click="$router.push('/cure')"/>
</div>
</div>
</template>
<script setup>
import ImageScan from '@/components/imageScan.vue'
import textDescription from "@/components/textDescription.vue";
import plantDetails from '@/components/plantDetails.vue';
import Button from 'primevue/button';
import { usePlantStateStore } from "@/stores/plantState.js";
const plantStateStore = usePlantStateStore();
</script>
<style scoped>
@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Playwrite+HU:wght@100..400&family=Roboto+Flex:opsz,wght@8..144,100..1000&family=Rubik:ital,wght@0,300..900;1,300..900&display=swap');
h2 {
font-family: 'Playwrite HU', sans-serif;
font-weight: 600;
font-size: 1.5rem;
margin-bottom: 1rem;
}
.plant-wrapper {
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: start;
gap: 2rem;
overflow-y: scroll;
width: 100%;
}
.loose {
flex: 1;
display: flex;
flex-direction: column;
gap: 1rem;
}
.footer {
display: flex;
justify-content: stretch;
gap: 1rem;
width: 100%;
}
img {
max-height: 5rem;
border-radius: 4px;
object-fit: cover;
}
</style>

View file

@ -0,0 +1,115 @@
<script setup>
import { usePlantStateStore} from "@/stores/plantState.js";
import Card from 'primevue/card';
import Accordion from 'primevue/accordion';
import AccordionPanel from 'primevue/accordionpanel';
import AccordionHeader from 'primevue/accordionheader';
import AccordionContent from 'primevue/accordioncontent';
const plantStore = usePlantStateStore();
const textResponse = plantStore.text_response.split('.').filter(line => line.trim() !== '').map(line => line.trim());
</script>
<template>
<div class="simulation-result-wrapper">
<h1>Your plant has been successfully simulated!</h1>
<Card >
<template #title><i class="pi pi-cog"></i><em> Here's what we think will happen</em></template>
<template #content>
<ul>
<li class="list-item" v-for="(line, index) in textResponse" :key="index">{{ line }}</li>
</ul>
</template>
<template #footer>
<a class="gray" href="https://books.google.it/books/about/Horticultural_Crop_Response_to_Different.html?id=TPmnzgEACAAJ&source=kp_book_description&redir_esc=y">Source: Horticultural Crop Response to Different Environmental and Nutritional Stress - Stefano Marino</a>
</template>
</Card>
<div class="image-container">
<img :src="plantStore.imageBase64" class="image-first" alt="First image">
<img :src="plantStore.generatedImageBase64" class="image-second" alt="Second image">
</div>
<Accordion value="1">
<AccordionPanel value="0">
<AccordionHeader>Old Image</AccordionHeader>
<AccordionContent>
<img :src="plantStore.imageBase64" alt="Original Plant Image">
</AccordionContent>
</AccordionPanel>
</Accordion>
</div>
</template>
<style scoped>
@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Playwrite+HU:wght@100..400&family=Roboto+Flex:opsz,wght@8..144,100..1000&family=Rubik:ital,wght@0,300..900;1,300..900&display=swap');
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
h1 {
font-family: 'Playwrite HU', sans-serif;
margin-bottom: 1rem;
}
.list-item {
margin-bottom: 1rem;
margin-left: 1.5rem;
font-size: 110%;
}
ul {
margin-top: 0.5rem;
}
.gray {
opacity: 0.7;
}
img {
margin-top: 2rem;
max-height: 30rem;
width: 100%;
object-fit: cover;
border-radius: 5px;
}
.image-container {
position: relative;
width: 45rem;
height: 30rem;
margin-bottom: 10rem;
object-fit: contain;
}
.image-container img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.image-first {
opacity: 1;
animation: fadeOut 2s forwards;
animation-delay: 1s; /* Optional delay before starting */
}
.image-second {
opacity: 0;
animation: fadeIn 2s forwards;
animation-delay: 1; /* Match delay with fadeOut */
}
</style>

BIN
frontend/style/moss.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

16
frontend/style/styles.css Normal file
View file

@ -0,0 +1,16 @@
@import 'primeicons/primeicons.css';
#app {
height: 100%;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.p-button-label {
display: block;
padding-top: 4px;
}

27
frontend/vite.config.js Normal file
View file

@ -0,0 +1,27 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
server: {
proxy: {
'/api': {
target: 'http://10.100.1.111:5000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
}
})