This commit is contained in:
che
2026-07-23 09:31:01 +05:00
commit 4cfc5d6ae6
64 changed files with 9779 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+7
View File
@@ -0,0 +1,7 @@
{
"recommendations": [
"Vue.volar",
"tauri-apps.tauri-vscode",
"rust-lang.rust-analyzer"
]
}
+7
View File
@@ -0,0 +1,7 @@
# Tauri + Vue + TypeScript
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Vue - Official](https://marketplace.visualstudio.com/items?itemName=Vue.volar) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri + Vue + Typescript App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1843
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "ewa-mobile",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"vant": "^4.10.0",
"vue": "^3.5.13",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "~5.6.2",
"vite": "^6.0.3",
"vue-tsc": "^2.1.10"
}
}
+6
View File
@@ -0,0 +1,6 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+12
View File
@@ -0,0 +1,12 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
# Local SQLite databases
*.sqlite
*.sqlite-shm
*.sqlite-wal
+6155
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "ewa-mobile"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
default-run = "ewa-mobile"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "ewa_mobile_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
che-orm = { path = "../../che-orm/crates/che-orm" }
che-tauri = { path = "../../che-tauri" }
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
+5
View File
@@ -0,0 +1,5 @@
[database]
url = "sqlite://ewa-mobile.sqlite?mode=rwc"
[remote]
base_url = "http://localhost:3000"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+10
View File
@@ -0,0 +1,10 @@
pub mod personemanagment;
pub mod users;
use che_tauri::InstalledApps;
pub fn installed_apps() -> InstalledApps {
InstalledApps::new()
.add(users::module())
.add(personemanagment::module())
}
@@ -0,0 +1,35 @@
use che_tauri::{Filter, FilterSet};
use super::models::{Employee, Message, Task};
static EMPLOYEE_FILTERS: &[Filter] = &[
Filter::exact("id"),
Filter::exact("name"),
Filter::contains("name"),
];
static TASK_FILTERS: &[Filter] = &[
Filter::exact("id"),
Filter::exact("name"),
Filter::contains("name"),
Filter::exact("author_id"),
Filter::exact("responsible_id"),
];
static MESSAGE_FILTERS: &[Filter] = &[
Filter::exact("id"),
Filter::exact("task_id"),
Filter::exact("employee_id"),
];
pub fn employee_filterset() -> FilterSet<Employee> {
FilterSet::new(EMPLOYEE_FILTERS)
}
pub fn task_filterset() -> FilterSet<Task> {
FilterSet::new(TASK_FILTERS)
}
pub fn message_filterset() -> FilterSet<Message> {
FilterSet::new(MESSAGE_FILTERS)
}
@@ -0,0 +1,4 @@
CREATE TABLE IF NOT EXISTS task (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL
);
@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS employee (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
avatar TEXT NOT NULL
);
ALTER TABLE task ADD COLUMN author_id INTEGER REFERENCES employee(id);
ALTER TABLE task ADD COLUMN responsible_id INTEGER REFERENCES employee(id);
@@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS message (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL REFERENCES task(id),
employee_id INTEGER NOT NULL REFERENCES employee(id),
text TEXT NOT NULL
);
@@ -0,0 +1,152 @@
{
"models": [
{
"table": "employee",
"fields": [
{
"name": "id",
"ty": "integer",
"primary_key": true,
"nullable": false,
"auto": true,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "name",
"ty": "text",
"primary_key": false,
"nullable": false,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "avatar",
"ty": "text",
"primary_key": false,
"nullable": false,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
}
]
},
{
"table": "message",
"fields": [
{
"name": "id",
"ty": "integer",
"primary_key": true,
"nullable": false,
"auto": true,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "task_id",
"ty": "integer",
"primary_key": false,
"nullable": false,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": {
"table": "task",
"column": "id"
}
},
{
"name": "employee_id",
"ty": "integer",
"primary_key": false,
"nullable": false,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": {
"table": "employee",
"column": "id"
}
},
{
"name": "text",
"ty": "text",
"primary_key": false,
"nullable": false,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
}
]
},
{
"table": "task",
"fields": [
{
"name": "id",
"ty": "integer",
"primary_key": true,
"nullable": false,
"auto": true,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "name",
"ty": "text",
"primary_key": false,
"nullable": false,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": null
},
{
"name": "author_id",
"ty": "integer",
"primary_key": false,
"nullable": true,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": {
"table": "employee",
"column": "id"
}
},
{
"name": "responsible_id",
"ty": "integer",
"primary_key": false,
"nullable": true,
"auto": false,
"unique": false,
"max_length": null,
"default": null,
"foreign_key": {
"table": "employee",
"column": "id"
}
}
]
}
]
}
@@ -0,0 +1,35 @@
pub mod filters;
pub mod models;
pub mod serializers;
use che_tauri::{AppModule, ModuleContext};
pub fn module() -> TaskModule {
TaskModule
}
pub struct TaskModule;
impl AppModule for TaskModule {
fn name(&self) -> &'static str {
"personemanagment"
}
fn init(&self, ctx: &mut ModuleContext) {
ctx.resource::<models::Employee>(
"employee",
serializers::employee_serializer(),
filters::employee_filterset(),
);
ctx.resource::<models::Task>(
"task",
serializers::task_serializer(),
filters::task_filterset(),
);
ctx.resource::<models::Message>(
"message",
serializers::message_serializer(),
filters::message_filterset(),
);
}
}
@@ -0,0 +1,42 @@
use che_orm::Model;
#[derive(Debug, Clone, Model)]
#[model(table = "employee")]
pub struct Employee {
#[field(primary_key)]
pub id: i64,
pub name: String,
pub avatar: String,
}
#[derive(Debug, Clone, Model)]
#[model(table = "task")]
pub struct Task {
#[field(primary_key)]
pub id: i64,
pub name: String,
#[field(foreign_key = Employee)]
pub author_id: Option<i64>,
#[field(foreign_key = Employee)]
pub responsible_id: Option<i64>,
}
#[derive(Debug, Clone, Model)]
#[model(table = "message")]
pub struct Message {
#[field(primary_key)]
pub id: i64,
#[field(foreign_key = Task)]
pub task_id: i64,
#[field(foreign_key = Employee)]
pub employee_id: i64,
pub text: String,
}
@@ -0,0 +1,35 @@
use che_tauri::{Field, ModelSerializer};
use super::models::{Employee, Message, Task};
static EMPLOYEE_FIELDS: &[Field] = &[
Field::new("id").read_only(),
Field::new("name"),
Field::new("avatar"),
];
static TASK_FIELDS: &[Field] = &[
Field::new("id").read_only(),
Field::new("name"),
Field::new("author_id").required(false).nullable(),
Field::new("responsible_id").required(false).nullable(),
];
static MESSAGE_FIELDS: &[Field] = &[
Field::new("id").read_only(),
Field::new("task_id"),
Field::new("employee_id"),
Field::new("text"),
];
pub fn employee_serializer() -> ModelSerializer<Employee> {
ModelSerializer::new(EMPLOYEE_FIELDS)
}
pub fn task_serializer() -> ModelSerializer<Task> {
ModelSerializer::new(TASK_FIELDS)
}
pub fn message_serializer() -> ModelSerializer<Message> {
ModelSerializer::new(MESSAGE_FIELDS)
}
+13
View File
@@ -0,0 +1,13 @@
use che_tauri::{Filter, FilterSet};
use super::models::User;
static USERS_FILTERS: &[Filter] = &[
Filter::exact("id"),
Filter::exact("name"),
Filter::contains("name"),
];
pub fn user_filterset() -> FilterSet<User> {
FilterSet::new(USERS_FILTERS)
}
+26
View File
@@ -0,0 +1,26 @@
pub mod filters;
pub mod models;
pub mod serializers;
use che_tauri::{AppModule, ModuleContext};
pub fn module() -> UsersModule {
UsersModule
}
pub struct UsersModule;
impl AppModule for UsersModule {
fn name(&self) -> &'static str {
"users"
}
fn init(&self, ctx: &mut ModuleContext) {
ctx.remote_resource::<models::User>(
"users",
"/api/users",
serializers::user_serializer(),
filters::user_filterset(),
);
}
}
+10
View File
@@ -0,0 +1,10 @@
use che_orm::Model;
#[derive(Debug, Clone, Model)]
#[model(table = "users")]
pub struct User {
#[field(primary_key)]
pub id: i64,
pub name: String,
}
+9
View File
@@ -0,0 +1,9 @@
use che_tauri::{Field, ModelSerializer};
use super::models::User;
static USERS_FIELDS: &[Field] = &[Field::new("id").read_only(), Field::new("name")];
pub fn user_serializer() -> ModelSerializer<User> {
ModelSerializer::new(USERS_FIELDS)
}
+8
View File
@@ -0,0 +1,8 @@
use che_tauri::Management;
use ewa_mobile_lib::apps;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
Management::new(apps::installed_apps()).run().await
}
+35
View File
@@ -0,0 +1,35 @@
pub mod apps;
use che_tauri::{ApiError, ApiRequest, AppState, TauriApi};
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
#[tauri::command]
async fn che_api(
api: tauri::State<'_, TauriApi>,
request: ApiRequest,
) -> Result<serde_json::Value, ApiError> {
api.dispatch(request).await
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let api = tauri::async_runtime::block_on(async {
let state = AppState::from_config_file("app.toml").await?;
TauriApi::new(state)
.install(apps::installed_apps())
.build()
.await
})
.expect("failed to initialize che-tauri");
tauri::Builder::default()
.manage(api)
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![greet, che_api])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
ewa_mobile_lib::run()
}
+35
View File
@@ -0,0 +1,35 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ewa-mobile",
"version": "0.1.0",
"identifier": "com.che.ewa-mobile",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "ewa-mobile",
"width": 800,
"height": 600
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
+167
View File
@@ -0,0 +1,167 @@
<script setup lang="ts">
import { computed } from "vue";
import { useRoute, useRouter } from "vue-router";
const route = useRoute();
const router = useRouter();
const activeTab = computed({
get() {
return route.path.startsWith("/users") ? "/users" : "/tasks";
},
set(path: string) {
if (path !== route.path) {
router.push(path);
}
},
});
const title = computed(() => String(route.meta.title ?? "EWA Mobile"));
const showBack = computed(() => Boolean(route.meta.back));
</script>
<template>
<van-nav-bar :title="title" :left-arrow="showBack" fixed placeholder @click-left="router.back()" />
<main class="page">
<router-view />
</main>
<van-tabbar v-model="activeTab" route placeholder safe-area-inset-bottom>
<van-tabbar-item to="/tasks" name="/tasks" icon="todo-list-o">Задачи</van-tabbar-item>
<van-tabbar-item to="/users" name="/users" icon="friends-o">Пользователи</van-tabbar-item>
</van-tabbar>
</template>
<style>
:root {
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
color: #172033;
background: #f7f8fa;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background: #f7f8fa;
}
.page {
box-sizing: border-box;
width: min(760px, 100%);
margin: 0 auto;
padding: 24px 14px 40px;
}
.hero {
margin: 12px 2px 18px;
}
.eyebrow {
margin: 0 0 6px;
color: #1989fa;
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
h1 {
margin: 0;
font-size: clamp(2rem, 9vw, 4rem);
line-height: 1;
}
.subtitle {
margin: 12px 0 0;
color: #646566;
}
.notice {
margin-bottom: 12px;
border-radius: 14px;
}
.card {
overflow: hidden;
margin-bottom: 14px;
border-radius: 18px;
background: #fff;
box-shadow: 0 12px 40px rgba(36, 42, 56, 0.08);
}
.form-actions {
padding: 14px 16px 16px;
}
.list-card {
padding: 14px 0 16px;
}
.list-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 16px 12px;
}
.list-actions {
display: flex;
align-items: center;
gap: 8px;
}
.stacked-actions {
display: grid;
gap: 10px;
}
h2 {
margin: 0;
font-size: 1.1rem;
}
.state {
justify-content: center;
padding: 34px 0;
}
.detail-card {
padding: 14px 0 16px;
}
.detail-actions {
padding: 16px 16px 0;
}
.employee-popup {
box-sizing: border-box;
max-height: 78vh;
padding: 16px 0 22px;
}
.employee-popup-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 16px 12px;
}
.employee-avatar {
margin-right: 10px;
}
</style>
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

+136
View File
@@ -0,0 +1,136 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { employeeApi } from "../generated/api";
import { useModelApi } from "../composables/useModelApi";
const selectedEmployeeId = defineModel<number>({ required: true });
const search = ref("");
const showSelector = ref(false);
const {
items: employees,
loading,
load: loadEmployees,
} = useModelApi(employeeApi, {
defaultListParams: { ordering: "id" },
loadErrorMessage: "Не удалось загрузить сотрудников",
});
const filteredEmployees = computed(() => {
const value = search.value.trim().toLowerCase();
if (!value) {
return employees.value;
}
return employees.value.filter((employee) => employee.name.toLowerCase().includes(value));
});
const selectedEmployee = computed(() =>
employees.value.find((employee) => employee.id === selectedEmployeeId.value),
);
const selectedEmployeeName = computed(() => selectedEmployee.value?.name ?? "не указан");
function openSelector() {
search.value = "";
showSelector.value = true;
}
function selectEmployee(id: number) {
selectedEmployeeId.value = id;
showSelector.value = false;
}
onMounted(loadEmployees);
</script>
<template>
<button class="employee-select" type="button" @click="openSelector">
<span>{{ selectedEmployeeName }}</span>
<van-icon name="arrow" />
</button>
<van-popup v-model:show="showSelector" round position="bottom" class="employee-popup">
<div class="employee-popup-header">
<h2>Выберите сотрудника</h2>
<van-button size="small" type="primary" plain @click="selectEmployee(0)">Не указан</van-button>
</div>
<van-search v-model="search" placeholder="Поиск по имени" />
<van-loading v-if="loading" class="employee-state" type="spinner">Загрузка...</van-loading>
<template v-else>
<van-cell-group inset>
<van-cell
v-for="employee in filteredEmployees"
:key="employee.id"
:title="employee.name"
:label="`ID: ${employee.id}`"
clickable
center
@click="selectEmployee(employee.id)"
>
<template #icon>
<van-image class="employee-avatar" round width="36" height="36" :src="employee.avatar" />
</template>
<template #right-icon>
<van-icon v-if="selectedEmployeeId === employee.id" name="success" color="#1989fa" />
</template>
</van-cell>
</van-cell-group>
<van-empty v-if="filteredEmployees.length === 0" description="Сотрудники не найдены" />
</template>
</van-popup>
</template>
<style scoped>
.employee-popup {
min-height: 55vh;
padding: 18px 0 28px;
}
.employee-popup-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 18px 10px;
}
.employee-popup-header h2 {
margin: 0;
font-size: 18px;
}
.employee-avatar {
margin-right: 10px;
}
.employee-state {
display: flex;
justify-content: center;
padding: 36px 0;
}
.employee-select {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
width: 100%;
border: 0;
padding: 0;
color: #323233;
background: transparent;
font: inherit;
line-height: 24px;
text-align: right;
}
.employee-select .van-icon {
color: #969799;
font-size: 16px;
}
</style>
+103
View File
@@ -0,0 +1,103 @@
import { ref, shallowRef } from "vue";
import { showToast } from "vant";
import type { BaseEntity, ListParams, ModelApi } from "../generated/api_client";
interface UseModelApiOptions<Params> {
defaultListParams?: Params;
loadErrorMessage?: string;
retrieveErrorMessage?: string;
createErrorMessage?: string;
showErrorToast?: boolean;
}
export function useModelApi<
T extends BaseEntity,
CreateDTO = Partial<T>,
UpdateDTO = Partial<T>,
Params extends ListParams = ListParams,
>(api: ModelApi<T, CreateDTO, UpdateDTO, Params>, options: UseModelApiOptions<Params> = {}) {
const items = shallowRef<T[]>([]);
const item = shallowRef<T | null>(null);
const loading = ref(false);
const loadingItem = ref(false);
const creating = ref(false);
const error = ref("");
async function load(params?: Params) {
loading.value = true;
error.value = "";
try {
const response = await api.list(params ?? options.defaultListParams);
items.value = response.results;
} catch (err) {
handleError(err, options.loadErrorMessage ?? "Не удалось загрузить данные");
} finally {
loading.value = false;
}
}
async function create(payload: CreateDTO) {
creating.value = true;
error.value = "";
try {
const item = await api.create(payload);
items.value = [...items.value, item];
return item;
} catch (err) {
handleError(err, options.createErrorMessage ?? "Не удалось создать объект");
return null;
} finally {
creating.value = false;
}
}
async function retrieve(id: number) {
loadingItem.value = true;
error.value = "";
try {
item.value = await api.retrieve(id);
return item.value;
} catch (err) {
handleError(err, options.retrieveErrorMessage ?? "Не удалось загрузить объект");
return null;
} finally {
loadingItem.value = false;
}
}
function handleError(err: unknown, fallback: string) {
const message = errorMessage(err, fallback);
error.value = message;
if (options.showErrorToast !== false) {
showToast(message);
}
}
return {
items,
item,
loading,
loadingItem,
creating,
error,
load,
retrieve,
create,
};
}
function errorMessage(err: unknown, fallback: string) {
if (typeof err === "object" && err && "detail" in err) {
return String((err as { detail: unknown }).detail);
}
if (err instanceof Error) {
return err.message;
}
return fallback;
}
+24
View File
@@ -0,0 +1,24 @@
import { createModelApi } from "./api_client";
import type {
Employee,
EmployeeCreate,
EmployeeUpdate,
EmployeeListParams,
Message,
MessageCreate,
MessageUpdate,
MessageListParams,
Task,
TaskCreate,
TaskUpdate,
TaskListParams,
User,
UserCreate,
UserUpdate,
UserListParams,
} from "./models";
export const employeeApi = createModelApi<Employee, EmployeeCreate, EmployeeUpdate, EmployeeListParams>("employee");
export const messageApi = createModelApi<Message, MessageCreate, MessageUpdate, MessageListParams>("message");
export const taskApi = createModelApi<Task, TaskCreate, TaskUpdate, TaskListParams>("task");
export const userApi = createModelApi<User, UserCreate, UserUpdate, UserListParams>("users");
+86
View File
@@ -0,0 +1,86 @@
import { invoke } from "@tauri-apps/api/core";
export interface BaseEntity {
id?: number;
}
export interface ListParams {
ordering?: string;
limit?: number;
offset?: number;
[key: string]: string | number | boolean | null | undefined;
}
export interface PaginatedResponse<T> {
count: number;
results: T[];
}
export interface ApiError {
code: string;
detail: string;
}
export interface ModelApi<
T extends BaseEntity,
CreateDTO = Partial<T>,
UpdateDTO = Partial<T>,
Params extends ListParams = ListParams,
> {
list: (params?: Params) => Promise<PaginatedResponse<T>>;
retrieve: (id: number) => Promise<T>;
create: (payload: CreateDTO) => Promise<T>;
update: (id: number, payload: UpdateDTO) => Promise<T>;
remove: (id: number) => Promise<{ deleted: true }>;
}
export function createModelApi<
T extends BaseEntity,
CreateDTO = Partial<T>,
UpdateDTO = Partial<T>,
Params extends ListParams = ListParams,
>(resource: string): ModelApi<T, CreateDTO, UpdateDTO, Params> {
return {
list(params) {
return invoke<PaginatedResponse<T>>("che_api", {
request: { resource, action: "list", params: serializeListParams(params) },
});
},
retrieve(id) {
return invoke<T>("che_api", {
request: { resource, action: "retrieve", id },
});
},
create(payload) {
return invoke<T>("che_api", {
request: { resource, action: "create", payload },
});
},
update(id, payload) {
return invoke<T>("che_api", {
request: { resource, action: "update", id, payload },
});
},
remove(id) {
return invoke<{ deleted: true }>("che_api", {
request: { resource, action: "delete", id },
});
},
};
}
function serializeListParams(params?: ListParams) {
const serialized: Record<string, string> = {};
for (const [key, value] of Object.entries(params ?? {})) {
if (value !== null && value !== undefined) {
serialized[key] = String(value);
}
}
return serialized;
}
+95
View File
@@ -0,0 +1,95 @@
import type { ListParams } from "./api_client";
export interface Employee {
id: number;
name: string;
avatar: string;
}
export interface EmployeeCreate {
name: string;
avatar: string;
}
export interface EmployeeUpdate {
name?: string;
avatar?: string;
}
export interface EmployeeListParams extends ListParams {
id?: number;
name?: string;
name__contains?: string;
}
export interface Message {
id: number;
task_id: number;
employee_id: number;
text: string;
}
export interface MessageCreate {
task_id: number;
employee_id: number;
text: string;
}
export interface MessageUpdate {
task_id?: number;
employee_id?: number;
text?: string;
}
export interface MessageListParams extends ListParams {
id?: number;
task_id?: number;
employee_id?: number;
}
export interface Task {
id: number;
name: string;
author_id: number | null;
responsible_id: number | null;
}
export interface TaskCreate {
name: string;
author_id?: number | null;
responsible_id?: number | null;
}
export interface TaskUpdate {
name?: string;
author_id?: number | null;
responsible_id?: number | null;
}
export interface TaskListParams extends ListParams {
id?: number;
name?: string;
name__contains?: string;
author_id?: number | null;
responsible_id?: number | null;
}
export interface User {
id: number;
name: string;
}
export interface UserCreate {
name: string;
}
export interface UserUpdate {
name?: string;
}
export interface UserListParams extends ListParams {
id?: number;
name?: string;
name__contains?: string;
}
+7
View File
@@ -0,0 +1,7 @@
import { createApp } from "vue";
import Vant from "vant";
import "vant/lib/index.css";
import App from "./App.vue";
import { router } from "./router";
createApp(App).use(router).use(Vant).mount("#app");
+39
View File
@@ -0,0 +1,39 @@
import { createRouter, createWebHashHistory } from "vue-router";
import TaskCreateView from "../views/TaskCreateView.vue";
import TaskDetailView from "../views/TaskDetailView.vue";
import TasksView from "../views/TasksView.vue";
import UsersView from "../views/UsersView.vue";
export const router = createRouter({
history: createWebHashHistory(),
routes: [
{
path: "/",
redirect: "/tasks",
},
{
path: "/tasks",
name: "tasks",
component: TasksView,
meta: { title: "Задачи" },
},
{
path: "/tasks/new",
name: "task-create",
component: TaskCreateView,
meta: { title: "Новая задача", back: true },
},
{
path: "/tasks/:id",
name: "task-detail",
component: TaskDetailView,
meta: { title: "Детали задачи", back: true },
},
{
path: "/users",
name: "users",
component: UsersView,
meta: { title: "Пользователи" },
},
],
});
+95
View File
@@ -0,0 +1,95 @@
<script setup lang="ts">
import { ref } from "vue";
import { useRouter } from "vue-router";
import { showToast } from "vant";
import { taskApi } from "../generated/api";
import { useModelApi } from "../composables/useModelApi";
import EmployeeSelect from "../components/EmployeeSelect.vue";
const router = useRouter();
const newTaskName = ref("");
const selectedAuthorId = ref(0);
const selectedResponsibleId = ref(0);
const {
creating,
error,
create: createTaskApi,
} = useModelApi(taskApi, {
createErrorMessage: "Не удалось создать задачу",
});
async function createTask() {
const name = newTaskName.value.trim();
if (!name) {
showToast("Введите название задачи");
return;
}
const task = await createTaskApi({
name,
author_id: selectedAuthorId.value || null,
responsible_id: selectedResponsibleId.value || null,
});
if (task) {
showToast("Задача создана");
router.push("/tasks");
}
}
</script>
<template>
<section class="hero">
<p class="eyebrow">New task</p>
<h1>Создать задачу</h1>
<p class="subtitle">Заполните название и при необходимости выберите автора и ответственного.</p>
</section>
<van-notice-bar
v-if="error"
class="notice"
color="#991b1b"
background="#fee2e2"
left-icon="warning-o"
wrapable
:scrollable="false"
:text="error"
/>
<van-form class="card" @submit="createTask">
<van-field
v-model="newTaskName"
name="name"
label="Название"
placeholder="Например: Купить молоко"
clearable
:disabled="creating"
/>
<van-field label="Автор">
<template #input>
<EmployeeSelect v-model="selectedAuthorId" />
</template>
</van-field>
<van-field label="Ответственный">
<template #input>
<EmployeeSelect v-model="selectedResponsibleId" />
</template>
</van-field>
<div class="form-actions stacked-actions">
<van-button
block
round
type="primary"
native-type="submit"
:loading="creating"
:disabled="!newTaskName.trim()"
>
Создать задачу
</van-button>
<van-button block round plain type="default" @click="router.back()">Отмена</van-button>
</div>
</van-form>
</template>
+191
View File
@@ -0,0 +1,191 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { showToast } from "vant";
import { employeeApi, messageApi, taskApi } from "../generated/api";
import { useModelApi } from "../composables/useModelApi";
import EmployeeSelect from "../components/EmployeeSelect.vue";
const route = useRoute();
const router = useRouter();
const taskId = computed(() => Number(route.params.id));
const messageText = ref("");
const selectedEmployeeId = ref(0);
const {
item: task,
loadingItem,
error,
retrieve: loadTask,
} = useModelApi(taskApi, {
retrieveErrorMessage: "Не удалось загрузить задачу",
});
const {
items: messages,
loading: loadingMessages,
creating: sendingMessage,
error: messageError,
load: loadMessages,
create: createMessageApi,
} = useModelApi(messageApi, {
loadErrorMessage: "Не удалось загрузить сообщения",
createErrorMessage: "Не удалось отправить сообщение",
});
const { items: employees, load: loadEmployees } = useModelApi(employeeApi, {
defaultListParams: { ordering: "id" },
loadErrorMessage: "Не удалось загрузить сотрудников",
});
async function sendMessage() {
if (!Number.isFinite(taskId.value)) {
showToast("Некорректный ID задачи");
return;
}
const text = messageText.value.trim();
if (!text) {
showToast("Введите сообщение");
return;
}
if (!selectedEmployeeId.value) {
showToast("Выберите сотрудника");
return;
}
const message = await createMessageApi({
task_id: taskId.value,
employee_id: selectedEmployeeId.value,
text,
});
if (message) {
messageText.value = "";
await loadMessages({ task_id: taskId.value });
showToast("Сообщение отправлено");
}
}
function employeeName(id: number) {
return employees.value.find((employee) => employee.id === id)?.name ?? `#${id}`;
}
function employeeAvatar(id: number) {
return employees.value.find((employee) => employee.id === id)?.avatar ?? "";
}
onMounted(() => {
if (Number.isFinite(taskId.value)) {
loadTask(taskId.value);
loadMessages({ task_id: taskId.value });
}
loadEmployees();
});
</script>
<template>
<section class="hero detail-hero">
<p class="eyebrow">Task details</p>
<h1>Детали</h1>
<p class="subtitle">Просмотр задачи и диалог сотрудников.</p>
</section>
<van-notice-bar
v-if="error || messageError"
class="notice"
color="#991b1b"
background="#fee2e2"
left-icon="warning-o"
wrapable
:scrollable="false"
:text="error || messageError"
/>
<section class="card detail-card">
<van-loading v-if="loadingItem" class="state" type="spinner">Загрузка...</van-loading>
<van-empty v-else-if="!task" description="Задача не найдена" />
<template v-else>
<van-cell-group inset>
<van-cell title="ID" :value="task.id" />
<van-cell title="Название" :value="task.name" />
<van-cell title="Автор" :value="task.author_id ?? 'не указан'" />
<van-cell title="Ответственный" :value="task.responsible_id ?? 'не указан'" />
</van-cell-group>
</template>
</section>
<section class="card messages-card">
<div class="list-title">
<h2>Диалог</h2>
<van-button
size="small"
plain
type="primary"
:loading="loadingMessages"
@click="loadMessages({ task_id: taskId })"
>
Обновить
</van-button>
</div>
<van-loading v-if="loadingMessages && messages.length === 0" class="state" type="spinner">
Загрузка сообщений...
</van-loading>
<van-empty v-else-if="messages.length === 0" description="Сообщений пока нет" />
<div v-else class="message-list">
<article v-for="message in messages" :key="message.id" class="message-item">
<van-image
class="message-avatar"
round
width="40"
height="40"
:src="employeeAvatar(message.employee_id)"
/>
<div class="message-bubble">
<div class="message-author">{{ employeeName(message.employee_id) }}</div>
<div class="message-text">{{ message.text }}</div>
</div>
</article>
</div>
</section>
<van-form class="card" @submit="sendMessage">
<van-field
name="employee_id"
label="Отправитель"
placeholder="Выберите сотрудника"
>
<template #input>
<EmployeeSelect v-model="selectedEmployeeId" />
</template>
</van-field>
<van-field
v-model="messageText"
name="text"
label="Сообщение"
type="textarea"
rows="2"
autosize
clearable
placeholder="Напишите сообщение"
:disabled="sendingMessage"
/>
<div class="form-actions stacked-actions">
<van-button
block
round
type="primary"
native-type="submit"
:loading="sendingMessage"
:disabled="!Number.isFinite(taskId) || !messageText.trim() || !selectedEmployeeId"
>
Отправить сообщение
</van-button>
<van-button block round type="primary" plain @click="router.back()">Назад</van-button>
</div>
</van-form>
</template>
+90
View File
@@ -0,0 +1,90 @@
<script setup lang="ts">
import { onMounted } from "vue";
import { useRouter } from "vue-router";
import { employeeApi, taskApi } from "../generated/api";
import { useModelApi } from "../composables/useModelApi";
const router = useRouter();
const {
items: tasks,
loading,
error,
load: loadTasks,
} = useModelApi(taskApi, {
defaultListParams: { ordering: "id" },
loadErrorMessage: "Не удалось загрузить задачи",
});
const { items: employees, load: loadEmployees } = useModelApi(employeeApi, {
defaultListParams: { ordering: "id" },
loadErrorMessage: "Не удалось загрузить сотрудников",
});
function employeeName(id: number | null) {
if (id === null || id === 0) {
return "не указан";
}
return employees.value.find((employee) => employee.id === id)?.name ?? `#${id}`;
}
onMounted(() => {
loadTasks();
loadEmployees();
});
</script>
<template>
<section class="hero">
<p class="eyebrow">Task manager</p>
<h1>Мои задачи</h1>
<p class="subtitle">Данные загружаются через `che_api` и отображаются компонентами Vant.</p>
</section>
<van-notice-bar
v-if="error"
class="notice"
color="#991b1b"
background="#fee2e2"
left-icon="warning-o"
wrapable
:scrollable="false"
:text="error"
/>
<section class="card list-card">
<div class="list-title">
<h2>Список</h2>
<div class="list-actions">
<van-button size="small" plain type="primary" :loading="loading" @click="loadTasks()">
Обновить
</van-button>
<van-button size="small" round type="primary" icon="plus" @click="router.push('/tasks/new')" />
</div>
</div>
<van-loading v-if="loading && tasks.length === 0" class="state" type="spinner">
Загрузка...
</van-loading>
<van-empty v-else-if="tasks.length === 0" description="Задач пока нет" />
<van-cell-group v-else inset>
<van-cell
v-for="task in tasks"
:key="task.id"
:title="task.name"
center
is-link
@click="router.push(`/tasks/${task.id}`)"
>
<template #label>
ID: {{ task.id }} · Автор: {{ employeeName(task.author_id) }} · Ответственный:
{{ employeeName(task.responsible_id) }}
</template>
<template #right-icon>
<van-tag plain type="primary">Task</van-tag>
</template>
</van-cell>
</van-cell-group>
</section>
</template>
+60
View File
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { onMounted } from "vue";
import { userApi } from "../generated/api";
import { useModelApi } from "../composables/useModelApi";
const {
items: users,
loading,
error,
load: loadUsers,
} = useModelApi(userApi, {
defaultListParams: { ordering: "id" },
loadErrorMessage: "Не удалось загрузить пользователей",
});
onMounted(loadUsers);
</script>
<template>
<section class="hero">
<p class="eyebrow">Directory</p>
<h1>Пользователи</h1>
<p class="subtitle">Вторая страница для проверки маршрутизации и панели навигации.</p>
</section>
<van-notice-bar
v-if="error"
class="notice"
color="#991b1b"
background="#fee2e2"
left-icon="warning-o"
wrapable
:scrollable="false"
:text="error"
/>
<section class="card list-card">
<div class="list-title">
<h2>Список</h2>
<van-button size="small" plain type="primary" :loading="loading" @click="loadUsers()">
Обновить
</van-button>
</div>
<van-loading v-if="loading && users.length === 0" class="state" type="spinner">
Загрузка...
</van-loading>
<van-empty v-else-if="users.length === 0" description="Пользователей пока нет" />
<van-cell-group v-else inset>
<van-cell v-for="user in users" :key="user.id" :title="user.name" center>
<template #label> ID: {{ user.id }} </template>
<template #right-icon>
<van-tag plain type="success">User</van-tag>
</template>
</van-cell>
</van-cell-group>
</section>
</template>
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+32
View File
@@ -0,0 +1,32 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/
export default defineConfig(async () => ({
plugins: [vue()],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent Vite from obscuring rust errors
clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: "ws",
host,
port: 1421,
}
: undefined,
watch: {
// 3. tell Vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
},
},
}));